From 582ada7e60cd1bd6086da1e9d7f2d24dd3edb06e Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 11 Jun 2026 10:42:33 +0800 Subject: [PATCH] feat(hub): add data dashboard and AI chat with session history Add /dashboard with daily PnL overview and loss alerts. Extend AI coach chat with history sidebar, delete/switch sessions, message copy, and trading vs general bot modes. Co-authored-by: Cursor --- manual_trading_hub/hub.py | 17 +- manual_trading_hub/hub_ai/chat.py | 99 ++++-- manual_trading_hub/hub_ai/prompts.py | 27 ++ manual_trading_hub/hub_ai/routes.py | 29 +- manual_trading_hub/hub_ai/store.py | 83 ++++- manual_trading_hub/hub_dashboard.py | 98 ++++++ manual_trading_hub/static/app.css | 212 +++++++++++- manual_trading_hub/static/app.js | 204 ++++++++++- manual_trading_hub/static/dashboard.css | 434 ++++++++++++++++++++++++ manual_trading_hub/static/dashboard.js | 242 +++++++++++++ manual_trading_hub/static/index.html | 89 +++-- 11 files changed, 1479 insertions(+), 55 deletions(-) create mode 100644 manual_trading_hub/hub_dashboard.py create mode 100644 manual_trading_hub/static/dashboard.css create mode 100644 manual_trading_hub/static/dashboard.js diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index a2b82b9..244e709 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -648,6 +648,7 @@ def root_redirect(): @app.get("/monitor") @app.get("/market") @app.get("/archive") +@app.get("/dashboard") @app.get("/funds") @app.get("/ai") @app.get("/settings") @@ -661,10 +662,24 @@ def _all_exchanges_for_ai() -> list: from hub_ai.routes import create_hub_ai_router +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.get("/api/dashboard/daily") +def api_dashboard_daily(trading_day: str = ""): + day = (trading_day or "").strip()[:10] or default_trading_day() + try: + payload = build_dashboard_payload( + _all_exchanges_for_ai(), + trading_day=day, + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return payload + + @app.get("/trade") def trade_removed_redirect(): from fastapi.responses import RedirectResponse @@ -2107,7 +2122,7 @@ def api_ping(): "service": "manual-trading-hub", "build": HUB_BUILD, "trade_ui": False, - "features": ["monitor", "settings", "auth", "board_sse", "archive", "funds"], + "features": ["monitor", "settings", "auth", "board_sse", "archive", "dashboard", "funds"], "board_poll_interval_sec": HUB_BOARD_POLL_INTERVAL, "board_version": board_store.version, "board_aggregating": board_store.aggregating, diff --git a/manual_trading_hub/hub_ai/chat.py b/manual_trading_hub/hub_ai/chat.py index 76af86c..e37ea56 100644 --- a/manual_trading_hub/hub_ai/chat.py +++ b/manual_trading_hub/hub_ai/chat.py @@ -13,15 +13,27 @@ from hub_ai.config import ( CHAT_MAX_OUTPUT_TOKENS, CHAT_SUMMARY_EXCERPT_MAX_CHARS, CHAT_TEMPERATURE, + trading_day_reset_hour, ) +from hub_trades_lib import current_trading_day from hub_ai.context import build_daily_context, format_chat_context_for_chat -from hub_ai.prompts import CHAT_SYSTEM, build_chat_user_prompt +from hub_ai.prompts import ( + CHAT_GENERAL_SYSTEM, + CHAT_SYSTEM, + build_chat_user_prompt, + build_general_chat_user_prompt, +) from hub_ai.store import ( + CHAT_BOT_GENERAL, + CHAT_BOT_TRADING, append_chat_message, create_new_session, + delete_chat_session, ensure_active_session, get_active_session, + list_chat_sessions, load_chat_store, + set_active_session, summary_excerpt_for_chat, ) @@ -58,16 +70,48 @@ def _history_lines( def get_chat_state() -> dict[str, Any]: store = load_chat_store() session = get_active_session() + if session: + session.setdefault("bot_mode", CHAT_BOT_TRADING) return { "active_session_id": store.get("active_session_id"), "session": session, + "sessions": list_chat_sessions(), "model": model_label(), } -def start_new_chat(*, trading_day: str) -> dict: - session = create_new_session(trading_day=trading_day) - return {"ok": True, "session": session, "model": model_label()} +def start_new_chat(*, trading_day: str, bot_mode: str = CHAT_BOT_TRADING) -> dict: + session = create_new_session(trading_day=trading_day, bot_mode=bot_mode) + return { + "ok": True, + "session": session, + "sessions": list_chat_sessions(), + "model": model_label(), + } + + +def switch_chat_session(session_id: str) -> dict[str, Any]: + session = set_active_session(session_id) + return { + "ok": True, + "session": session, + "sessions": list_chat_sessions(), + "model": model_label(), + } + + +def remove_chat_session(session_id: str) -> dict[str, Any]: + deleted, new_active = delete_chat_session(session_id) + if not deleted: + return {"ok": False, "msg": "session_not_found"} + session = get_active_session() + return { + "ok": True, + "active_session_id": new_active, + "session": session, + "sessions": list_chat_sessions(), + "model": model_label(), + } def send_chat_message( @@ -90,8 +134,9 @@ def send_chat_message( if not user_visible and parsed.get("attachment_note"): user_visible = f"(上传了 {parsed['attachment_note']})" - ctx = build_daily_context(exchanges, trading_day=trading_day) - day = ctx["trading_day"] + day = (trading_day or "").strip()[:10] or current_trading_day( + reset_hour=trading_day_reset_hour() + ) session = ensure_active_session(trading_day=day) sid = session["id"] history = _history_lines( @@ -106,22 +151,35 @@ def send_chat_message( attachments=parsed.get("attachment_meta") or [], ) - brief_ctx = format_chat_context_for_chat(ctx, max_chars=CHAT_CONTEXT_MAX_CHARS) - excerpt = summary_excerpt_for_chat(day, max_chars=CHAT_SUMMARY_EXCERPT_MAX_CHARS) - - user_prompt = build_chat_user_prompt( - context_text=brief_ctx, - trading_day=day, - summary_excerpt=excerpt, - history_lines=history, - user_message=text or user_visible, - attachment_note=str(parsed.get("attachment_note") or ""), - ) - if parsed.get("text_append"): - user_prompt += "\n\n【附件正文】\n" + parsed["text_append"] + bot_mode = (session.get("bot_mode") or CHAT_BOT_TRADING).strip().lower() + if bot_mode == CHAT_BOT_GENERAL: + user_prompt = build_general_chat_user_prompt( + history_lines=history, + user_message=text or user_visible, + attachment_note=str(parsed.get("attachment_note") or ""), + ) + if parsed.get("text_append"): + user_prompt += "\n\n【附件正文】\n" + parsed["text_append"] + system_prompt = CHAT_GENERAL_SYSTEM + else: + ctx = build_daily_context(exchanges, trading_day=day) + day = ctx["trading_day"] + brief_ctx = format_chat_context_for_chat(ctx, max_chars=CHAT_CONTEXT_MAX_CHARS) + excerpt = summary_excerpt_for_chat(day, max_chars=CHAT_SUMMARY_EXCERPT_MAX_CHARS) + user_prompt = build_chat_user_prompt( + context_text=brief_ctx, + trading_day=day, + summary_excerpt=excerpt, + history_lines=history, + user_message=text or user_visible, + attachment_note=str(parsed.get("attachment_note") or ""), + ) + if parsed.get("text_append"): + user_prompt += "\n\n【附件正文】\n" + parsed["text_append"] + system_prompt = CHAT_SYSTEM reply = generate_text( - system=CHAT_SYSTEM, + system=system_prompt, user=user_prompt, temperature=CHAT_TEMPERATURE, images_b64=parsed.get("images_b64") or None, @@ -136,6 +194,7 @@ def send_chat_message( "ok": True, "trading_day": day, "session": session, + "sessions": list_chat_sessions(), "reply": reply, "model": model_label(), "attachment_warnings": parsed.get("errors") or [], diff --git a/manual_trading_hub/hub_ai/prompts.py b/manual_trading_hub/hub_ai/prompts.py index 49c6be0..849a13b 100644 --- a/manual_trading_hub/hub_ai/prompts.py +++ b/manual_trading_hub/hub_ai/prompts.py @@ -65,6 +65,33 @@ def build_summary_user_prompt(context_text: str, trading_day: str) -> str: """.strip() +CHAT_GENERAL_SYSTEM = """ +你是简洁、友好的中文助手,陪用户闲聊、答疑、整理思路。 + +规则: +- 口语化、自然,不要列清单式说教,不要「作为 AI 我必须…」。 +- 用户未主动聊交易时,不要主动扯合约、仓位、盈亏、盯盘。 +- 你没有接入用户的交易账户数据;不要编造持仓、资金或监控状态。若被问到交易事实,说明这边看不到实盘,建议去中控监控区或实例页查看。 +- 若用户上传图片或文档,结合可见内容回应;看不清的直说。 +- 接续【此前对话】,不要重复开场白;回复须写完整,以句号/问号/感叹号收尾。 +""".strip() + + +def build_general_chat_user_prompt( + *, + history_lines: str, + user_message: str, + attachment_note: str = "", +) -> str: + parts: list[str] = [] + if history_lines.strip(): + parts.extend(["【此前对话(须接续,勿重复开场)】", history_lines.strip()]) + if attachment_note.strip(): + parts.extend(["【用户附件说明】", attachment_note.strip()]) + parts.extend(["【用户现在说(优先回应这一条)】", user_message.strip()]) + return "\n\n".join(parts) + + def build_chat_user_prompt( *, context_text: str, diff --git a/manual_trading_hub/hub_ai/routes.py b/manual_trading_hub/hub_ai/routes.py index 45e64f6..94bf83e 100644 --- a/manual_trading_hub/hub_ai/routes.py +++ b/manual_trading_hub/hub_ai/routes.py @@ -6,7 +6,13 @@ from typing import Callable from fastapi import APIRouter, File, Form, HTTPException, UploadFile from pydantic import BaseModel, Field -from hub_ai.chat import get_chat_state, send_chat_message, start_new_chat +from hub_ai.chat import ( + get_chat_state, + remove_chat_session, + send_chat_message, + start_new_chat, + switch_chat_session, +) from hub_ai.client import model_label from hub_ai.config import trading_day_reset_hour from hub_ai.context import build_daily_context @@ -27,6 +33,11 @@ class SummaryGenerateBody(BaseModel): class ChatNewBody(BaseModel): trading_day: str = "" + bot_mode: str = "trading" + + +class ChatSwitchBody(BaseModel): + session_id: str = Field(..., min_length=1) def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter: @@ -91,7 +102,21 @@ def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter @router.post("/chat/new") def api_ai_chat_new(body: ChatNewBody = ChatNewBody()): day = _day(body.trading_day) - return start_new_chat(trading_day=day) + return start_new_chat(trading_day=day, bot_mode=body.bot_mode or "trading") + + @router.post("/chat/switch") + def api_ai_chat_switch(body: ChatSwitchBody): + try: + return switch_chat_session(body.session_id.strip()) + except KeyError: + raise HTTPException(status_code=404, detail="会话不存在") + + @router.delete("/chat/session/{session_id}") + def api_ai_chat_delete(session_id: str): + result = remove_chat_session(session_id.strip()) + if not result.get("ok"): + raise HTTPException(status_code=404, detail="会话不存在") + return result @router.post("/chat/send") async def api_ai_chat_send( diff --git a/manual_trading_hub/hub_ai/store.py b/manual_trading_hub/hub_ai/store.py index a2cfbb9..5197b15 100644 --- a/manual_trading_hub/hub_ai/store.py +++ b/manual_trading_hub/hub_ai/store.py @@ -140,12 +140,28 @@ def get_active_session() -> Optional[dict]: return None -def create_new_session(*, trading_day: str, title: str = "新对话") -> dict: +CHAT_BOT_TRADING = "trading" +CHAT_BOT_GENERAL = "general" +CHAT_BOT_MODES = frozenset({CHAT_BOT_TRADING, CHAT_BOT_GENERAL}) + + +def _normalize_bot_mode(raw: Any) -> str: + mode = (raw or CHAT_BOT_TRADING).strip().lower() + return mode if mode in CHAT_BOT_MODES else CHAT_BOT_TRADING + + +def create_new_session( + *, + trading_day: str, + title: str = "新对话", + bot_mode: str = CHAT_BOT_TRADING, +) -> dict: store = load_chat_store() session = { "id": uuid.uuid4().hex, "trading_day": trading_day, "title": title, + "bot_mode": _normalize_bot_mode(bot_mode), "created_at": _now_str(), "updated_at": _now_str(), "messages": [], @@ -193,6 +209,71 @@ def append_chat_message( return target +def _session_list_item(s: dict, *, active_id: Optional[str]) -> dict: + msgs = s.get("messages") or [] + preview = "" + for m in reversed(msgs): + if m.get("role") == "user": + preview = str(m.get("content") or "").replace("\n", " ")[:48] + break + if not preview and msgs: + last = msgs[-1] + preview = str(last.get("content") or "").replace("\n", " ")[:48] + sid = str(s.get("id") or "") + return { + "id": sid, + "title": s.get("title") or "新对话", + "bot_mode": _normalize_bot_mode(s.get("bot_mode")), + "trading_day": s.get("trading_day"), + "created_at": s.get("created_at"), + "updated_at": s.get("updated_at"), + "message_count": len(msgs), + "preview": preview, + "is_active": sid and sid == str(active_id or ""), + } + + +def list_chat_sessions(*, limit: int = 50) -> list[dict]: + store = load_chat_store() + active_id = store.get("active_session_id") + sessions = list(store.get("sessions") or []) + for s in sessions: + s.setdefault("bot_mode", CHAT_BOT_TRADING) + sessions.sort(key=lambda x: str(x.get("updated_at") or ""), reverse=True) + return [_session_list_item(s, active_id=active_id) for s in sessions[: max(1, min(limit, 100))]] + + +def set_active_session(session_id: str) -> dict: + store = load_chat_store() + target = None + for s in store.get("sessions") or []: + if str(s.get("id")) == str(session_id): + target = s + break + if not target: + raise KeyError("session_not_found") + target.setdefault("bot_mode", CHAT_BOT_TRADING) + store["active_session_id"] = target["id"] + save_chat_store(store) + return target + + +def delete_chat_session(session_id: str) -> tuple[bool, Optional[str]]: + store = load_chat_store() + sessions = list(store.get("sessions") or []) + new_sessions = [s for s in sessions if str(s.get("id")) != str(session_id)] + if len(new_sessions) == len(sessions): + return False, None + active = store.get("active_session_id") + new_active = active + if str(active) == str(session_id): + new_active = new_sessions[0]["id"] if new_sessions else None + store["sessions"] = new_sessions + store["active_session_id"] = new_active + save_chat_store(store) + return True, new_active + + def summary_excerpt_for_chat(trading_day: str, max_chars: int = 600) -> str: latest = get_latest_summary(trading_day) if not latest: diff --git a/manual_trading_hub/hub_dashboard.py b/manual_trading_hub/hub_dashboard.py new file mode 100644 index 0000000..220c775 --- /dev/null +++ b/manual_trading_hub/hub_dashboard.py @@ -0,0 +1,98 @@ +"""中控数据看板:四户当日总览(无 AI,纯数据聚合)。""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from hub_ai.context import ( + build_daily_context, + collect_closed_trades_snapshot, + format_account_remark, +) +from hub_ai.config import trading_day_reset_hour +from hub_trades_lib import current_trading_day + +LOSS_ALERT_PCT = 5.0 +DASHBOARD_POLL_INTERVAL_SEC = 60 + + +def _safe_float(v: Any) -> Optional[float]: + try: + if v is None or v == "": + return None + return float(v) + except (TypeError, ValueError): + return None + + +def _account_capital_base(ac: dict) -> Optional[float]: + funding = _safe_float(ac.get("funding_usdt")) + trading = _safe_float(ac.get("trading_usdt")) + if funding is not None and trading is not None: + return funding + trading + if funding is not None: + return funding + if trading is not None: + return trading + return None + + +def _enrich_account_row(ac: dict) -> dict: + st = ac.get("trade_stats") or {} + capital = _account_capital_base(ac) + day_pnl = float(st.get("total_pnl_u") or 0) + loss_pct: Optional[float] = None + loss_alert = False + if capital is not None and capital > 0 and day_pnl < -1e-9: + loss_pct = round(abs(day_pnl) / capital * 100.0, 2) + loss_alert = loss_pct >= LOSS_ALERT_PCT + return { + "id": ac.get("id"), + "key": ac.get("key"), + "name": ac.get("name"), + "status": ac.get("status"), + "monitored": ac.get("status") != "未监控", + "funding_usdt": ac.get("funding_usdt"), + "trading_usdt": ac.get("trading_usdt"), + "capital_total_usdt": round(capital, 4) if capital is not None else None, + "available_trading_usdt": ac.get("available_trading_usdt"), + "pnl_u": st.get("total_pnl_u"), + "closed_count": st.get("closed_count"), + "win_count": st.get("win_count"), + "loss_count": st.get("loss_count"), + "float_pnl_u": ac.get("float_pnl_u"), + "open_position_count": ac.get("open_position_count"), + "remark": format_account_remark(ac), + "issues": ac.get("issues") or [], + "daily_loss_pct": loss_pct, + "loss_alert": loss_alert, + } + + +def build_dashboard_payload( + exchanges: list[dict], + *, + trading_day: str | None = None, +) -> dict[str, Any]: + ctx = build_daily_context(exchanges, trading_day=trading_day) + day = ctx["trading_day"] + accounts_raw = ctx.get("accounts") or [] + accounts = [_enrich_account_row(ac) for ac in accounts_raw] + closed_trades = collect_closed_trades_snapshot(accounts_raw, today=day) + loss_alert_count = sum(1 for ac in accounts if ac.get("loss_alert")) + now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S") + return { + "ok": True, + "updated_at": now, + "trading_day": day, + "totals": ctx.get("totals"), + "accounts": accounts, + "closed_trades": closed_trades, + "loss_alert_pct_threshold": LOSS_ALERT_PCT, + "loss_alert_count": loss_alert_count, + "poll_interval_sec": DASHBOARD_POLL_INTERVAL_SEC, + } + + +def default_trading_day() -> str: + return current_trading_day(reset_hour=trading_day_reset_hour()) diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 78bcbcf..2513b9e 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -3939,7 +3939,8 @@ body.hub-page-ai #page-ai { } 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="summary"] .ai-chat-panel, + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-summary-panel { display: none !important; } @@ -3953,6 +3954,35 @@ body.hub-page-ai #page-ai { min-width: 0; } + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-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="chat"] .ai-chat-history-panel { + display: none !important; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-main { + display: none !important; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-bot-bar { + display: none; + } + + body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-history-panel { + display: flex; + flex: 1 1 auto; + min-height: 0; + border-left: none; + width: 100%; + } + body.hub-page-ai .ai-panel { width: 100%; max-width: 100%; @@ -4291,6 +4321,186 @@ body.hub-page-ai #page-ai { color: var(--muted); margin: 0; } +.ai-chat-panel { + gap: 8px; +} +.ai-chat-panel .ai-chat-split { + flex: 1 1 auto; +} +.ai-bot-bar { + display: flex; + gap: 8px; + flex-shrink: 0; + padding-bottom: 2px; +} +.ai-bot-tab { + flex: 1; + min-height: 36px; + padding: 6px 12px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--inset-surface); + color: var(--muted); + font-family: var(--font); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} +.ai-bot-tab:hover { + border-color: var(--accent); + color: var(--text); +} +.ai-bot-tab.is-active { + color: var(--text); + border-color: var(--accent); + background: var(--accent-dim); + box-shadow: var(--glow); +} +.ai-chat-split { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(200px, 260px); + gap: 0; + overflow: hidden; + border: 1px solid var(--border-soft); + border-radius: 8px; +} +.ai-chat-main { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + overflow: hidden; +} +.ai-chat-history-panel { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + border-left: 1px solid var(--border-soft); + background: color-mix(in srgb, var(--inset-surface) 65%, var(--panel)); +} +.ai-chat-history-head { + flex-shrink: 0; + padding: 10px 12px 6px; + border-bottom: 1px solid var(--border-soft); +} +.ai-chat-history-head h3 { + margin: 0; + font-size: 0.82rem; + font-weight: 700; + color: var(--muted); + letter-spacing: 0.04em; +} +.ai-chat-history-list { + flex: 1 1 auto; + min-height: 0; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} +.ai-chat-history-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 8px; + align-items: start; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-soft); + background: var(--panel); + cursor: pointer; + text-align: left; + transition: border-color 0.15s, background 0.15s; +} +.ai-chat-history-item:hover { + border-color: var(--accent); +} +.ai-chat-history-item.is-active { + border-color: var(--accent); + background: var(--accent-dim); + box-shadow: var(--glow); +} +.ai-chat-history-item-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} +.ai-chat-history-item-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ai-chat-history-item-preview { + font-size: 0.72rem; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ai-chat-history-item-meta { + font-size: 0.68rem; + color: var(--muted); + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.ai-chat-history-badge { + display: inline-flex; + padding: 1px 6px; + border-radius: 999px; + font-size: 0.62rem; + font-weight: 600; + border: 1px solid var(--border-soft); + color: var(--muted); +} +.ai-chat-history-badge.trading { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 40%, var(--border-soft)); +} +.ai-chat-history-del { + min-width: 28px; + min-height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: 1rem; + line-height: 1; + cursor: pointer; +} +.ai-chat-history-del:hover { + color: var(--red); + background: color-mix(in srgb, var(--red) 12%, transparent); +} +.ai-msg-actions { + display: flex; + gap: 6px; + padding: 0 4px; +} +.ai-msg-copy-btn { + min-height: 24px; + padding: 2px 8px; + border-radius: 6px; + border: 1px solid var(--border-soft); + background: var(--panel); + color: var(--muted); + font-size: 0.68rem; + font-weight: 600; + cursor: pointer; +} +.ai-msg-copy-btn:hover { + border-color: var(--accent); + color: var(--accent); +} .ai-chat-messages { display: flex; flex-direction: column; diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index ef192e1..02496f0 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -644,6 +644,7 @@ const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; if (p.includes("settings")) return "settings"; if (p.includes("archive")) return "archive"; + if (p.includes("dashboard")) return "dashboard"; if (p.includes("funds")) return "funds"; if (p.includes("market")) return "market"; if (p.includes("/ai")) return "ai"; @@ -653,6 +654,7 @@ function pageElementId(page) { if (page === "settings") return "page-settings"; if (page === "archive") return "page-archive"; + if (page === "dashboard") return "page-dashboard"; if (page === "funds") return "page-funds"; if (page === "market") return "page-market"; if (page === "ai") return "page-ai"; @@ -674,9 +676,15 @@ }); document.body.classList.toggle("hub-page-ai", page === "ai"); document.body.classList.toggle("hub-page-funds", page === "funds"); + document.body.classList.toggle("hub-page-dashboard", page === "dashboard"); syncHubAiMobileViewport(); if (page === "monitor") startMonitorPoll(); else stopMonitorPoll(); + if (page === "dashboard" && window.hubDashboardPage) { + window.hubDashboardPage.init(); + } else if (window.hubDashboardPage && window.hubDashboardPage.destroy) { + window.hubDashboardPage.destroy(); + } if (page === "settings") loadSettingsUI(); if (page === "ai") loadAiPage(); if (page === "archive" && window.hubArchivePage) { @@ -1010,6 +1018,10 @@ btn.setAttribute("aria-selected", on ? "true" : "false"); }); if (mobile && active === "chat") scrollAiChatToEnd(); + if (mobile && active === "history") { + const hist = document.getElementById("ai-chat-history-list"); + if (hist) hist.scrollTop = 0; + } } function initAiMobileTabs() { @@ -3133,6 +3145,8 @@ let aiSummaryLoading = false; let aiChatLoading = false; let aiChatSessionCache = null; + let aiChatSessionsCache = []; + let aiSelectedBotMode = "trading"; function aiPnlClass(v) { const n = Number(v); @@ -3324,9 +3338,64 @@ requestAnimationFrame(() => requestAnimationFrame(run)); } - function renderAiChatRow(role, content, extraClass, attachments) { + function updateAiBotTabs(mode) { + const m = mode === "general" ? "general" : "trading"; + aiSelectedBotMode = m; + document.querySelectorAll(".ai-bot-tab").forEach((btn) => { + const on = (btn.dataset.bot || "trading") === m; + btn.classList.toggle("is-active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + const input = document.getElementById("ai-chat-input"); + if (input) { + input.placeholder = + m === "general" + ? "随便聊点什么,不绑交易数据…" + : "聊聊行情、心态、纪律、执行…"; + } + } + + function renderAiChatHistory(sessions) { + const list = document.getElementById("ai-chat-history-list"); + if (!list) return; + const items = Array.isArray(sessions) ? sessions : []; + if (!items.length) { + list.innerHTML = '

暂无历史,发送消息后会出现在这里。

'; + return; + } + list.innerHTML = items + .map((s) => { + const mode = s.bot_mode === "general" ? "general" : "trading"; + const badge = mode === "general" ? "普通" : "交易"; + const badgeCls = mode === "general" ? "" : " trading"; + const active = s.is_active ? " is-active" : ""; + const time = esc((s.updated_at || s.created_at || "").slice(0, 16)); + const title = esc(s.title || "新对话"); + const preview = esc(s.preview || "(空会话)"); + const sid = esc(s.id || ""); + return ( + `
` + + `
` + + `${title}` + + `${preview}` + + `` + + `${time}` + + `${badge}` + + `${Number(s.message_count) || 0} 条` + + `` + + `
` + + `` + + `
` + ); + }) + .join(""); + } + + function renderAiChatRow(role, content, extraClass, attachments, rowOpts) { + const opts = rowOpts || {}; + const botMode = opts.botMode === "general" ? "general" : "trading"; const isUser = role === "user"; - const label = isUser ? "主人" : "AI教练"; + const label = isUser ? "主人" : botMode === "general" ? "助手" : "交易教练"; const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach"; const bubbleCls = isUser ? "ai-bubble-user" : "ai-bubble-assistant"; const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking"); @@ -3342,11 +3411,16 @@ .map((a) => `${esc(a.name || "附件")}`) .join("")}` : ""; + const canCopy = !isThinking && String(content || "").trim(); + const copyHtml = canCopy + ? `
` + : ""; return ( `
` + `${label}` + `${attHtml}` + `
${bubbleInner}
` + + `${copyHtml}` + `
` ); } @@ -3357,23 +3431,30 @@ const title = document.getElementById("ai-chat-title"); if (!box) return; const msgs = (session && session.messages) || []; + const botMode = (session && session.bot_mode) || aiSelectedBotMode || "trading"; if (title) { - title.textContent = session && session.title ? `聊天 · ${session.title}` : "聊天"; + const modeLabel = botMode === "general" ? "普通聊天" : "交易教练"; + title.textContent = + session && session.title ? `${modeLabel} · ${session.title}` : modeLabel; } const showPlaceholder = !msgs.length && !options.pendingUser && !options.thinking; if (showPlaceholder) { - box.innerHTML = - '

主人发消息会立刻出现在右侧;AI教练 会先显示「正在思考…」再回复。可点「附件」上传图片或文档。

'; + const hint = + botMode === "general" + ? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。" + : "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可点「附件」上传图片或文档。"; + box.innerHTML = `

${hint}

`; return; } let html = msgs - .map((m) => + .map((m, idx) => renderAiChatRow( m.role === "user" ? "user" : "assistant", m.content || "", null, - m.attachments + m.attachments, + { botMode, msgIdx: idx } ) ) .join(""); @@ -3414,7 +3495,54 @@ const r = await apiFetch("/api/ai/chat/session"); const j = await r.json(); aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + updateAiBotTabs((aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode); + } + + async function switchAiChatSession(sessionId) { + if (!sessionId || aiChatLoading) return; + try { + const r = await apiFetch("/api/ai/chat/switch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "切换失败"); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + updateAiBotTabs((aiChatSessionCache && aiChatSessionCache.bot_mode) || "trading"); + applyAiMobileTab("chat"); + scrollAiChatToEnd(); + } catch (e) { + showToast(String(e), true); + } + } + + async function deleteAiChatSession(sessionId) { + if (!sessionId) return; + if (!confirm("确定删除这条聊天历史?")) return; + try { + const r = await apiFetch(`/api/ai/chat/session/${encodeURIComponent(sessionId)}`, { + method: "DELETE", + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "删除失败"); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + updateAiBotTabs( + (aiChatSessionCache && aiChatSessionCache.bot_mode) || aiSelectedBotMode || "trading" + ); + showToast("已删除"); + } catch (e) { + showToast(String(e), true); + } } async function loadAiPage() { @@ -3460,17 +3588,22 @@ } } - async function newAiChat() { + async function newAiChat(botMode) { + const mode = botMode === "general" ? "general" : "trading"; try { const r = await apiFetch("/api/ai/chat/new", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), + body: JSON.stringify({ bot_mode: mode }), }); const j = await r.json(); aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || []; renderAiChatMessages(aiChatSessionCache); - showToast("已开始新对话"); + renderAiChatHistory(aiChatSessionsCache); + updateAiBotTabs(mode); + applyAiMobileTab("chat"); + showToast(mode === "general" ? "已开始普通聊天" : "已开始交易教练对话"); } catch (e) { showToast(String(e), true); } @@ -3501,7 +3634,9 @@ const j = await r.json(); if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || aiChatSessionsCache; renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); if (fileInput) fileInput.value = ""; if (fileLabel) fileLabel.textContent = ""; if (j.attachment_warnings && j.attachment_warnings.length) { @@ -3531,10 +3666,57 @@ 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(); + if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(aiSelectedBotMode); const aiChatForm = document.getElementById("ai-chat-form"); if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat); + function initAiChatInteractions() { + const hist = document.getElementById("ai-chat-history-list"); + if (hist && !hist._aiBound) { + hist._aiBound = true; + hist.addEventListener("click", (ev) => { + const delBtn = ev.target.closest(".ai-chat-history-del"); + if (delBtn) { + ev.stopPropagation(); + const sid = delBtn.getAttribute("data-delete-session"); + if (sid) deleteAiChatSession(sid); + return; + } + const item = ev.target.closest(".ai-chat-history-item"); + if (!item) return; + const sid = item.getAttribute("data-session-id"); + if (sid) switchAiChatSession(sid); + }); + } + const box = document.getElementById("ai-chat-messages"); + if (box && !box._aiCopyBound) { + box._aiCopyBound = true; + box.addEventListener("click", async (ev) => { + const btn = ev.target.closest(".ai-msg-copy-btn"); + if (!btn) return; + const idx = Number(btn.getAttribute("data-msg-idx")); + const msgs = (aiChatSessionCache && aiChatSessionCache.messages) || []; + const text = msgs[idx] && msgs[idx].content ? String(msgs[idx].content) : ""; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + showToast("已复制"); + } catch (_) { + showToast("复制失败", true); + } + }); + } + document.querySelectorAll(".ai-bot-tab").forEach((btn) => { + if (btn._aiBotBound) return; + btn._aiBotBound = true; + btn.addEventListener("click", () => { + const mode = btn.getAttribute("data-bot") || "trading"; + newAiChat(mode); + }); + }); + } + initAiChatInteractions(); + initTpslModal(); initInstanceFrame(); initFullscreen(); diff --git a/manual_trading_hub/static/dashboard.css b/manual_trading_hub/static/dashboard.css new file mode 100644 index 0000000..2a4c8f0 --- /dev/null +++ b/manual_trading_hub/static/dashboard.css @@ -0,0 +1,434 @@ +/* 数据看板 — 科技感展示 */ +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); +} + +body.hub-page-dashboard .page#page-dashboard { + position: relative; + overflow: hidden; +} + +.dash-bg-grid { + position: absolute; + inset: 0; + pointer-events: none; + 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); +} + +.dash-wrap { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 18px; + min-height: calc(100vh - 120px); +} + +.dash-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.dash-head h1 { + font-family: Orbitron, var(--font-sans, system-ui), sans-serif; + font-size: clamp(1.35rem, 2.5vw, 1.85rem); + font-weight: 700; + letter-spacing: 0.06em; + margin: 0; + background: linear-gradient(90deg, var(--dash-cyan), #8fc8ff 45%, var(--dash-mag)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.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); + padding: 2px 8px; + border-radius: 4px; + margin-right: 10px; + vertical-align: middle; + letter-spacing: 0.12em; +} + +.dash-head-meta { + font-family: JetBrains Mono, monospace; + font-size: 0.78rem; + color: var(--muted); + text-align: right; +} + +.dash-head-meta strong { + color: var(--dash-cyan); + font-weight: 500; +} + +.dash-pulse-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--dash-ok); + margin-right: 6px; + box-shadow: 0 0 10px var(--dash-ok); + animation: dash-pulse 2s ease-in-out infinite; +} + +@keyframes dash-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.55; + transform: scale(0.85); + } +} + +.dash-kpi-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 12px; +} + +.dash-kpi { + position: relative; + padding: 16px 18px; + border-radius: 12px; + background: var(--dash-panel); + border: 1px solid var(--dash-border); + backdrop-filter: blur(12px); + 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; + margin-bottom: 8px; +} + +.dash-kpi-value { + font-family: JetBrains Mono, monospace; + font-size: clamp(1.25rem, 2.2vw, 1.65rem); + font-weight: 600; + line-height: 1.2; +} + +.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; +} + +.dash-alert-banner { + display: none; + align-items: center; + 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)); + font-size: 0.85rem; + animation: dash-alert-glow 2.5s ease-in-out infinite; +} + +.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; +} + +.dash-section { + border-radius: 14px; + border: 1px solid var(--dash-border); + background: var(--dash-panel); + backdrop-filter: blur(10px); + overflow: hidden; +} + +.dash-section-head { + display: flex; + 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; + font-size: 0.82rem; + letter-spacing: 0.1em; + color: var(--dash-cyan); +} + +.dash-section-body { + padding: 0; +} + +.dash-ac-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; + padding: 14px; +} + +.dash-ac-card { + position: relative; + padding: 14px 16px; + border-radius: 10px; + border: 1px solid rgba(136, 146, 176, 0.2); + background: rgba(8, 12, 24, 0.65); + transition: border-color 0.2s, box-shadow 0.2s; +} + +.dash-ac-card.is-alert { + border-color: rgba(255, 92, 122, 0.65); + 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); + } +} + +.dash-ac-card.is-unmon { + opacity: 0.55; +} + +.dash-ac-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.dash-ac-name { + font-weight: 600; + font-size: 0.92rem; + color: #e8eeff; +} + +.dash-ac-badge { + font-size: 0.65rem; + font-weight: 700; + padding: 3px 8px; + border-radius: 4px; + letter-spacing: 0.06em; + white-space: nowrap; +} + +.dash-ac-badge.alert { + color: #fff; + background: linear-gradient(135deg, #ff5c7a, #c45bff); +} + +.dash-ac-badge.ok { + color: var(--dash-cyan); + border: 1px solid var(--dash-border); +} + +.dash-ac-metrics { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px 12px; + font-family: JetBrains Mono, monospace; + font-size: 0.76rem; +} + +.dash-ac-metric span { + display: block; + color: #8892b0; + font-size: 0.65rem; + margin-bottom: 2px; +} + +.dash-ac-metric strong.pos { + color: var(--dash-ok); +} + +.dash-ac-metric strong.neg { + color: var(--dash-warn); +} + +.dash-loss-bar { + margin-top: 10px; + height: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; +} + +.dash-loss-bar i { + display: block; + height: 100%; + border-radius: 2px; + background: linear-gradient(90deg, var(--dash-warn), var(--dash-mag)); + transition: width 0.6s ease; +} + +.dash-ac-remark { + margin-top: 10px; + font-size: 0.7rem; + color: #8892b0; + line-height: 1.4; + word-break: break-word; +} + +.dash-table-wrap { + overflow: auto; + max-height: min(52vh, 480px); +} + +.dash-table { + width: 100%; + border-collapse: collapse; + font-family: JetBrains Mono, monospace; + font-size: 0.74rem; +} + +.dash-table th { + position: sticky; + top: 0; + 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); +} + +.dash-table td { + padding: 9px 12px; + border-bottom: 1px solid rgba(42, 52, 72, 0.5); + color: #c5cde0; +} + +.dash-table tr:hover td { + background: rgba(62, 231, 255, 0.04); +} + +.dash-table tr.is-alert-row td { + background: rgba(255, 92, 122, 0.08); +} + +.dash-table .pos { + color: var(--dash-ok); +} + +.dash-table .neg { + color: var(--dash-warn); +} + +.dash-empty { + padding: 32px; + text-align: center; + color: var(--muted); + font-size: 0.85rem; +} + +.dash-status { + font-family: JetBrains Mono, monospace; + font-size: 0.75rem; + color: var(--muted); +} + +.dash-status.err { + color: var(--dash-warn); +} + +@media (max-width: 720px) { + .dash-ac-grid { + grid-template-columns: 1fr; + } + .dash-head-meta { + text-align: left; + width: 100%; + } +} diff --git a/manual_trading_hub/static/dashboard.js b/manual_trading_hub/static/dashboard.js new file mode 100644 index 0000000..613aa13 --- /dev/null +++ b/manual_trading_hub/static/dashboard.js @@ -0,0 +1,242 @@ +/** + * 中控数据看板:总览 / 分户 / 平仓明细,60s 自动刷新。 + */ +(function () { + const page = document.getElementById("page-dashboard"); + if (!page) return; + + const POLL_MS = 60 * 1000; + let timer = null; + let inited = false; + let loading = false; + + const elStatus = document.getElementById("dash-status"); + const elBanner = document.getElementById("dash-alert-banner"); + const elBannerText = document.getElementById("dash-alert-banner-text"); + const elKpi = document.getElementById("dash-kpi-row"); + const elAccounts = document.getElementById("dash-accounts"); + const elTrades = document.getElementById("dash-trades-body"); + const elUpdated = document.getElementById("dash-updated-at"); + const elDay = document.getElementById("dash-trading-day"); + const btnRefresh = document.getElementById("dash-btn-refresh"); + + function fmt(n, d) { + if (n == null || n === "" || !Number.isFinite(Number(n))) return "—"; + return Number(n).toFixed(d == null ? 2 : d); + } + + function pnlClass(v) { + const n = Number(v); + if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return ""; + return n > 0 ? "pos" : "neg"; + } + + function pnlSigned(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 esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function setStatus(msg, isErr) { + if (!elStatus) return; + elStatus.textContent = msg || ""; + elStatus.className = "dash-status" + (isErr ? " err" : ""); + } + + function renderKpi(totals) { + if (!elKpi || !totals) return; + const closed = Number(totals.total_pnl_u); + const floating = Number(totals.float_pnl_u); + const funding = totals.total_funding_usdt; + const trading = totals.total_trading_usdt; + elKpi.innerHTML = [ + kpiCard("交易日", esc(totals.trading_day || "—"), ""), + kpiCard("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)), + kpiCard( + "平仓笔数", + `${totals.closed_count || 0}`, + "", + `胜 ${totals.win_count || 0} / 负 ${totals.loss_count || 0}` + ), + kpiCard("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)), + kpiCard( + "资金合计", + funding != null && trading != null + ? `${fmt(Number(funding) + Number(trading), 2)}U` + : "—", + "", + `资金 ${fmt(funding, 2)} + 交易 ${fmt(trading, 2)}` + ), + kpiCard("实盘持仓", `${totals.open_position_count || 0} 仓`, ""), + ].join(""); + } + + function kpiCard(label, value, valCls, sub) { + return `
+
${esc(label)}
+
${value}
+ ${sub ? `
${esc(sub)}
` : ""} +
`; + } + + function renderAccounts(accounts, threshold) { + if (!elAccounts) return; + const rows = Array.isArray(accounts) ? accounts : []; + if (!rows.length) { + elAccounts.innerHTML = '
暂无账户数据
'; + return; + } + elAccounts.innerHTML = rows + .map((ac) => { + const alert = !!ac.loss_alert; + const unmon = !ac.monitored; + const pnl = Number(ac.pnl_u); + const floatPnl = Number(ac.float_pnl_u); + const lossPct = Number(ac.daily_loss_pct); + const barW = + alert && Number.isFinite(lossPct) + ? Math.min(100, (lossPct / Math.max(threshold, 1)) * 100) + : 0; + const badge = alert + ? `单日亏损 ≥${threshold}%` + : `${esc(ac.status || "—")}`; + const lossBar = + alert && barW > 0 + ? `
` + : ""; + return `
+
+
${esc(ac.name || "—")}
+ ${badge} +
+
+
资金账户${fmt(ac.funding_usdt, 2)}U
+
交易账户${fmt(ac.trading_usdt, 2)}U
+
资金合计${fmt(ac.capital_total_usdt, 2)}U
+
今日盈亏${pnlSigned(pnl, 2)}
+
平仓笔数${Number(ac.closed_count) || 0}
+
浮盈亏${pnlSigned(floatPnl, 2)}
+
+ ${lossBar} +
${esc(ac.remark || "—")}
+
`; + }) + .join(""); + } + + function renderTrades(trades, accounts) { + if (!elTrades) return; + const rows = Array.isArray(trades) ? trades : []; + if (!rows.length) { + elTrades.innerHTML = '
今日暂无平仓
'; + return; + } + const alertNames = new Set( + (accounts || []).filter((a) => a.loss_alert).map((a) => String(a.name || "")) + ); + const body = rows + .map((t) => { + const pnl = Number(t.pnl_amount); + const rowAlert = alertNames.has(String(t.account_name || "")); + return ` + ${esc(t.trading_day || "—")} + ${esc(t.account_name || "—")} + ${esc(t.symbol || "—")} + ${esc(t.direction || "—")} + ${esc(t.result || "—")} + ${pnlSigned(pnl, 2)} + ${esc(t.closed_at || "—")} + `; + }) + .join(""); + elTrades.innerHTML = `
+ + + + ${body} +
交易日账户合约方向结果盈亏时间
`; + } + + function renderPayload(data) { + const totals = data.totals || {}; + const threshold = Number(data.loss_alert_pct_threshold) || 5; + const alertCount = Number(data.loss_alert_count) || 0; + if (elDay) elDay.textContent = totals.trading_day || data.trading_day || "—"; + if (elUpdated) elUpdated.textContent = data.updated_at || "—"; + renderKpi(totals); + renderAccounts(data.accounts, threshold); + renderTrades(data.closed_trades, data.accounts); + if (elBanner && elBannerText) { + if (alertCount > 0) { + const names = (data.accounts || []) + .filter((a) => a.loss_alert) + .map((a) => a.name) + .join("、"); + elBanner.classList.add("is-on"); + elBannerText.textContent = `${alertCount} 户单日平仓亏损超过资金合计 ${threshold}%:${names}`; + } else { + elBanner.classList.remove("is-on"); + elBannerText.textContent = ""; + } + } + } + + async function fetchDashboard() { + if (loading) return; + loading = true; + setStatus("同步中…"); + try { + const r = await fetch("/api/dashboard/daily", { credentials: "same-origin" }); + if (r.status === 401) { + location.href = "/login?next=" + encodeURIComponent(location.pathname); + return; + } + const data = await r.json(); + if (!data.ok) throw new Error(data.detail || data.msg || "加载失败"); + renderPayload(data); + setStatus(`每 ${(data.poll_interval_sec || 60)}s 自动刷新`); + } catch (e) { + setStatus(String(e.message || e), true); + } finally { + loading = false; + } + } + + function startPoll() { + stopPoll(); + void fetchDashboard(); + timer = setInterval(fetchDashboard, POLL_MS); + } + + function stopPoll() { + if (timer) { + clearInterval(timer); + timer = null; + } + } + + if (btnRefresh) { + btnRefresh.addEventListener("click", () => void fetchDashboard()); + } + + window.hubDashboardPage = { + init() { + if (!inited) inited = true; + startPoll(); + }, + destroy() { + stopPoll(); + setStatus(""); + }, + }; +})(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 665f838..a739a32 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -16,6 +16,7 @@ + @@ -48,6 +49,7 @@ 监控区 行情区 币种档案 + 数据看板 AI 教练 系统设置 @@ -312,6 +314,39 @@ + +