From 07e8604ea6602b9af64aef378655151687e0c6ec Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 11 Jun 2026 10:53:50 +0800 Subject: [PATCH] feat(hub): dashboard SSE push, light-theme cards, simplify AI coach Replace dashboard polling with backend SSE and snapshot refresh. Restyle for light/dark theme with soft card glow instead of neon. Remove Today's Summary from AI page; keep trading and general chat only. Update hub documentation. Co-authored-by: Cursor --- manual_trading_hub/AI教练说明.md | 13 +- manual_trading_hub/README.md | 7 +- manual_trading_hub/hub.py | 54 ++++- manual_trading_hub/hub_dashboard_cache.py | 169 ++++++++++++++++ manual_trading_hub/static/app.css | 26 +-- manual_trading_hub/static/app.js | 214 +------------------- manual_trading_hub/static/dashboard.css | 228 +++++++++------------- manual_trading_hub/static/dashboard.js | 98 +++++++--- manual_trading_hub/static/index.html | 25 +-- manual_trading_hub/使用说明.md | 21 +- manual_trading_hub/数据看板说明.md | 50 +++++ 11 files changed, 481 insertions(+), 424 deletions(-) create mode 100644 manual_trading_hub/hub_dashboard_cache.py create mode 100644 manual_trading_hub/数据看板说明.md 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(ac.name || "—")}` + - `${esc(ac.status || "—")}` + - `${aiFmtFund(ac.funding_usdt)}` + - `${aiFmtFund(ac.trading_usdt)}` + - `${aiPnlCellHtml(closedPnl, 2)}` + - `${countLabel}` + - `${aiPnlCellHtml(floatPnl, 2)}` + - `${esc(remark)}` + - "" - ); - }) - .join(""); - return `
${head}${body}
`; - } - - function renderAiClosedTradesBlock(snapshot) { - const rows = (snapshot && snapshot.closed_trades) || []; - if (!rows.length) return ""; - const head = - "交易日账户合约方向结果盈亏时间"; - const body = rows - .map((t) => { - const pnl = Number(t.pnl_amount); - return ( - "" + - `${esc(t.trading_day || "—")}` + - `${esc(t.account_name || "—")}` + - `${esc(t.symbol || "—")}` + - `${esc(t.direction || "—")}` + - `${esc(t.result || "—")}` + - `${aiPnlCellHtml(pnl, 2)}` + - `${esc(t.closed_at || "—")}` + - "" - ); - }) - .join(""); - return ( - `
` + - `

平仓明细(今日)

` + - `
${head}${body}
` + - `
` - ); - } - - function renderAiSummaryBody(contentMd, snapshot) { - const md = enhanceHubSummaryMarkdown(contentMd); - const sec2 = /##\s*2\.\s*👥\s*分户明细/; - const sec3 = /##\s*3\.\s*⚠️\s*需关注/; - const i2 = md.search(sec2); - const i3 = md.search(sec3); - const tableHtml = renderAiAccountTable(snapshot); - const closedHtml = renderAiClosedTradesBlock(snapshot); - if (i2 >= 0 && i3 > i2 && tableHtml) { - const headEnd = i2 + md.slice(i2).match(sec2)[0].length; - const part1 = md.slice(0, headEnd); - const part2 = md.slice(i3); - return renderHubMarkdown(part1) + tableHtml + closedHtml + renderHubMarkdown(part2); - } - return renderHubMarkdown(md) + (tableHtml ? tableHtml + closedHtml : ""); - } - - function setAiSummaryMarkdown(body, contentMd, snapshot) { - if (!body) return; - body.classList.add("ai-result-md"); - body.innerHTML = renderAiSummaryBody(contentMd, snapshot); - } - - function setAiSummaryPlaceholder(body, html) { - if (!body) return; - body.classList.remove("ai-result-md"); - body.innerHTML = html; - } - - function renderAiSummaryStats(snapshot) { - const el = document.getElementById("ai-summary-stats"); - if (!el) return; - if (!snapshot || !snapshot.totals) { - el.innerHTML = ""; - return; - } - const t = snapshot.totals; - const closedPnl = Number(t.total_pnl_u); - const floatPnl = Number(t.float_pnl_u); - const closedCls = aiPnlClass(closedPnl); - const floatCls = aiPnlClass(floatPnl); - el.innerHTML = [ - `交易日${esc(t.trading_day || "—")}`, - `平仓盈亏${aiPnlSigned(closedPnl, 2)}`, - `笔数${t.closed_count || 0}(胜${t.win_count || 0}/负${t.loss_count || 0})`, - `浮盈亏${aiPnlSigned(floatPnl, 2)}`, - ].join(""); - } - function scrollAiChatToEnd() { const box = document.getElementById("ai-chat-messages"); if (!box) return; @@ -3476,21 +3313,6 @@ if (input) input.disabled = busy; } - async function loadAiSummary() { - const body = document.getElementById("ai-summary-body"); - try { - const r = await apiFetch("/api/ai/summary"); - const j = await r.json(); - const latest = j.latest; - if (latest && latest.content_md) { - if (body) setAiSummaryMarkdown(body, latest.content_md, latest.stats_snapshot); - renderAiSummaryStats(latest.stats_snapshot); - } - } catch (e) { - if (body) setAiSummaryPlaceholder(body, `

${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 @@