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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-11 10:42:33 +08:00
parent a45a3b18e2
commit 582ada7e60
11 changed files with 1479 additions and 55 deletions
+16 -1
View File
@@ -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,
+79 -20
View File
@@ -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 [],
+27
View File
@@ -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,
+27 -2
View File
@@ -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(
+82 -1
View File
@@ -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:
+98
View File
@@ -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())
+211 -1
View File
@@ -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;
+193 -11
View File
@@ -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 = '<p class="ai-placeholder">暂无历史,发送消息后会出现在这里。</p>';
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 (
`<div class="ai-chat-history-item${active}" role="listitem" data-session-id="${sid}">` +
`<div class="ai-chat-history-item-main">` +
`<span class="ai-chat-history-item-title">${title}</span>` +
`<span class="ai-chat-history-item-preview">${preview}</span>` +
`<span class="ai-chat-history-item-meta">` +
`<span>${time}</span>` +
`<span class="ai-chat-history-badge${badgeCls}">${badge}</span>` +
`<span>${Number(s.message_count) || 0} 条</span>` +
`</span>` +
`</div>` +
`<button type="button" class="ai-chat-history-del" title="删除" data-delete-session="${sid}" aria-label="删除">×</button>` +
`</div>`
);
})
.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) => `<span class="ai-attach-chip">${esc(a.name || "附件")}</span>`)
.join("")}</div>`
: "";
const canCopy = !isThinking && String(content || "").trim();
const copyHtml = canCopy
? `<div class="ai-msg-actions"><button type="button" class="ai-msg-copy-btn" data-msg-idx="${opts.msgIdx != null ? opts.msgIdx : ""}">复制</button></div>`
: "";
return (
`<div class="ai-msg-row ${rowCls}">` +
`<span class="ai-msg-role">${label}</span>` +
`${attHtml}` +
`<div class="ai-bubble ${bubbleCls}${mdCls}${isError ? " ai-bubble-error" : ""}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` +
`${copyHtml}` +
`</div>`
);
}
@@ -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 =
'<p class="ai-placeholder">主人发消息会立刻出现在右侧;AI教练 会先显示「正在思考…」再回复。可点「附件」上传图片或文档。</p>';
const hint =
botMode === "general"
? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。"
: "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可点「附件」上传图片或文档。";
box.innerHTML = `<p class="ai-placeholder">${hint}</p>`;
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();
+434
View File
@@ -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%;
}
}
+242
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 `<div class="dash-kpi">
<div class="dash-kpi-label">${esc(label)}</div>
<div class="dash-kpi-value ${valCls || ""}">${value}</div>
${sub ? `<div class="dash-kpi-sub">${esc(sub)}</div>` : ""}
</div>`;
}
function renderAccounts(accounts, threshold) {
if (!elAccounts) return;
const rows = Array.isArray(accounts) ? accounts : [];
if (!rows.length) {
elAccounts.innerHTML = '<div class="dash-empty">暂无账户数据</div>';
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
? `<span class="dash-ac-badge alert">单日亏损 ≥${threshold}%</span>`
: `<span class="dash-ac-badge ok">${esc(ac.status || "—")}</span>`;
const lossBar =
alert && barW > 0
? `<div class="dash-loss-bar" title="占资金合计 ${fmt(lossPct, 2)}%"><i style="width:${barW}%"></i></div>`
: "";
return `<article class="dash-ac-card${alert ? " is-alert" : ""}${unmon ? " is-unmon" : ""}">
<div class="dash-ac-top">
<div class="dash-ac-name">${esc(ac.name || "—")}</div>
${badge}
</div>
<div class="dash-ac-metrics">
<div class="dash-ac-metric"><span>资金账户</span><strong>${fmt(ac.funding_usdt, 2)}U</strong></div>
<div class="dash-ac-metric"><span>交易账户</span><strong>${fmt(ac.trading_usdt, 2)}U</strong></div>
<div class="dash-ac-metric"><span>资金合计</span><strong>${fmt(ac.capital_total_usdt, 2)}U</strong></div>
<div class="dash-ac-metric"><span>今日盈亏</span><strong class="${pnlClass(pnl)}">${pnlSigned(pnl, 2)}</strong></div>
<div class="dash-ac-metric"><span>平仓笔数</span><strong>${Number(ac.closed_count) || 0}</strong></div>
<div class="dash-ac-metric"><span>浮盈亏</span><strong class="${pnlClass(floatPnl)}">${pnlSigned(floatPnl, 2)}</strong></div>
</div>
${lossBar}
<div class="dash-ac-remark">${esc(ac.remark || "—")}</div>
</article>`;
})
.join("");
}
function renderTrades(trades, accounts) {
if (!elTrades) return;
const rows = Array.isArray(trades) ? trades : [];
if (!rows.length) {
elTrades.innerHTML = '<div class="dash-empty">今日暂无平仓</div>';
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 `<tr class="${rowAlert ? "is-alert-row" : ""}">
<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 class="${pnlClass(pnl)}">${pnlSigned(pnl, 2)}</td>
<td>${esc(t.closed_at || "—")}</td>
</tr>`;
})
.join("");
elTrades.innerHTML = `<div class="dash-table-wrap"><table class="dash-table">
<thead><tr>
<th>交易日</th><th></th><th></th><th></th><th></th><th></th><th></th>
</tr></thead>
<tbody>${body}</tbody>
</table></div>`;
}
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("");
},
};
})();
+70 -19
View File
@@ -16,6 +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'" />
<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/dashboard.css?v=20260611-hub-dashboard" />
</head>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -48,6 +49,7 @@
<a href="/monitor" id="nav-monitor">监控区</a>
<a href="/market" id="nav-market">行情区</a>
<a href="/archive" id="nav-archive">币种档案</a>
<a href="/dashboard" id="nav-dashboard">数据看板</a>
<a href="/ai" id="nav-ai">AI 教练</a>
<a href="/settings" id="nav-settings">系统设置</a>
</nav>
@@ -312,6 +314,39 @@
</div>
</div>
<div id="page-dashboard" class="page hidden">
<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-head">
<div>
<h1><span class="dash-head-tag">DASH</span>数据看板</h1>
<p class="page-desc">四户当日总览 · 分户明细 · 平仓流水 · 每 60 秒刷新</p>
</div>
<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 id="dash-updated-at"></span></div>
<button type="button" id="dash-btn-refresh" class="ghost" style="margin-top:8px">立即刷新</button>
</div>
</div>
<div id="dash-alert-banner" class="dash-alert-banner" role="alert">
<strong>⚠ 风险预警</strong>
<span id="dash-alert-banner-text"></span>
</div>
<div id="dash-kpi-row" class="dash-kpi-row"></div>
<section class="dash-section">
<div class="dash-section-head">分户明细 · ACCOUNTS</div>
<div class="dash-section-body"><div id="dash-accounts" class="dash-ac-grid"></div></div>
</section>
<section class="dash-section">
<div class="dash-section-head">平仓明细 · CLOSED TRADES</div>
<div class="dash-section-body" id="dash-trades-body"></div>
</section>
<p id="dash-status" class="dash-status"></p>
</div>
</div>
<div id="instance-frame-shell" class="instance-frame-shell hidden" aria-hidden="true">
<div class="instance-frame-toolbar">
<button type="button" id="instance-frame-back" class="ghost">← 返回监控</button>
@@ -408,10 +443,11 @@
<div id="page-ai" class="page hidden">
<div class="page-head">
<h1><span class="head-tag">AI</span> 教练</h1>
<p class="page-desc">四户今日总结 · 口语化陪聊(单会话,点「新开对话」清空上下文)</p>
<p class="page-desc">四户今日总结 · 交易教练 / 普通聊天 · 右侧可回看历史会话</p>
</div>
<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" 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 class="ai-layout" data-ai-mobile-tab="chat">
@@ -428,26 +464,40 @@
</div>
</section>
<section class="ai-panel ai-chat-panel" data-ai-panel="chat">
<div class="ai-panel-head">
<h2 id="ai-chat-title">聊天</h2>
<div class="ai-panel-actions">
<button type="button" id="btn-ai-chat-new" class="primary">新开对话</button>
</div>
<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" data-bot="general" role="tab" aria-selected="false">普通聊天</button>
</div>
<div id="ai-chat-messages" class="ai-panel-scroll ai-chat-messages" aria-live="polite"></div>
<form id="ai-chat-form" class="ai-chat-form">
<div class="ai-chat-compose">
<textarea id="ai-chat-input" rows="2" placeholder="聊聊行情、心态、纪律、执行…" autocomplete="off"></textarea>
<div class="ai-chat-compose-actions">
<label class="ai-chat-upload-btn" title="上传图片或 txt/md/json 文档">
<input type="file" id="ai-chat-files" accept="image/*,.txt,.md,.markdown,.json" multiple hidden />
附件
</label>
<span id="ai-chat-files-label" class="ai-chat-files-label"></span>
<button type="submit" id="btn-ai-chat-send" class="primary">发送</button>
<div class="ai-chat-split">
<div class="ai-chat-main">
<div class="ai-panel-head">
<h2 id="ai-chat-title">聊天</h2>
<div class="ai-panel-actions">
<button type="button" id="btn-ai-chat-new" class="primary">新开对话</button>
</div>
</div>
<div id="ai-chat-messages" class="ai-panel-scroll ai-chat-messages" aria-live="polite"></div>
<form id="ai-chat-form" class="ai-chat-form">
<div class="ai-chat-compose">
<textarea id="ai-chat-input" rows="2" placeholder="聊聊行情、心态、纪律、执行…" autocomplete="off"></textarea>
<div class="ai-chat-compose-actions">
<label class="ai-chat-upload-btn" title="上传图片或 txt/md/json 文档">
<input type="file" id="ai-chat-files" accept="image/*,.txt,.md,.markdown,.json" multiple hidden />
附件
</label>
<span id="ai-chat-files-label" class="ai-chat-files-label"></span>
<button type="submit" id="btn-ai-chat-send" class="primary">发送</button>
</div>
</div>
</form>
</div>
</form>
<aside class="ai-chat-history-panel" aria-label="聊天历史">
<div class="ai-chat-history-head">
<h3>聊天历史</h3>
</div>
<div id="ai-chat-history-list" class="ai-panel-scroll ai-chat-history-list" role="list"></div>
</aside>
</div>
</section>
</div>
</div>
@@ -510,7 +560,8 @@
<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/funds.js?v=20260609-hub-funds-fold"></script>
<script src="/assets/dashboard.js?v=20260611-hub-dashboard"></script>
<script src="/assets/ai_review_render.js?v=2"></script>
<script src="/assets/app.js?v=20260609-hub-funds-ai"></script>
<script src="/assets/app.js?v=20260611-hub-dashboard"></script>
</body>
</html>