diff --git a/manual_trading_hub/AI教练说明.md b/manual_trading_hub/AI教练说明.md
index f3eb29b..b81e3ce 100644
--- a/manual_trading_hub/AI教练说明.md
+++ b/manual_trading_hub/AI教练说明.md
@@ -6,15 +6,18 @@
| 功能 | 说明 |
|------|------|
-| **今日总结** | 聚合四户(含未启用 →「未监控」)当日平仓、持仓浮盈亏、连接状态;语气偏冷、台账式 |
-| **AI 聊天** | 单会话直到点「新开对话」;口语化、安慰体贴、轻修正;注入监控快照与今日总结摘要 |
+| **交易教练** | 口语化陪聊;注入四户监控快照与今日总结摘要(后台自动生成,不在页面展示) |
+| **普通聊天** | 不绑交易数据,适合闲聊、答疑 |
+| **会话历史** | 右侧列表:切换、删除;消息一键复制 |
+
+页面仅保留 **交易教练 / 普通聊天** 两个机器人和聊天区;**今日总结** 已移至 **数据看板**(`/dashboard`)纯数据展示,不再在 AI 页生成。
## 存储
与 `hub_settings.json` 同目录(`manual_trading_hub/`):
-- `hub_ai_summaries.json` — 历史总结
-- `hub_ai_chat.json` — 聊天会话(`active_session_id` 指向当前会话)
+- `hub_ai_summaries.json` — 历史总结(供交易教练上下文,可选 API 仍保留)
+- `hub_ai_chat.json` — 聊天会话(`active_session_id`、多会话、`bot_mode`)
升级 / 迁移时请一并备份(见 [本地数据迁移到云端.md](./本地数据迁移到云端.md))。
@@ -56,7 +59,7 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|--|-------------|-------------|
| 入口 | `/ai` | 各所 `/records` |
| 数据 | 四户聚合 | 单户 `journal_entries` |
-| 语气 | 总结冷 / 聊天搭档 | 结构化教练报告 |
+| 语气 | 聊天搭档 | 结构化教练报告 |
| 代码 | `hub_ai/*` | `ai_review_lib` + 各 `app.py` |
详见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)(实例侧)。
diff --git a/manual_trading_hub/README.md b/manual_trading_hub/README.md
index 7c1dda8..dd843c7 100644
--- a/manual_trading_hub/README.md
+++ b/manual_trading_hub/README.md
@@ -1,6 +1,6 @@
# 复盘系统中控(manual_trading_hub)
-> **完整说明**:[使用说明.md](./使用说明.md) · **资金概况**:[资金概况说明.md](./资金概况说明.md) · **AI 教练**:[AI教练说明.md](./AI教练说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**:[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**:[常见问题.md](./常见问题.md)
+> **完整说明**:[使用说明.md](./使用说明.md) · **资金概况**:[资金概况说明.md](./资金概况说明.md) · **数据看板**:[数据看板说明.md](./数据看板说明.md) · **AI 教练**:[AI教练说明.md](./AI教练说明.md) · **行情区**:[行情区说明.md](./行情区说明.md) · **部署**:[部署文档.md](./部署文档.md) · **云服务器**:[云服务器部署说明.md](./云服务器部署说明.md) · **本地→云端迁移**:[本地数据迁移到云端.md](./本地数据迁移到云端.md) · **局域网/反代**:[局域网与反代部署说明.md](./局域网与反代部署说明.md) · **故障**:[常见问题.md](./常见问题.md)
多账户 **监控聚合 + 紧急全平**;**不在中控网页下单**。人工下单、关键位、**策略交易**(`/strategy`)、复盘请在各 `crypto_monitor_*` 实例网页操作(监控卡片 **「实例」** / **「复盘」**)。**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate)。
@@ -12,8 +12,9 @@
|------|------|
| 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) |
| 资金概况 | 总/分户资金(资金户+交易户)、180 日曲线、最大回撤 |
+| **数据看板** | 四户当日总览/分户/平仓明细,SSE 推送(`/dashboard`;见 [数据看板说明.md](./数据看板说明.md)) |
| 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) |
-| **AI 教练** | 四户今日总结 + 口语化聊天(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) |
+| **AI 教练** | 交易教练 + 普通聊天、会话历史(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) |
| 紧急全平 | 单户 / 全局市价减仓 |
| 系统设置 | `hub_settings.json` 管理 URL、启用、**监控关键位 / 监控趋势计划**(不控制策略交易页) |
| Web 登录 | `.env` 设 `HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) |
@@ -24,7 +25,7 @@
## 架构
```
-浏览器 → hub.py (:5100) 监控 / 资金概况 / 行情 / **AI 教练** / 设置 / 登录
+浏览器 → hub.py (:5100) 监控 / 资金概况 / **数据看板** / 行情 / **AI 教练** / 设置 / 登录
├→ agent.py × N (:15200~15203) 持仓、全平
└→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合
```
diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py
index 244e709..752c816 100644
--- a/manual_trading_hub/hub.py
+++ b/manual_trading_hub/hub.py
@@ -90,6 +90,8 @@ from url_public import browser_url, default_review_url, public_origin
from urllib.parse import urlencode
from hub_board_cache import HUB_BOARD_POLL_INTERVAL, board_store
+from hub_dashboard_cache import dashboard_store
+from hub_dashboard import DASHBOARD_POLL_INTERVAL_SEC
from hub_chart_cache import (
HUB_CHART_POLL_INTERVAL,
HUB_CHART_WATCH_TTL_SEC,
@@ -271,6 +273,7 @@ async def _run_board_aggregate() -> dict:
def _schedule_board_refresh() -> None:
board_store.request_refresh()
+ dashboard_store.request_refresh()
async def _run_archive_sync_once() -> dict:
@@ -470,6 +473,7 @@ async def _archive_sync_loop() -> None:
async def _hub_lifespan(_app: FastAPI):
global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task
await board_store.start(_run_board_aggregate)
+ await dashboard_store.start(_run_dashboard_aggregate)
await chart_poll_store.start(_run_chart_poll)
_archive_sync_stop = asyncio.Event()
_archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync")
@@ -499,6 +503,7 @@ async def _hub_lifespan(_app: FastAPI):
_volume_rank_task = None
_volume_rank_stop = None
await chart_poll_store.stop()
+ await dashboard_store.stop()
await board_store.stop()
@@ -667,9 +672,26 @@ from hub_dashboard import build_dashboard_payload, default_trading_day
app.include_router(create_hub_ai_router(load_all_exchanges=_all_exchanges_for_ai))
+async def _run_dashboard_aggregate() -> dict:
+ try:
+ return await asyncio.to_thread(
+ build_dashboard_payload,
+ _all_exchanges_for_ai(),
+ trading_day=default_trading_day(),
+ )
+ except Exception as exc:
+ return {"ok": False, "msg": str(exc), "error": "aggregate_failed"}
+
+
+def _schedule_dashboard_refresh() -> None:
+ dashboard_store.request_refresh()
+
+
@app.get("/api/dashboard/daily")
def api_dashboard_daily(trading_day: str = ""):
day = (trading_day or "").strip()[:10] or default_trading_day()
+ if not (trading_day or "").strip():
+ return dashboard_store.snapshot_dict()
try:
payload = build_dashboard_payload(
_all_exchanges_for_ai(),
@@ -677,7 +699,28 @@ def api_dashboard_daily(trading_day: str = ""):
)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
- return payload
+ return {**payload, "dashboard_version": dashboard_store.version}
+
+
+@app.get("/api/dashboard/stream")
+async def api_dashboard_stream():
+ from fastapi.responses import StreamingResponse
+
+ return StreamingResponse(
+ dashboard_store.iter_sse(),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
+
+
+@app.post("/api/dashboard/refresh")
+async def api_dashboard_refresh():
+ _schedule_dashboard_refresh()
+ return {"ok": True, "dashboard_version": dashboard_store.version}
@app.get("/trade")
@@ -2122,7 +2165,7 @@ def api_ping():
"service": "manual-trading-hub",
"build": HUB_BUILD,
"trade_ui": False,
- "features": ["monitor", "settings", "auth", "board_sse", "archive", "dashboard", "funds"],
+ "features": ["monitor", "settings", "auth", "board_sse", "dashboard_sse", "archive", "dashboard", "funds"],
"board_poll_interval_sec": HUB_BOARD_POLL_INTERVAL,
"board_version": board_store.version,
"board_aggregating": board_store.aggregating,
@@ -2130,6 +2173,13 @@ def api_ping():
if isinstance(board_store.payload, dict)
else None,
"board_error": board_store.last_error,
+ "dashboard_poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC,
+ "dashboard_version": dashboard_store.version,
+ "dashboard_aggregating": dashboard_store.aggregating,
+ "dashboard_updated_at": (dashboard_store.payload or {}).get("updated_at")
+ if isinstance(dashboard_store.payload, dict)
+ else None,
+ "dashboard_error": dashboard_store.last_error,
"password_required": password_required(),
"env_disabled_ids": sorted(env_force_disabled_ids()),
"hub_disabled_ids_raw": (os.getenv("HUB_DISABLED_IDS") or ""),
diff --git a/manual_trading_hub/hub_dashboard_cache.py b/manual_trading_hub/hub_dashboard_cache.py
new file mode 100644
index 0000000..e086b86
--- /dev/null
+++ b/manual_trading_hub/hub_dashboard_cache.py
@@ -0,0 +1,169 @@
+"""数据看板:后台定时聚合、内存快照、SSE 版本通知。"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from collections.abc import AsyncIterator, Awaitable, Callable
+from typing import Any
+
+from hub_dashboard import DASHBOARD_POLL_INTERVAL_SEC
+
+HUB_DASHBOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_DASHBOARD_SSE_HEARTBEAT_SEC", "25"))
+
+BuildFn = Callable[[], Awaitable[dict[str, Any]]]
+
+
+class DashboardStore:
+ def __init__(self) -> None:
+ self._lock = asyncio.Lock()
+ self.version = 0
+ self.payload: dict[str, Any] | None = None
+ self.aggregating = False
+ self.last_error: str | None = None
+ self._subscribers: list[asyncio.Queue[str | None]] = []
+ self._task: asyncio.Task | None = None
+ self._stop = asyncio.Event()
+ self._refresh = asyncio.Event()
+ self._build_fn: BuildFn | None = None
+
+ async def start(self, build_fn: BuildFn) -> None:
+ if self._task and not self._task.done():
+ return
+ self._build_fn = build_fn
+ self._stop.clear()
+ self._task = asyncio.create_task(self._loop(), name="hub-dashboard-poll")
+
+ async def stop(self) -> None:
+ self._stop.set()
+ self._refresh.set()
+ if self._task:
+ self._task.cancel()
+ try:
+ await self._task
+ except asyncio.CancelledError:
+ pass
+ self._task = None
+ self._broadcast(close=True)
+
+ def request_refresh(self) -> None:
+ self._refresh.set()
+
+ def snapshot_dict(self) -> dict[str, Any]:
+ p = dict(self.payload or {})
+ if not p:
+ return {
+ "ok": False,
+ "dashboard_version": self.version,
+ "aggregating": self.aggregating,
+ "error": self.last_error,
+ "poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC,
+ }
+ return {
+ **p,
+ "dashboard_version": self.version,
+ "aggregating": self.aggregating,
+ "error": self.last_error or p.get("error"),
+ "poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC,
+ }
+
+ def event_dict(self) -> dict[str, Any]:
+ p = self.payload or {}
+ return {
+ "dashboard_version": self.version,
+ "updated_at": p.get("updated_at"),
+ "aggregating": self.aggregating,
+ "ok": p.get("ok", True) if self.payload else False,
+ "error": self.last_error or p.get("error"),
+ }
+
+ async def _loop(self) -> None:
+ assert self._build_fn is not None
+ while not self._stop.is_set():
+ await self._aggregate_once(self._build_fn)
+ if self._stop.is_set():
+ break
+ self._refresh.clear()
+ sleep_task = asyncio.create_task(asyncio.sleep(DASHBOARD_POLL_INTERVAL_SEC))
+ refresh_task = asyncio.create_task(self._refresh.wait())
+ done, pending = await asyncio.wait(
+ {sleep_task, refresh_task},
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ for t in pending:
+ t.cancel()
+
+ async def _aggregate_once(self, build_fn: BuildFn) -> None:
+ async with self._lock:
+ self.aggregating = True
+ self._broadcast()
+ try:
+ result = await build_fn()
+ if not isinstance(result, dict):
+ result = {"ok": False, "msg": "聚合返回无效"}
+ except Exception as e:
+ result = {"ok": False, "msg": str(e), "error": "aggregate_failed"}
+ async with self._lock:
+ self.version += 1
+ prev = self.payload if isinstance(self.payload, dict) else None
+ if result.get("ok") is False and prev and prev.get("ok"):
+ self.payload = prev
+ self.last_error = str(result.get("msg") or result.get("error") or "aggregate_failed")
+ else:
+ self.payload = result
+ self.last_error = None if result.get("ok") is not False else (
+ str(result.get("msg") or result.get("error") or "aggregate_failed")
+ )
+ self.aggregating = False
+ self._broadcast()
+
+ def _broadcast(self, *, close: bool = False) -> None:
+ dead: list[asyncio.Queue[str | None]] = []
+ for q in self._subscribers:
+ try:
+ q.put_nowait(None if close else json.dumps(self.event_dict(), ensure_ascii=False))
+ except asyncio.QueueFull:
+ try:
+ q.get_nowait()
+ except asyncio.QueueEmpty:
+ pass
+ try:
+ q.put_nowait(json.dumps(self.event_dict(), ensure_ascii=False))
+ except asyncio.QueueFull:
+ dead.append(q)
+ except Exception:
+ dead.append(q)
+ for q in dead:
+ if q in self._subscribers:
+ self._subscribers.remove(q)
+
+ async def iter_sse(self) -> AsyncIterator[str]:
+ q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32)
+ self._subscribers.append(q)
+ try:
+ yield _sse_frame(self.event_dict())
+ while True:
+ try:
+ raw = await asyncio.wait_for(q.get(), timeout=HUB_DASHBOARD_SSE_HEARTBEAT_SEC)
+ except asyncio.TimeoutError:
+ yield ": heartbeat\n\n"
+ continue
+ if raw is None:
+ break
+ try:
+ data = json.loads(raw)
+ except Exception:
+ data = self.event_dict()
+ yield _sse_frame(data)
+ finally:
+ if q in self._subscribers:
+ self._subscribers.remove(q)
+
+
+def _sse_frame(data: dict[str, Any]) -> str:
+ body = json.dumps(data, ensure_ascii=False)
+ return f"event: dashboard\ndata: {body}\n\n"
+
+
+dashboard_store = DashboardStore()
diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css
index 2513b9e..2d170be 100644
--- a/manual_trading_hub/static/app.css
+++ b/manual_trading_hub/static/app.css
@@ -3850,13 +3850,15 @@ body.hub-page-ai #page-ai {
.ai-layout {
flex: 1 1 auto;
min-height: 0;
- display: grid;
- grid-template-columns: 1fr 1fr;
- grid-template-rows: minmax(0, 1fr);
- gap: 12px;
+ display: flex;
+ flex-direction: column;
align-items: stretch;
overflow: hidden;
}
+.ai-layout .ai-chat-panel {
+ flex: 1 1 auto;
+ min-height: 0;
+}
.ai-mobile-tabs {
display: none;
}
@@ -3938,22 +3940,6 @@ body.hub-page-ai #page-ai {
overflow: hidden;
}
- body.hub-page-ai .ai-layout[data-ai-mobile-tab="chat"] .ai-summary-panel,
- body.hub-page-ai .ai-layout[data-ai-mobile-tab="summary"] .ai-chat-panel,
- body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-summary-panel {
- display: none !important;
- }
-
- body.hub-page-ai .ai-layout[data-ai-mobile-tab="chat"] .ai-chat-panel,
- body.hub-page-ai .ai-layout[data-ai-mobile-tab="summary"] .ai-summary-panel {
- display: flex;
- flex: 1 1 auto;
- width: 100%;
- max-width: 100%;
- min-height: 0;
- min-width: 0;
- }
-
body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-panel {
display: flex;
flex: 1 1 auto;
diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js
index 02496f0..7c05dbb 100644
--- a/manual_trading_hub/static/app.js
+++ b/manual_trading_hub/static/app.js
@@ -3142,26 +3142,11 @@
showToast("已添加一行,请填写 URL 后点「保存设置」");
};
- let aiSummaryLoading = false;
let aiChatLoading = false;
let aiChatSessionCache = null;
let aiChatSessionsCache = [];
let aiSelectedBotMode = "trading";
- function aiPnlClass(v) {
- const n = Number(v);
- if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "";
- return n > 0 ? "pos" : "neg";
- }
-
- function aiPnlSigned(v, digits) {
- const n = Number(v);
- if (!Number.isFinite(n)) return "—";
- const abs = fmt(Math.abs(n), digits);
- if (Math.abs(n) < 1e-9) return `${abs}U`;
- return `${n > 0 ? "+" : "-"}${abs}U`;
- }
-
function renderHubMarkdown(text) {
const raw = String(text || "");
if (typeof window !== "undefined" && window.AiReviewRender && window.AiReviewRender.renderMarkdown) {
@@ -3172,154 +3157,6 @@
.replace(/\n/g, "
");
}
- function renderAiMarkdown(text) {
- return renderHubMarkdown(text);
- }
-
- function enhanceHubSummaryMarkdown(md) {
- let out = String(md || "");
- out = out.replace(/\*\*今日交易总结(([^)]+))\*\*/g, "# 📋 今日交易总结($1)");
- out = out.replace(/\*\*1\.\s*(?:📊\s*)?总览\*\*/g, "## 1. 📊 总览");
- out = out.replace(/\*\*2\.\s*(?:👥\s*)?分户明细\*\*/g, "## 2. 👥 分户明细");
- out = out.replace(/\*\*3\.\s*(?:⚠️\s*)?需关注\*\*/g, "## 3. ⚠️ 需关注");
- out = out.replace(/\*\*4\.\s*(?:ℹ️\s*)?数据说明\*\*/g, "## 4. ℹ️ 数据说明");
- out = out.replace(/\*\*5\.\s*(?:💡\s*)?操作建议\*\*/g, "## 5. 💡 操作建议");
- return out;
- }
-
- function aiFmtFund(v) {
- const n = Number(v);
- if (!Number.isFinite(n)) return "—";
- return `${fmt(n, 2)}U`;
- }
-
- function aiPnlCellHtml(v, digits) {
- const cls = aiPnlClass(v);
- const valCls = cls ? ` ai-stat-val ${cls}` : " ai-stat-val";
- return `${aiPnlSigned(v, digits)}`;
- }
-
- function aiAccountStatusClass(status) {
- const s = String(status || "");
- if (s === "未监控") return "ai-ac-unmon";
- if (s.includes("异常")) return "ai-ac-err";
- if (s.includes("需关注")) return "ai-ac-warn";
- return "";
- }
-
- function renderAiAccountTable(snapshot) {
- const accounts = snapshot && snapshot.by_account;
- if (!accounts || typeof accounts !== "object") return "";
- const rows = Object.values(accounts);
- if (!rows.length) return "";
- const head =
- "" +
- " ";
- const body = rows
- .map((ac) => {
- const closedPnl = Number(ac.pnl_u);
- const floatPnl = Number(ac.float_pnl_u);
- const remark =
- ac.remark ||
- (Array.isArray(ac.issues) && ac.issues.length ? ac.issues.join(";") : "无");
- const statusCls = aiAccountStatusClass(ac.status);
- const countLabel = `${Number(ac.closed_count) || 0}${Number(ac.closed_count_yesterday) ? ` / 昨${Number(ac.closed_count_yesterday)}` : ""}`;
- return (
- "账户 状态 资金账户 交易账户 今日盈亏 笔数 浮盈亏 备注 " +
- "
${esc(String(e))}
`); - } - } - async function loadAiChatSession() { const r = await apiFetch("/api/ai/chat/session"); const j = await r.json(); @@ -3547,7 +3369,7 @@ async function loadAiPage() { applyAiMobileTab(); - await Promise.all([loadAiSummary(), loadAiChatSession()]); + await loadAiChatSession(); if (isMobileLayout() && (localStorage.getItem(AI_MOBILE_TAB_KEY) || "chat") === "chat") { const input = document.getElementById("ai-chat-input"); if (input && !aiChatLoading) { @@ -3556,38 +3378,6 @@ } } - async function generateAiSummary() { - if (aiSummaryLoading) return; - aiSummaryLoading = true; - const btn = document.getElementById("btn-ai-summary"); - const body = document.getElementById("ai-summary-body"); - if (btn) btn.disabled = true; - if (body) setAiSummaryPlaceholder(body, '正在聚合四户数据并生成总结…
'); - try { - const r = await apiFetch("/api/ai/summary/generate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ force: true }), - }); - const j = await r.json(); - if (!r.ok) throw new Error(j.detail || j.msg || "生成失败"); - if (!j.ok && j.detail) throw new Error(j.detail); - const sum = j.summary; - if (sum && sum.content_md && body) { - setAiSummaryMarkdown(body, sum.content_md, sum.stats_snapshot); - renderAiSummaryStats(sum.stats_snapshot); - } - showToast(j.cached ? "已是最新上下文,返回缓存总结" : "今日总结已生成"); - await loadAiSummary(); - } catch (e) { - showToast(String(e), true); - if (body) setAiSummaryPlaceholder(body, `${esc(String(e))}
`); - } finally { - aiSummaryLoading = false; - if (btn) btn.disabled = false; - } - } - async function newAiChat(botMode) { const mode = botMode === "general" ? "general" : "trading"; try { @@ -3663,8 +3453,6 @@ }); } - const aiSummaryBtn = document.getElementById("btn-ai-summary"); - if (aiSummaryBtn) aiSummaryBtn.onclick = () => generateAiSummary(); const aiChatNewBtn = document.getElementById("btn-ai-chat-new"); if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(aiSelectedBotMode); const aiChatForm = document.getElementById("ai-chat-form"); diff --git a/manual_trading_hub/static/dashboard.css b/manual_trading_hub/static/dashboard.css index 2a4c8f0..07c107d 100644 --- a/manual_trading_hub/static/dashboard.css +++ b/manual_trading_hub/static/dashboard.css @@ -1,49 +1,43 @@ -/* 数据看板 — 科技感展示 */ +/* 数据看板 — 随中控亮/暗主题,卡片柔光 */ body.hub-page-dashboard { - --dash-cyan: #3ee7ff; - --dash-mag: #c45bff; - --dash-warn: #ff5c7a; - --dash-ok: #3dffb0; - --dash-panel: rgba(10, 16, 32, 0.82); - --dash-border: rgba(62, 231, 255, 0.22); + --dash-card-bg: var(--panel); + --dash-card-border: var(--border-soft); + --dash-card-glow: 0 2px 12px rgba(0, 0, 0, 0.06); + --dash-section-bg: var(--panel); + --dash-muted: var(--muted); + --dash-text: var(--text); + --dash-accent: var(--accent); + --dash-ok: var(--green); + --dash-warn: var(--red); +} + +html[data-theme="light"] body.hub-page-dashboard { + --dash-card-glow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 8px 24px rgba(15, 23, 42, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.85); +} + +html[data-theme="dark"] body.hub-page-dashboard { + --dash-card-glow: + 0 4px 18px rgba(0, 0, 0, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.04); } body.hub-page-dashboard .page#page-dashboard { position: relative; - overflow: hidden; } .dash-bg-grid { position: absolute; inset: 0; pointer-events: none; + opacity: 0.45; background-image: - linear-gradient(rgba(62, 231, 255, 0.04) 1px, transparent 1px), - linear-gradient(90deg, rgba(62, 231, 255, 0.04) 1px, transparent 1px); - background-size: 48px 48px; - mask-image: radial-gradient(ellipse 80% 70% at 50% 20%, #000 20%, transparent 75%); -} - -.dash-bg-glow { - position: absolute; - width: 520px; - height: 520px; - border-radius: 50%; - filter: blur(90px); - opacity: 0.35; - pointer-events: none; -} - -.dash-bg-glow-a { - top: -120px; - left: -80px; - background: var(--dash-cyan); -} - -.dash-bg-glow-b { - top: 40%; - right: -160px; - background: var(--dash-mag); + linear-gradient(color-mix(in srgb, var(--border-soft) 55%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--border-soft) 55%, transparent) 1px, transparent 1px); + background-size: 40px 40px; + mask-image: radial-gradient(ellipse 85% 65% at 50% 0%, #000 15%, transparent 72%); } .dash-wrap { @@ -64,40 +58,36 @@ body.hub-page-dashboard .page#page-dashboard { } .dash-head h1 { - font-family: Orbitron, var(--font-sans, system-ui), sans-serif; - font-size: clamp(1.35rem, 2.5vw, 1.85rem); + font-size: clamp(1.35rem, 2.5vw, 1.75rem); font-weight: 700; - letter-spacing: 0.06em; + letter-spacing: 0.02em; margin: 0; - background: linear-gradient(90deg, var(--dash-cyan), #8fc8ff 45%, var(--dash-mag)); - -webkit-background-clip: text; - background-clip: text; - color: transparent; + color: var(--dash-text); } .dash-head-tag { display: inline-block; font-family: JetBrains Mono, monospace; font-size: 0.65rem; - color: var(--dash-cyan); - border: 1px solid var(--dash-border); + color: var(--dash-accent); + border: 1px solid var(--dash-card-border); padding: 2px 8px; border-radius: 4px; margin-right: 10px; vertical-align: middle; - letter-spacing: 0.12em; + letter-spacing: 0.1em; } .dash-head-meta { font-family: JetBrains Mono, monospace; font-size: 0.78rem; - color: var(--muted); + color: var(--dash-muted); text-align: right; } .dash-head-meta strong { - color: var(--dash-cyan); - font-weight: 500; + color: var(--dash-text); + font-weight: 600; } .dash-pulse-dot { @@ -107,7 +97,7 @@ body.hub-page-dashboard .page#page-dashboard { border-radius: 50%; background: var(--dash-ok); margin-right: 6px; - box-shadow: 0 0 10px var(--dash-ok); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dash-ok) 25%, transparent); animation: dash-pulse 2s ease-in-out infinite; } @@ -118,8 +108,8 @@ body.hub-page-dashboard .page#page-dashboard { transform: scale(1); } 50% { - opacity: 0.55; - transform: scale(0.85); + opacity: 0.65; + transform: scale(0.9); } } @@ -129,56 +119,48 @@ body.hub-page-dashboard .page#page-dashboard { gap: 12px; } +.dash-kpi, +.dash-section, +.dash-ac-card { + box-shadow: var(--dash-card-glow); +} + .dash-kpi { position: relative; padding: 16px 18px; border-radius: 12px; - background: var(--dash-panel); - border: 1px solid var(--dash-border); - backdrop-filter: blur(12px); + background: var(--dash-card-bg); + border: 1px solid var(--dash-card-border); overflow: hidden; } -.dash-kpi::before { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 2px; - background: linear-gradient(90deg, transparent, var(--dash-cyan), transparent); - opacity: 0.7; -} - .dash-kpi-label { font-size: 0.72rem; - color: var(--muted); - letter-spacing: 0.08em; - text-transform: uppercase; + color: var(--dash-muted); + letter-spacing: 0.06em; margin-bottom: 8px; } .dash-kpi-value { font-family: JetBrains Mono, monospace; - font-size: clamp(1.25rem, 2.2vw, 1.65rem); + font-size: clamp(1.25rem, 2.2vw, 1.55rem); font-weight: 600; line-height: 1.2; + color: var(--dash-text); } .dash-kpi-value.pos { color: var(--dash-ok); - text-shadow: 0 0 18px rgba(61, 255, 176, 0.35); } .dash-kpi-value.neg { color: var(--dash-warn); - text-shadow: 0 0 18px rgba(255, 92, 122, 0.35); } .dash-kpi-sub { margin-top: 6px; font-size: 0.72rem; - color: #8892b0; + color: var(--dash-muted); } .dash-alert-banner { @@ -187,37 +169,25 @@ body.hub-page-dashboard .page#page-dashboard { gap: 12px; padding: 12px 16px; border-radius: 10px; - border: 1px solid rgba(255, 92, 122, 0.45); - background: linear-gradient(90deg, rgba(255, 92, 122, 0.12), rgba(196, 91, 255, 0.08)); + border: 1px solid color-mix(in srgb, var(--dash-warn) 45%, var(--dash-card-border)); + background: color-mix(in srgb, var(--dash-warn) 8%, var(--dash-card-bg)); font-size: 0.85rem; - animation: dash-alert-glow 2.5s ease-in-out infinite; + box-shadow: var(--dash-card-glow); } .dash-alert-banner.is-on { display: flex; } -@keyframes dash-alert-glow { - 0%, - 100% { - box-shadow: 0 0 0 rgba(255, 92, 122, 0); - } - 50% { - box-shadow: 0 0 22px rgba(255, 92, 122, 0.25); - } -} - .dash-alert-banner strong { color: var(--dash-warn); - font-family: Orbitron, sans-serif; - letter-spacing: 0.04em; + letter-spacing: 0.02em; } .dash-section { border-radius: 14px; - border: 1px solid var(--dash-border); - background: var(--dash-panel); - backdrop-filter: blur(10px); + border: 1px solid var(--dash-card-border); + background: var(--dash-section-bg); overflow: hidden; } @@ -226,11 +196,11 @@ body.hub-page-dashboard .page#page-dashboard { align-items: center; justify-content: space-between; padding: 12px 16px; - border-bottom: 1px solid rgba(62, 231, 255, 0.12); - font-family: Orbitron, sans-serif; + border-bottom: 1px solid var(--dash-card-border); font-size: 0.82rem; - letter-spacing: 0.1em; - color: var(--dash-cyan); + letter-spacing: 0.06em; + color: var(--dash-muted); + font-weight: 600; } .dash-section-body { @@ -248,35 +218,20 @@ body.hub-page-dashboard .page#page-dashboard { position: relative; padding: 14px 16px; border-radius: 10px; - border: 1px solid rgba(136, 146, 176, 0.2); - background: rgba(8, 12, 24, 0.65); + border: 1px solid var(--dash-card-border); + background: var(--dash-card-bg); transition: border-color 0.2s, box-shadow 0.2s; } .dash-ac-card.is-alert { - border-color: rgba(255, 92, 122, 0.65); + border-color: color-mix(in srgb, var(--dash-warn) 55%, var(--dash-card-border)); box-shadow: - 0 0 0 1px rgba(255, 92, 122, 0.2), - 0 0 28px rgba(255, 92, 122, 0.15); - animation: dash-card-alert 3s ease-in-out infinite; -} - -@keyframes dash-card-alert { - 0%, - 100% { - box-shadow: - 0 0 0 1px rgba(255, 92, 122, 0.2), - 0 0 20px rgba(255, 92, 122, 0.1); - } - 50% { - box-shadow: - 0 0 0 1px rgba(255, 92, 122, 0.45), - 0 0 36px rgba(255, 92, 122, 0.22); - } + var(--dash-card-glow), + 0 0 0 1px color-mix(in srgb, var(--dash-warn) 18%, transparent); } .dash-ac-card.is-unmon { - opacity: 0.55; + opacity: 0.6; } .dash-ac-top { @@ -290,7 +245,7 @@ body.hub-page-dashboard .page#page-dashboard { .dash-ac-name { font-weight: 600; font-size: 0.92rem; - color: #e8eeff; + color: var(--dash-text); } .dash-ac-badge { @@ -298,18 +253,19 @@ body.hub-page-dashboard .page#page-dashboard { font-weight: 700; padding: 3px 8px; border-radius: 4px; - letter-spacing: 0.06em; + letter-spacing: 0.04em; white-space: nowrap; } .dash-ac-badge.alert { color: #fff; - background: linear-gradient(135deg, #ff5c7a, #c45bff); + background: var(--dash-warn); } .dash-ac-badge.ok { - color: var(--dash-cyan); - border: 1px solid var(--dash-border); + color: var(--dash-accent); + border: 1px solid var(--dash-card-border); + background: color-mix(in srgb, var(--dash-accent) 8%, var(--dash-card-bg)); } .dash-ac-metrics { @@ -322,11 +278,15 @@ body.hub-page-dashboard .page#page-dashboard { .dash-ac-metric span { display: block; - color: #8892b0; + color: var(--dash-muted); font-size: 0.65rem; margin-bottom: 2px; } +.dash-ac-metric strong { + color: var(--dash-text); +} + .dash-ac-metric strong.pos { color: var(--dash-ok); } @@ -339,7 +299,7 @@ body.hub-page-dashboard .page#page-dashboard { margin-top: 10px; height: 4px; border-radius: 2px; - background: rgba(255, 255, 255, 0.08); + background: color-mix(in srgb, var(--dash-muted) 18%, transparent); overflow: hidden; } @@ -347,14 +307,14 @@ body.hub-page-dashboard .page#page-dashboard { display: block; height: 100%; border-radius: 2px; - background: linear-gradient(90deg, var(--dash-warn), var(--dash-mag)); + background: var(--dash-warn); transition: width 0.6s ease; } .dash-ac-remark { margin-top: 10px; font-size: 0.7rem; - color: #8892b0; + color: var(--dash-muted); line-height: 1.4; word-break: break-word; } @@ -377,25 +337,25 @@ body.hub-page-dashboard .page#page-dashboard { z-index: 1; text-align: left; padding: 10px 12px; - background: rgba(12, 18, 36, 0.95); - color: var(--dash-cyan); - font-weight: 500; - letter-spacing: 0.06em; - border-bottom: 1px solid var(--dash-border); + background: var(--inset-surface); + color: var(--dash-muted); + font-weight: 600; + letter-spacing: 0.04em; + border-bottom: 1px solid var(--dash-card-border); } .dash-table td { padding: 9px 12px; - border-bottom: 1px solid rgba(42, 52, 72, 0.5); - color: #c5cde0; + border-bottom: 1px solid var(--dash-card-border); + color: var(--dash-text); } .dash-table tr:hover td { - background: rgba(62, 231, 255, 0.04); + background: color-mix(in srgb, var(--dash-accent) 6%, transparent); } .dash-table tr.is-alert-row td { - background: rgba(255, 92, 122, 0.08); + background: color-mix(in srgb, var(--dash-warn) 10%, transparent); } .dash-table .pos { @@ -409,14 +369,14 @@ body.hub-page-dashboard .page#page-dashboard { .dash-empty { padding: 32px; text-align: center; - color: var(--muted); + color: var(--dash-muted); font-size: 0.85rem; } .dash-status { font-family: JetBrains Mono, monospace; font-size: 0.75rem; - color: var(--muted); + color: var(--dash-muted); } .dash-status.err { diff --git a/manual_trading_hub/static/dashboard.js b/manual_trading_hub/static/dashboard.js index 613aa13..8e6c897 100644 --- a/manual_trading_hub/static/dashboard.js +++ b/manual_trading_hub/static/dashboard.js @@ -1,12 +1,13 @@ /** - * 中控数据看板:总览 / 分户 / 平仓明细,60s 自动刷新。 + * 中控数据看板:后端 SSE 推送版本号,前端拉快照刷新(无轮询闪烁)。 */ (function () { const page = document.getElementById("page-dashboard"); if (!page) return; - const POLL_MS = 60 * 1000; - let timer = null; + let dashEventSource = null; + let dashReconnectTimer = null; + let localDashVersion = 0; let inited = false; let loading = false; @@ -191,10 +192,11 @@ } } - async function fetchDashboard() { - if (loading) return; + async function fetchDashboardSnapshot(opts) { + const options = opts || {}; + if (loading && !options.force) return; loading = true; - setStatus("同步中…"); + if (!options.silent) setStatus("同步中…"); try { const r = await fetch("/api/dashboard/daily", { credentials: "same-origin" }); if (r.status === 401) { @@ -202,9 +204,12 @@ return; } const data = await r.json(); - if (!data.ok) throw new Error(data.detail || data.msg || "加载失败"); + if (!data.ok) throw new Error(data.detail || data.msg || data.error || "加载失败"); + const ver = Number(data.dashboard_version) || 0; + if (ver) localDashVersion = ver; renderPayload(data); - setStatus(`每 ${(data.poll_interval_sec || 60)}s 自动刷新`); + const sec = Number(data.poll_interval_sec) || 60; + setStatus(options.silent ? `SSE 已连接 · 后台每 ${sec}s 聚合` : `已更新 · 后台每 ${sec}s 聚合`); } catch (e) { setStatus(String(e.message || e), true); } finally { @@ -212,31 +217,78 @@ } } - function startPoll() { - stopPoll(); - void fetchDashboard(); - timer = setInterval(fetchDashboard, POLL_MS); - } - - function stopPoll() { - if (timer) { - clearInterval(timer); - timer = null; + function closeDashboardStream() { + if (dashEventSource) { + dashEventSource.close(); + dashEventSource = null; + } + if (dashReconnectTimer) { + clearTimeout(dashReconnectTimer); + dashReconnectTimer = null; } } + function connectDashboardStream() { + closeDashboardStream(); + dashEventSource = new EventSource("/api/dashboard/stream"); + dashEventSource.addEventListener("dashboard", (ev) => { + try { + const st = JSON.parse(ev.data || "{}"); + const ver = Number(st.dashboard_version) || 0; + if (ver && ver !== localDashVersion) { + void fetchDashboardSnapshot({ silent: true }); + } else if (st.aggregating) { + setStatus("后台聚合中…"); + } + } catch (_) { + /* ignore */ + } + }); + dashEventSource.onerror = () => { + closeDashboardStream(); + setStatus("SSE 断开,8s 后重连…", true); + dashReconnectTimer = setTimeout(() => { + if (inited) { + connectDashboardStream(); + void fetchDashboardSnapshot({ silent: true }); + } + }, 8000); + }; + } + + async function requestDashboardRefresh() { + try { + await fetch("/api/dashboard/refresh", { method: "POST", credentials: "same-origin" }); + } catch (_) { + /* ignore */ + } + } + + function startLive() { + void fetchDashboardSnapshot(); + connectDashboardStream(); + } + + function stopLive() { + closeDashboardStream(); + setStatus(""); + } + if (btnRefresh) { - btnRefresh.addEventListener("click", () => void fetchDashboard()); + btnRefresh.addEventListener("click", () => { + void requestDashboardRefresh(); + void fetchDashboardSnapshot({ force: true }); + }); } window.hubDashboardPage = { init() { - if (!inited) inited = true; - startPoll(); + inited = true; + startLive(); }, destroy() { - stopPoll(); - setStatus(""); + inited = false; + stopLive(); }, }; })(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index a739a32..88af80c 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -16,7 +16,7 @@ - + @@ -316,13 +316,11 @@