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 <cursoragent@cursor.com>
This commit is contained in:
@@ -6,15 +6,18 @@
|
|||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **今日总结** | 聚合四户(含未启用 →「未监控」)当日平仓、持仓浮盈亏、连接状态;语气偏冷、台账式 |
|
| **交易教练** | 口语化陪聊;注入四户监控快照与今日总结摘要(后台自动生成,不在页面展示) |
|
||||||
| **AI 聊天** | 单会话直到点「新开对话」;口语化、安慰体贴、轻修正;注入监控快照与今日总结摘要 |
|
| **普通聊天** | 不绑交易数据,适合闲聊、答疑 |
|
||||||
|
| **会话历史** | 右侧列表:切换、删除;消息一键复制 |
|
||||||
|
|
||||||
|
页面仅保留 **交易教练 / 普通聊天** 两个机器人和聊天区;**今日总结** 已移至 **数据看板**(`/dashboard`)纯数据展示,不再在 AI 页生成。
|
||||||
|
|
||||||
## 存储
|
## 存储
|
||||||
|
|
||||||
与 `hub_settings.json` 同目录(`manual_trading_hub/`):
|
与 `hub_settings.json` 同目录(`manual_trading_hub/`):
|
||||||
|
|
||||||
- `hub_ai_summaries.json` — 历史总结
|
- `hub_ai_summaries.json` — 历史总结(供交易教练上下文,可选 API 仍保留)
|
||||||
- `hub_ai_chat.json` — 聊天会话(`active_session_id` 指向当前会话)
|
- `hub_ai_chat.json` — 聊天会话(`active_session_id`、多会话、`bot_mode`)
|
||||||
|
|
||||||
升级 / 迁移时请一并备份(见 [本地数据迁移到云端.md](./本地数据迁移到云端.md))。
|
升级 / 迁移时请一并备份(见 [本地数据迁移到云端.md](./本地数据迁移到云端.md))。
|
||||||
|
|
||||||
@@ -56,7 +59,7 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
|||||||
|--|-------------|-------------|
|
|--|-------------|-------------|
|
||||||
| 入口 | `/ai` | 各所 `/records` |
|
| 入口 | `/ai` | 各所 `/records` |
|
||||||
| 数据 | 四户聚合 | 单户 `journal_entries` |
|
| 数据 | 四户聚合 | 单户 `journal_entries` |
|
||||||
| 语气 | 总结冷 / 聊天搭档 | 结构化教练报告 |
|
| 语气 | 聊天搭档 | 结构化教练报告 |
|
||||||
| 代码 | `hub_ai/*` | `ai_review_lib` + 各 `app.py` |
|
| 代码 | `hub_ai/*` | `ai_review_lib` + 各 `app.py` |
|
||||||
|
|
||||||
详见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)(实例侧)。
|
详见仓库根 [AI复盘与模型配置说明.md](../AI复盘与模型配置说明.md)(实例侧)。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 复盘系统中控(manual_trading_hub)
|
# 复盘系统中控(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)。
|
多账户 **监控聚合 + 紧急全平**;**不在中控网页下单**。人工下单、关键位、**策略交易**(`/strategy`)、复盘请在各 `crypto_monitor_*` 实例网页操作(监控卡片 **「实例」** / **「复盘」**)。**增加子账户**见 [使用说明 §4.3](./使用说明.md#43-增加账户例如再挂一个-gate)。
|
||||||
|
|
||||||
@@ -12,8 +12,9 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) |
|
| 监控区 | 持仓、余额、关键位摘要、趋势计划、机器人单(只读) |
|
||||||
| 资金概况 | 总/分户资金(资金户+交易户)、180 日曲线、最大回撤 |
|
| 资金概况 | 总/分户资金(资金户+交易户)、180 日曲线、最大回撤 |
|
||||||
|
| **数据看板** | 四户当日总览/分户/平仓明细,SSE 推送(`/dashboard`;见 [数据看板说明.md](./数据看板说明.md)) |
|
||||||
| 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) |
|
| 行情区 | K 线(多周期、本地缓存、技术指标、从监控跳转持仓线) |
|
||||||
| **AI 教练** | 四户今日总结 + 口语化聊天(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) |
|
| **AI 教练** | 交易教练 + 普通聊天、会话历史(`/ai`;见 [AI教练说明.md](./AI教练说明.md)) |
|
||||||
| 紧急全平 | 单户 / 全局市价减仓 |
|
| 紧急全平 | 单户 / 全局市价减仓 |
|
||||||
| 系统设置 | `hub_settings.json` 管理 URL、启用、**监控关键位 / 监控趋势计划**(不控制策略交易页) |
|
| 系统设置 | `hub_settings.json` 管理 URL、启用、**监控关键位 / 监控趋势计划**(不控制策略交易页) |
|
||||||
| Web 登录 | `.env` 设 `HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) |
|
| Web 登录 | `.env` 设 `HUB_PASSWORD` 后用户名+密码保护(反代公网**务必**配置) |
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
浏览器 → hub.py (:5100) 监控 / 资金概况 / 行情 / **AI 教练** / 设置 / 登录
|
浏览器 → hub.py (:5100) 监控 / 资金概况 / **数据看板** / 行情 / **AI 教练** / 设置 / 登录
|
||||||
├→ agent.py × N (:15200~15203) 持仓、全平
|
├→ agent.py × N (:15200~15203) 持仓、全平
|
||||||
└→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合
|
└→ 各 Flask (:5000/5001/5002/5004) /api/hub/monitor 只读聚合
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ from url_public import browser_url, default_review_url, public_origin
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from hub_board_cache import HUB_BOARD_POLL_INTERVAL, board_store
|
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 (
|
from hub_chart_cache import (
|
||||||
HUB_CHART_POLL_INTERVAL,
|
HUB_CHART_POLL_INTERVAL,
|
||||||
HUB_CHART_WATCH_TTL_SEC,
|
HUB_CHART_WATCH_TTL_SEC,
|
||||||
@@ -271,6 +273,7 @@ async def _run_board_aggregate() -> dict:
|
|||||||
|
|
||||||
def _schedule_board_refresh() -> None:
|
def _schedule_board_refresh() -> None:
|
||||||
board_store.request_refresh()
|
board_store.request_refresh()
|
||||||
|
dashboard_store.request_refresh()
|
||||||
|
|
||||||
|
|
||||||
async def _run_archive_sync_once() -> dict:
|
async def _run_archive_sync_once() -> dict:
|
||||||
@@ -470,6 +473,7 @@ async def _archive_sync_loop() -> None:
|
|||||||
async def _hub_lifespan(_app: FastAPI):
|
async def _hub_lifespan(_app: FastAPI):
|
||||||
global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task
|
global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task
|
||||||
await board_store.start(_run_board_aggregate)
|
await board_store.start(_run_board_aggregate)
|
||||||
|
await dashboard_store.start(_run_dashboard_aggregate)
|
||||||
await chart_poll_store.start(_run_chart_poll)
|
await chart_poll_store.start(_run_chart_poll)
|
||||||
_archive_sync_stop = asyncio.Event()
|
_archive_sync_stop = asyncio.Event()
|
||||||
_archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync")
|
_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_task = None
|
||||||
_volume_rank_stop = None
|
_volume_rank_stop = None
|
||||||
await chart_poll_store.stop()
|
await chart_poll_store.stop()
|
||||||
|
await dashboard_store.stop()
|
||||||
await board_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))
|
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")
|
@app.get("/api/dashboard/daily")
|
||||||
def api_dashboard_daily(trading_day: str = ""):
|
def api_dashboard_daily(trading_day: str = ""):
|
||||||
day = (trading_day or "").strip()[:10] or default_trading_day()
|
day = (trading_day or "").strip()[:10] or default_trading_day()
|
||||||
|
if not (trading_day or "").strip():
|
||||||
|
return dashboard_store.snapshot_dict()
|
||||||
try:
|
try:
|
||||||
payload = build_dashboard_payload(
|
payload = build_dashboard_payload(
|
||||||
_all_exchanges_for_ai(),
|
_all_exchanges_for_ai(),
|
||||||
@@ -677,7 +699,28 @@ def api_dashboard_daily(trading_day: str = ""):
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from 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")
|
@app.get("/trade")
|
||||||
@@ -2122,7 +2165,7 @@ def api_ping():
|
|||||||
"service": "manual-trading-hub",
|
"service": "manual-trading-hub",
|
||||||
"build": HUB_BUILD,
|
"build": HUB_BUILD,
|
||||||
"trade_ui": False,
|
"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_poll_interval_sec": HUB_BOARD_POLL_INTERVAL,
|
||||||
"board_version": board_store.version,
|
"board_version": board_store.version,
|
||||||
"board_aggregating": board_store.aggregating,
|
"board_aggregating": board_store.aggregating,
|
||||||
@@ -2130,6 +2173,13 @@ def api_ping():
|
|||||||
if isinstance(board_store.payload, dict)
|
if isinstance(board_store.payload, dict)
|
||||||
else None,
|
else None,
|
||||||
"board_error": board_store.last_error,
|
"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(),
|
"password_required": password_required(),
|
||||||
"env_disabled_ids": sorted(env_force_disabled_ids()),
|
"env_disabled_ids": sorted(env_force_disabled_ids()),
|
||||||
"hub_disabled_ids_raw": (os.getenv("HUB_DISABLED_IDS") or ""),
|
"hub_disabled_ids_raw": (os.getenv("HUB_DISABLED_IDS") or ""),
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -3850,13 +3850,15 @@ body.hub-page-ai #page-ai {
|
|||||||
.ai-layout {
|
.ai-layout {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
flex-direction: column;
|
||||||
grid-template-rows: minmax(0, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.ai-layout .ai-chat-panel {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
.ai-mobile-tabs {
|
.ai-mobile-tabs {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -3938,22 +3940,6 @@ body.hub-page-ai #page-ai {
|
|||||||
overflow: hidden;
|
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 {
|
body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
@@ -3142,26 +3142,11 @@
|
|||||||
showToast("已添加一行,请填写 URL 后点「保存设置」");
|
showToast("已添加一行,请填写 URL 后点「保存设置」");
|
||||||
};
|
};
|
||||||
|
|
||||||
let aiSummaryLoading = false;
|
|
||||||
let aiChatLoading = false;
|
let aiChatLoading = false;
|
||||||
let aiChatSessionCache = null;
|
let aiChatSessionCache = null;
|
||||||
let aiChatSessionsCache = [];
|
let aiChatSessionsCache = [];
|
||||||
let aiSelectedBotMode = "trading";
|
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) {
|
function renderHubMarkdown(text) {
|
||||||
const raw = String(text || "");
|
const raw = String(text || "");
|
||||||
if (typeof window !== "undefined" && window.AiReviewRender && window.AiReviewRender.renderMarkdown) {
|
if (typeof window !== "undefined" && window.AiReviewRender && window.AiReviewRender.renderMarkdown) {
|
||||||
@@ -3172,154 +3157,6 @@
|
|||||||
.replace(/\n/g, "<br>");
|
.replace(/\n/g, "<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
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 `<span class="${valCls.trim()}">${aiPnlSigned(v, digits)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 =
|
|
||||||
"<thead><tr>" +
|
|
||||||
"<th>账户</th><th>状态</th><th>资金账户</th><th>交易账户</th><th>今日盈亏</th><th>笔数</th><th>浮盈亏</th><th>备注</th>" +
|
|
||||||
"</tr></thead>";
|
|
||||||
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 (
|
|
||||||
"<tr>" +
|
|
||||||
`<td class="ai-ac-name">${esc(ac.name || "—")}</td>` +
|
|
||||||
`<td class="${statusCls}">${esc(ac.status || "—")}</td>` +
|
|
||||||
`<td>${aiFmtFund(ac.funding_usdt)}</td>` +
|
|
||||||
`<td>${aiFmtFund(ac.trading_usdt)}</td>` +
|
|
||||||
`<td>${aiPnlCellHtml(closedPnl, 2)}</td>` +
|
|
||||||
`<td>${countLabel}</td>` +
|
|
||||||
`<td>${aiPnlCellHtml(floatPnl, 2)}</td>` +
|
|
||||||
`<td class="ai-ac-remark">${esc(remark)}</td>` +
|
|
||||||
"</tr>"
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
return `<div class="ai-ac-table-wrap"><table class="ai-ac-table">${head}<tbody>${body}</tbody></table></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAiClosedTradesBlock(snapshot) {
|
|
||||||
const rows = (snapshot && snapshot.closed_trades) || [];
|
|
||||||
if (!rows.length) return "";
|
|
||||||
const head =
|
|
||||||
"<thead><tr><th>交易日</th><th>账户</th><th>合约</th><th>方向</th><th>结果</th><th>盈亏</th><th>时间</th></tr></thead>";
|
|
||||||
const body = rows
|
|
||||||
.map((t) => {
|
|
||||||
const pnl = Number(t.pnl_amount);
|
|
||||||
return (
|
|
||||||
"<tr>" +
|
|
||||||
`<td>${esc(t.trading_day || "—")}</td>` +
|
|
||||||
`<td>${esc(t.account_name || "—")}</td>` +
|
|
||||||
`<td>${esc(t.symbol || "—")}</td>` +
|
|
||||||
`<td>${esc(t.direction || "—")}</td>` +
|
|
||||||
`<td>${esc(t.result || "—")}</td>` +
|
|
||||||
`<td>${aiPnlCellHtml(pnl, 2)}</td>` +
|
|
||||||
`<td class="ai-ac-remark">${esc(t.closed_at || "—")}</td>` +
|
|
||||||
"</tr>"
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
return (
|
|
||||||
`<div class="ai-closed-trades-wrap">` +
|
|
||||||
`<h4 class="ai-closed-trades-title">平仓明细(今日)</h4>` +
|
|
||||||
`<div class="ai-ac-table-wrap"><table class="ai-ac-table ai-closed-trades-table">${head}<tbody>${body}</tbody></table></div>` +
|
|
||||||
`</div>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = [
|
|
||||||
`<span class="ai-stat-chip"><strong>交易日</strong>${esc(t.trading_day || "—")}</span>`,
|
|
||||||
`<span class="ai-stat-chip ${closedCls}"><strong>平仓盈亏</strong><span class="ai-stat-val ${closedCls}">${aiPnlSigned(closedPnl, 2)}</span></span>`,
|
|
||||||
`<span class="ai-stat-chip"><strong>笔数</strong>${t.closed_count || 0}(胜${t.win_count || 0}/负${t.loss_count || 0})</span>`,
|
|
||||||
`<span class="ai-stat-chip ${floatCls}"><strong>浮盈亏</strong><span class="ai-stat-val ${floatCls}">${aiPnlSigned(floatPnl, 2)}</span></span>`,
|
|
||||||
].join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollAiChatToEnd() {
|
function scrollAiChatToEnd() {
|
||||||
const box = document.getElementById("ai-chat-messages");
|
const box = document.getElementById("ai-chat-messages");
|
||||||
if (!box) return;
|
if (!box) return;
|
||||||
@@ -3476,21 +3313,6 @@
|
|||||||
if (input) input.disabled = busy;
|
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, `<p class="ai-placeholder">${esc(String(e))}</p>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAiChatSession() {
|
async function loadAiChatSession() {
|
||||||
const r = await apiFetch("/api/ai/chat/session");
|
const r = await apiFetch("/api/ai/chat/session");
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
@@ -3547,7 +3369,7 @@
|
|||||||
|
|
||||||
async function loadAiPage() {
|
async function loadAiPage() {
|
||||||
applyAiMobileTab();
|
applyAiMobileTab();
|
||||||
await Promise.all([loadAiSummary(), loadAiChatSession()]);
|
await loadAiChatSession();
|
||||||
if (isMobileLayout() && (localStorage.getItem(AI_MOBILE_TAB_KEY) || "chat") === "chat") {
|
if (isMobileLayout() && (localStorage.getItem(AI_MOBILE_TAB_KEY) || "chat") === "chat") {
|
||||||
const input = document.getElementById("ai-chat-input");
|
const input = document.getElementById("ai-chat-input");
|
||||||
if (input && !aiChatLoading) {
|
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, '<p class="ai-placeholder">正在聚合四户数据并生成总结…</p>');
|
|
||||||
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, `<p class="ai-placeholder">${esc(String(e))}</p>`);
|
|
||||||
} finally {
|
|
||||||
aiSummaryLoading = false;
|
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function newAiChat(botMode) {
|
async function newAiChat(botMode) {
|
||||||
const mode = botMode === "general" ? "general" : "trading";
|
const mode = botMode === "general" ? "general" : "trading";
|
||||||
try {
|
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");
|
const aiChatNewBtn = document.getElementById("btn-ai-chat-new");
|
||||||
if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(aiSelectedBotMode);
|
if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(aiSelectedBotMode);
|
||||||
const aiChatForm = document.getElementById("ai-chat-form");
|
const aiChatForm = document.getElementById("ai-chat-form");
|
||||||
|
|||||||
@@ -1,49 +1,43 @@
|
|||||||
/* 数据看板 — 科技感展示 */
|
/* 数据看板 — 随中控亮/暗主题,卡片柔光 */
|
||||||
body.hub-page-dashboard {
|
body.hub-page-dashboard {
|
||||||
--dash-cyan: #3ee7ff;
|
--dash-card-bg: var(--panel);
|
||||||
--dash-mag: #c45bff;
|
--dash-card-border: var(--border-soft);
|
||||||
--dash-warn: #ff5c7a;
|
--dash-card-glow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
--dash-ok: #3dffb0;
|
--dash-section-bg: var(--panel);
|
||||||
--dash-panel: rgba(10, 16, 32, 0.82);
|
--dash-muted: var(--muted);
|
||||||
--dash-border: rgba(62, 231, 255, 0.22);
|
--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 {
|
body.hub-page-dashboard .page#page-dashboard {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-bg-grid {
|
.dash-bg-grid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
opacity: 0.45;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(62, 231, 255, 0.04) 1px, transparent 1px),
|
linear-gradient(color-mix(in srgb, var(--border-soft) 55%, transparent) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(62, 231, 255, 0.04) 1px, transparent 1px);
|
linear-gradient(90deg, color-mix(in srgb, var(--border-soft) 55%, transparent) 1px, transparent 1px);
|
||||||
background-size: 48px 48px;
|
background-size: 40px 40px;
|
||||||
mask-image: radial-gradient(ellipse 80% 70% at 50% 20%, #000 20%, transparent 75%);
|
mask-image: radial-gradient(ellipse 85% 65% at 50% 0%, #000 15%, transparent 72%);
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-wrap {
|
.dash-wrap {
|
||||||
@@ -64,40 +58,36 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dash-head h1 {
|
.dash-head h1 {
|
||||||
font-family: Orbitron, var(--font-sans, system-ui), sans-serif;
|
font-size: clamp(1.35rem, 2.5vw, 1.75rem);
|
||||||
font-size: clamp(1.35rem, 2.5vw, 1.85rem);
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.02em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: linear-gradient(90deg, var(--dash-cyan), #8fc8ff 45%, var(--dash-mag));
|
color: var(--dash-text);
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
color: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-head-tag {
|
.dash-head-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-family: JetBrains Mono, monospace;
|
font-family: JetBrains Mono, monospace;
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--dash-cyan);
|
color: var(--dash-accent);
|
||||||
border: 1px solid var(--dash-border);
|
border: 1px solid var(--dash-card-border);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-head-meta {
|
.dash-head-meta {
|
||||||
font-family: JetBrains Mono, monospace;
|
font-family: JetBrains Mono, monospace;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--muted);
|
color: var(--dash-muted);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-head-meta strong {
|
.dash-head-meta strong {
|
||||||
color: var(--dash-cyan);
|
color: var(--dash-text);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-pulse-dot {
|
.dash-pulse-dot {
|
||||||
@@ -107,7 +97,7 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--dash-ok);
|
background: var(--dash-ok);
|
||||||
margin-right: 6px;
|
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;
|
animation: dash-pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,8 +108,8 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.55;
|
opacity: 0.65;
|
||||||
transform: scale(0.85);
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,56 +119,48 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dash-kpi,
|
||||||
|
.dash-section,
|
||||||
|
.dash-ac-card {
|
||||||
|
box-shadow: var(--dash-card-glow);
|
||||||
|
}
|
||||||
|
|
||||||
.dash-kpi {
|
.dash-kpi {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--dash-panel);
|
background: var(--dash-card-bg);
|
||||||
border: 1px solid var(--dash-border);
|
border: 1px solid var(--dash-card-border);
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
overflow: hidden;
|
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 {
|
.dash-kpi-label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--muted);
|
color: var(--dash-muted);
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-kpi-value {
|
.dash-kpi-value {
|
||||||
font-family: JetBrains Mono, monospace;
|
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;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
color: var(--dash-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-kpi-value.pos {
|
.dash-kpi-value.pos {
|
||||||
color: var(--dash-ok);
|
color: var(--dash-ok);
|
||||||
text-shadow: 0 0 18px rgba(61, 255, 176, 0.35);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-kpi-value.neg {
|
.dash-kpi-value.neg {
|
||||||
color: var(--dash-warn);
|
color: var(--dash-warn);
|
||||||
text-shadow: 0 0 18px rgba(255, 92, 122, 0.35);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-kpi-sub {
|
.dash-kpi-sub {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: #8892b0;
|
color: var(--dash-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-alert-banner {
|
.dash-alert-banner {
|
||||||
@@ -187,37 +169,25 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 92, 122, 0.45);
|
border: 1px solid color-mix(in srgb, var(--dash-warn) 45%, var(--dash-card-border));
|
||||||
background: linear-gradient(90deg, rgba(255, 92, 122, 0.12), rgba(196, 91, 255, 0.08));
|
background: color-mix(in srgb, var(--dash-warn) 8%, var(--dash-card-bg));
|
||||||
font-size: 0.85rem;
|
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 {
|
.dash-alert-banner.is-on {
|
||||||
display: flex;
|
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 {
|
.dash-alert-banner strong {
|
||||||
color: var(--dash-warn);
|
color: var(--dash-warn);
|
||||||
font-family: Orbitron, sans-serif;
|
letter-spacing: 0.02em;
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-section {
|
.dash-section {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid var(--dash-border);
|
border: 1px solid var(--dash-card-border);
|
||||||
background: var(--dash-panel);
|
background: var(--dash-section-bg);
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,11 +196,11 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid rgba(62, 231, 255, 0.12);
|
border-bottom: 1px solid var(--dash-card-border);
|
||||||
font-family: Orbitron, sans-serif;
|
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--dash-cyan);
|
color: var(--dash-muted);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-section-body {
|
.dash-section-body {
|
||||||
@@ -248,35 +218,20 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
position: relative;
|
position: relative;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(136, 146, 176, 0.2);
|
border: 1px solid var(--dash-card-border);
|
||||||
background: rgba(8, 12, 24, 0.65);
|
background: var(--dash-card-bg);
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-ac-card.is-alert {
|
.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:
|
box-shadow:
|
||||||
0 0 0 1px rgba(255, 92, 122, 0.2),
|
var(--dash-card-glow),
|
||||||
0 0 28px rgba(255, 92, 122, 0.15);
|
0 0 0 1px color-mix(in srgb, var(--dash-warn) 18%, transparent);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-ac-card.is-unmon {
|
.dash-ac-card.is-unmon {
|
||||||
opacity: 0.55;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-ac-top {
|
.dash-ac-top {
|
||||||
@@ -290,7 +245,7 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
.dash-ac-name {
|
.dash-ac-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
color: #e8eeff;
|
color: var(--dash-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-ac-badge {
|
.dash-ac-badge {
|
||||||
@@ -298,18 +253,19 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.04em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-ac-badge.alert {
|
.dash-ac-badge.alert {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(135deg, #ff5c7a, #c45bff);
|
background: var(--dash-warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-ac-badge.ok {
|
.dash-ac-badge.ok {
|
||||||
color: var(--dash-cyan);
|
color: var(--dash-accent);
|
||||||
border: 1px solid var(--dash-border);
|
border: 1px solid var(--dash-card-border);
|
||||||
|
background: color-mix(in srgb, var(--dash-accent) 8%, var(--dash-card-bg));
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-ac-metrics {
|
.dash-ac-metrics {
|
||||||
@@ -322,11 +278,15 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
|
|
||||||
.dash-ac-metric span {
|
.dash-ac-metric span {
|
||||||
display: block;
|
display: block;
|
||||||
color: #8892b0;
|
color: var(--dash-muted);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dash-ac-metric strong {
|
||||||
|
color: var(--dash-text);
|
||||||
|
}
|
||||||
|
|
||||||
.dash-ac-metric strong.pos {
|
.dash-ac-metric strong.pos {
|
||||||
color: var(--dash-ok);
|
color: var(--dash-ok);
|
||||||
}
|
}
|
||||||
@@ -339,7 +299,7 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: color-mix(in srgb, var(--dash-muted) 18%, transparent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,14 +307,14 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: linear-gradient(90deg, var(--dash-warn), var(--dash-mag));
|
background: var(--dash-warn);
|
||||||
transition: width 0.6s ease;
|
transition: width 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-ac-remark {
|
.dash-ac-remark {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: #8892b0;
|
color: var(--dash-muted);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -377,25 +337,25 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: rgba(12, 18, 36, 0.95);
|
background: var(--inset-surface);
|
||||||
color: var(--dash-cyan);
|
color: var(--dash-muted);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.04em;
|
||||||
border-bottom: 1px solid var(--dash-border);
|
border-bottom: 1px solid var(--dash-card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-table td {
|
.dash-table td {
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
border-bottom: 1px solid rgba(42, 52, 72, 0.5);
|
border-bottom: 1px solid var(--dash-card-border);
|
||||||
color: #c5cde0;
|
color: var(--dash-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-table tr:hover td {
|
.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 {
|
.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 {
|
.dash-table .pos {
|
||||||
@@ -409,14 +369,14 @@ body.hub-page-dashboard .page#page-dashboard {
|
|||||||
.dash-empty {
|
.dash-empty {
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--muted);
|
color: var(--dash-muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-status {
|
.dash-status {
|
||||||
font-family: JetBrains Mono, monospace;
|
font-family: JetBrains Mono, monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--muted);
|
color: var(--dash-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dash-status.err {
|
.dash-status.err {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* 中控数据看板:总览 / 分户 / 平仓明细,60s 自动刷新。
|
* 中控数据看板:后端 SSE 推送版本号,前端拉快照刷新(无轮询闪烁)。
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
const page = document.getElementById("page-dashboard");
|
const page = document.getElementById("page-dashboard");
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
|
|
||||||
const POLL_MS = 60 * 1000;
|
let dashEventSource = null;
|
||||||
let timer = null;
|
let dashReconnectTimer = null;
|
||||||
|
let localDashVersion = 0;
|
||||||
let inited = false;
|
let inited = false;
|
||||||
let loading = false;
|
let loading = false;
|
||||||
|
|
||||||
@@ -191,10 +192,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDashboard() {
|
async function fetchDashboardSnapshot(opts) {
|
||||||
if (loading) return;
|
const options = opts || {};
|
||||||
|
if (loading && !options.force) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
setStatus("同步中…");
|
if (!options.silent) setStatus("同步中…");
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/dashboard/daily", { credentials: "same-origin" });
|
const r = await fetch("/api/dashboard/daily", { credentials: "same-origin" });
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
@@ -202,9 +204,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await r.json();
|
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);
|
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) {
|
} catch (e) {
|
||||||
setStatus(String(e.message || e), true);
|
setStatus(String(e.message || e), true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -212,31 +217,78 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPoll() {
|
function closeDashboardStream() {
|
||||||
stopPoll();
|
if (dashEventSource) {
|
||||||
void fetchDashboard();
|
dashEventSource.close();
|
||||||
timer = setInterval(fetchDashboard, POLL_MS);
|
dashEventSource = null;
|
||||||
}
|
}
|
||||||
|
if (dashReconnectTimer) {
|
||||||
function stopPoll() {
|
clearTimeout(dashReconnectTimer);
|
||||||
if (timer) {
|
dashReconnectTimer = null;
|
||||||
clearInterval(timer);
|
|
||||||
timer = 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) {
|
if (btnRefresh) {
|
||||||
btnRefresh.addEventListener("click", () => void fetchDashboard());
|
btnRefresh.addEventListener("click", () => {
|
||||||
|
void requestDashboardRefresh();
|
||||||
|
void fetchDashboardSnapshot({ force: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.hubDashboardPage = {
|
window.hubDashboardPage = {
|
||||||
init() {
|
init() {
|
||||||
if (!inited) inited = true;
|
inited = true;
|
||||||
startPoll();
|
startLive();
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
stopPoll();
|
inited = false;
|
||||||
setStatus("");
|
stopLive();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||||
<link rel="stylesheet" href="/assets/app.css?v=20260609-hub-funds-fold" />
|
<link rel="stylesheet" href="/assets/app.css?v=20260609-hub-funds-fold" />
|
||||||
<link rel="stylesheet" href="/assets/dashboard.css?v=20260611-hub-dashboard" />
|
<link rel="stylesheet" href="/assets/dashboard.css?v=20260611-hub-dash-sse" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-bg" aria-hidden="true"></div>
|
<div class="app-bg" aria-hidden="true"></div>
|
||||||
@@ -316,13 +316,11 @@
|
|||||||
|
|
||||||
<div id="page-dashboard" class="page hidden">
|
<div id="page-dashboard" class="page hidden">
|
||||||
<div class="dash-bg-grid" aria-hidden="true"></div>
|
<div class="dash-bg-grid" aria-hidden="true"></div>
|
||||||
<div class="dash-bg-glow dash-bg-glow-a" aria-hidden="true"></div>
|
|
||||||
<div class="dash-bg-glow dash-bg-glow-b" aria-hidden="true"></div>
|
|
||||||
<div class="dash-wrap">
|
<div class="dash-wrap">
|
||||||
<div class="dash-head">
|
<div class="dash-head">
|
||||||
<div>
|
<div>
|
||||||
<h1><span class="dash-head-tag">DASH</span>数据看板</h1>
|
<h1><span class="dash-head-tag">DASH</span>数据看板</h1>
|
||||||
<p class="page-desc">四户当日总览 · 分户明细 · 平仓流水 · 每 60 秒刷新</p>
|
<p class="page-desc">四户当日总览 · 分户明细 · 平仓流水 · SSE 推送更新</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-head-meta">
|
<div class="dash-head-meta">
|
||||||
<div><span class="dash-pulse-dot" aria-hidden="true"></span><strong>LIVE</strong> · 交易日 <span id="dash-trading-day">—</span></div>
|
<div><span class="dash-pulse-dot" aria-hidden="true"></span><strong>LIVE</strong> · 交易日 <span id="dash-trading-day">—</span></div>
|
||||||
@@ -443,26 +441,13 @@
|
|||||||
<div id="page-ai" class="page hidden">
|
<div id="page-ai" class="page hidden">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1><span class="head-tag">AI</span> 教练</h1>
|
<h1><span class="head-tag">AI</span> 教练</h1>
|
||||||
<p class="page-desc">四户今日总结 · 交易教练 / 普通聊天 · 右侧可回看历史会话</p>
|
<p class="page-desc">交易教练 / 普通聊天 · 右侧可回看历史会话</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-mobile-tabs" role="tablist" aria-label="AI 教练视图">
|
<div class="ai-mobile-tabs" role="tablist" aria-label="AI 教练视图">
|
||||||
<button type="button" class="ai-mobile-tab is-active" data-ai-tab="chat" role="tab" aria-selected="true">聊天</button>
|
<button type="button" class="ai-mobile-tab is-active" data-ai-tab="chat" role="tab" aria-selected="true">聊天</button>
|
||||||
<button type="button" class="ai-mobile-tab" data-ai-tab="history" role="tab" aria-selected="false">历史</button>
|
<button type="button" class="ai-mobile-tab" data-ai-tab="history" role="tab" aria-selected="false">历史</button>
|
||||||
<button type="button" class="ai-mobile-tab" data-ai-tab="summary" role="tab" aria-selected="false">今日总结</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ai-layout" data-ai-mobile-tab="chat">
|
<div class="ai-layout" data-ai-mobile-tab="chat">
|
||||||
<section class="ai-panel ai-summary-panel" data-ai-panel="summary">
|
|
||||||
<div class="ai-panel-head">
|
|
||||||
<h2>今日总结</h2>
|
|
||||||
<div class="ai-panel-actions">
|
|
||||||
<button type="button" id="btn-ai-summary" class="primary">生成今日总结</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="ai-summary-stats" class="ai-stats-row" aria-live="polite"></div>
|
|
||||||
<div id="ai-summary-body" class="ai-panel-scroll ai-md-body">
|
|
||||||
<p class="ai-placeholder">点击「生成今日总结」聚合四户平仓与持仓数据(未启用账户显示「未监控」)。</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="ai-panel ai-chat-panel" data-ai-panel="chat">
|
<section class="ai-panel ai-chat-panel" data-ai-panel="chat">
|
||||||
<div class="ai-bot-bar" role="tablist" aria-label="聊天机器人">
|
<div class="ai-bot-bar" role="tablist" aria-label="聊天机器人">
|
||||||
<button type="button" class="ai-bot-tab is-active" data-bot="trading" role="tab" aria-selected="true">交易教练</button>
|
<button type="button" class="ai-bot-tab is-active" data-bot="trading" role="tab" aria-selected="true">交易教练</button>
|
||||||
@@ -560,8 +545,8 @@
|
|||||||
<script src="/assets/chart.js?v=20260609-market-day-split"></script>
|
<script src="/assets/chart.js?v=20260609-market-day-split"></script>
|
||||||
<script src="/assets/archive.js?v=20260608-hub-archive-history"></script>
|
<script src="/assets/archive.js?v=20260608-hub-archive-history"></script>
|
||||||
<script src="/assets/funds.js?v=20260609-hub-funds-fold"></script>
|
<script src="/assets/funds.js?v=20260609-hub-funds-fold"></script>
|
||||||
<script src="/assets/dashboard.js?v=20260611-hub-dashboard"></script>
|
<script src="/assets/dashboard.js?v=20260611-hub-dash-sse"></script>
|
||||||
<script src="/assets/ai_review_render.js?v=2"></script>
|
<script src="/assets/ai_review_render.js?v=2"></script>
|
||||||
<script src="/assets/app.js?v=20260611-hub-dashboard"></script>
|
<script src="/assets/app.js?v=20260611-hub-ai-chat"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
├─ /market 行情区(K 线、技术指标、持仓价格线)
|
├─ /market 行情区(K 线、技术指标、持仓价格线)
|
||||||
├─ /archive 币种档案(交易时间线 + 永久 5m K 线)
|
├─ /archive 币种档案(交易时间线 + 永久 5m K 线)
|
||||||
├─ /funds 资金概况(总资金曲线、分户资金与回撤)
|
├─ /funds 资金概况(总资金曲线、分户资金与回撤)
|
||||||
├─ /ai AI 教练(四户今日总结 + 聊天)
|
├─ /dashboard 数据看板(四户当日总览,SSE 推送;见 [数据看板说明.md](./数据看板说明.md))
|
||||||
|
├─ /ai AI 教练(交易教练 / 普通聊天;见 [AI教练说明.md](./AI教练说明.md))
|
||||||
└─ /settings 系统设置(hub_settings.json)
|
└─ /settings 系统设置(hub_settings.json)
|
||||||
|
|
||||||
中控 hub.py(默认 :5100)
|
中控 hub.py(默认 :5100)
|
||||||
@@ -207,13 +208,25 @@ Chrome **桌面快捷方式**图标来自站点 `favicon` / `manifest`(已配
|
|||||||
|
|
||||||
细则见 **[资金概况说明.md](./资金概况说明.md)**。
|
细则见 **[资金概况说明.md](./资金概况说明.md)**。
|
||||||
|
|
||||||
|
### 4.2.3 数据看板 `/dashboard`
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **总览** | 交易日、平仓盈亏、笔数、浮盈亏、资金合计、持仓数 |
|
||||||
|
| **分户** | 四户资金/交易账户、今日盈亏、浮盈亏;单日亏损 ≥ 资金合计 **5%** 高亮预警 |
|
||||||
|
| **平仓明细** | 当日平仓流水表 |
|
||||||
|
| **刷新** | 后台每 60s 聚合 + **SSE** 推送版本号;页面无整页轮询闪烁 |
|
||||||
|
| **主题** | 跟随顶栏亮/暗主题,卡片柔光样式(非霓虹背景) |
|
||||||
|
|
||||||
|
细则见 **[数据看板说明.md](./数据看板说明.md)**。
|
||||||
|
|
||||||
### 4.3 AI 教练 `/ai`
|
### 4.3 AI 教练 `/ai`
|
||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **今日总结** | 聚合四户当日平仓(`trade_records`)、持仓浮盈亏、连接状态;**未启用**账户标注 **未监控**;语气偏冷、台账式 |
|
| **交易教练** | 口语化陪聊;后台注入四户监控快照(不在页面展示今日总结) |
|
||||||
| **生成** | 点「生成今日总结」;结果写入 `hub_ai_summaries.json`(同目录备份) |
|
| **普通聊天** | 不绑交易数据 |
|
||||||
| **聊天** | **单会话**持续对话,直到点 **「新开对话」**;口语化、安慰体贴、轻修正(非说教) |
|
| **会话** | 多会话历史(切换/删除)、消息复制;点 **「新开对话」** 清空当前上下文 |
|
||||||
| **模型** | 与四实例相同 `.env`(默认 `AI_PROVIDER=openai` + `OPENAI_*`;改 `ollama` 走本机),见 [AI教练说明.md](./AI教练说明.md) |
|
| **模型** | 与四实例相同 `.env`(默认 `AI_PROVIDER=openai` + `OPENAI_*`;改 `ollama` 走本机),见 [AI教练说明.md](./AI教练说明.md) |
|
||||||
| **与实例复盘** | 深度单笔 journal 复盘仍在各所 `/records`;中控不做重复 |
|
| **与实例复盘** | 深度单笔 journal 复盘仍在各所 `/records`;中控不做重复 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 中控数据看板说明
|
||||||
|
|
||||||
|
入口:**`/dashboard`**(顶栏「数据看板」)。
|
||||||
|
|
||||||
|
## 能力
|
||||||
|
|
||||||
|
| 区块 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **总览 KPI** | 交易日、平仓盈亏、笔数、浮盈亏、资金合计、实盘持仓 |
|
||||||
|
| **分户明细** | 四户资金/交易账户、今日盈亏、浮盈亏、备注;未启用显示「未监控」 |
|
||||||
|
| **平仓明细** | 当日平仓流水(合约、方向、结果、盈亏、时间) |
|
||||||
|
| **风险预警** | 单户单日平仓亏损 ≥ 资金合计 **5%** 时横幅 + 卡片高亮 |
|
||||||
|
|
||||||
|
纯数据聚合,**不调用 AI**。交易日口径与实例一致(`TRADING_DAY_RESET_HOUR`,默认 8 点)。
|
||||||
|
|
||||||
|
## 刷新机制(SSE)
|
||||||
|
|
||||||
|
与监控区 board 类似,采用 **后台聚合 + SSE 推送版本号**:
|
||||||
|
|
||||||
|
1. `hub.py` 启动后 `dashboard_store` 每 **60s**(`DASHBOARD_POLL_INTERVAL_SEC`)聚合四户数据到内存快照。
|
||||||
|
2. 浏览器打开看板页后连接 `GET /api/dashboard/stream`(`event: dashboard`)。
|
||||||
|
3. 收到新版本号后拉取 `GET /api/dashboard/daily` 快照并局部渲染,**无整页轮询闪烁**。
|
||||||
|
4. 监控区触发 board 刷新(全平、撤单等)时,会一并 `request_refresh` 看板,尽量与实盘同步。
|
||||||
|
5. 「立即刷新」→ `POST /api/dashboard/refresh` 触发下一轮聚合。
|
||||||
|
|
||||||
|
可选环境变量:`HUB_DASHBOARD_SSE_HEARTBEAT_SEC`(默认 25,SSE 心跳间隔)。
|
||||||
|
|
||||||
|
## 主题与样式
|
||||||
|
|
||||||
|
- 跟随中控顶栏 **亮/暗主题**(`theme.js`),使用 `--panel` / `--border` / `--accent` 等变量。
|
||||||
|
- 卡片采用 **柔光阴影**(非霓虹渐变背景);亮色主题下为浅灰投影,暗色主题为轻微内高光。
|
||||||
|
- 盈亏仍用绿/红语义色,与全局一致。
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/dashboard/daily` | 当前交易日快照(含 `dashboard_version`) |
|
||||||
|
| GET | `/api/dashboard/stream` | SSE 版本推送 |
|
||||||
|
| POST | `/api/dashboard/refresh` | 请求立即重聚合 |
|
||||||
|
|
||||||
|
`GET /api/ping` 含 `dashboard_version`、`dashboard_poll_interval_sec` 等字段。
|
||||||
|
|
||||||
|
## 相关文件
|
||||||
|
|
||||||
|
- `hub_dashboard.py` — 聚合逻辑
|
||||||
|
- `hub_dashboard_cache.py` — 后台轮询 + SSE
|
||||||
|
- `static/dashboard.js` / `dashboard.css` — 前端
|
||||||
|
|
||||||
|
部署后 `git pull` 并 `pm2 restart manual-trading-hub`。
|
||||||
Reference in New Issue
Block a user