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("/monitor")
@app.get("/market") @app.get("/market")
@app.get("/archive") @app.get("/archive")
@app.get("/dashboard")
@app.get("/funds") @app.get("/funds")
@app.get("/ai") @app.get("/ai")
@app.get("/settings") @app.get("/settings")
@@ -661,10 +662,24 @@ def _all_exchanges_for_ai() -> list:
from hub_ai.routes import create_hub_ai_router 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.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") @app.get("/trade")
def trade_removed_redirect(): def trade_removed_redirect():
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -2107,7 +2122,7 @@ def api_ping():
"service": "manual-trading-hub", "service": "manual-trading-hub",
"build": HUB_BUILD, "build": HUB_BUILD,
"trade_ui": False, "trade_ui": False,
"features": ["monitor", "settings", "auth", "board_sse", "archive", "funds"], "features": ["monitor", "settings", "auth", "board_sse", "archive", "dashboard", "funds"],
"board_poll_interval_sec": HUB_BOARD_POLL_INTERVAL, "board_poll_interval_sec": HUB_BOARD_POLL_INTERVAL,
"board_version": board_store.version, "board_version": board_store.version,
"board_aggregating": board_store.aggregating, "board_aggregating": board_store.aggregating,
+79 -20
View File
@@ -13,15 +13,27 @@ from hub_ai.config import (
CHAT_MAX_OUTPUT_TOKENS, CHAT_MAX_OUTPUT_TOKENS,
CHAT_SUMMARY_EXCERPT_MAX_CHARS, CHAT_SUMMARY_EXCERPT_MAX_CHARS,
CHAT_TEMPERATURE, 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.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 ( from hub_ai.store import (
CHAT_BOT_GENERAL,
CHAT_BOT_TRADING,
append_chat_message, append_chat_message,
create_new_session, create_new_session,
delete_chat_session,
ensure_active_session, ensure_active_session,
get_active_session, get_active_session,
list_chat_sessions,
load_chat_store, load_chat_store,
set_active_session,
summary_excerpt_for_chat, summary_excerpt_for_chat,
) )
@@ -58,16 +70,48 @@ def _history_lines(
def get_chat_state() -> dict[str, Any]: def get_chat_state() -> dict[str, Any]:
store = load_chat_store() store = load_chat_store()
session = get_active_session() session = get_active_session()
if session:
session.setdefault("bot_mode", CHAT_BOT_TRADING)
return { return {
"active_session_id": store.get("active_session_id"), "active_session_id": store.get("active_session_id"),
"session": session, "session": session,
"sessions": list_chat_sessions(),
"model": model_label(), "model": model_label(),
} }
def start_new_chat(*, trading_day: str) -> dict: def start_new_chat(*, trading_day: str, bot_mode: str = CHAT_BOT_TRADING) -> dict:
session = create_new_session(trading_day=trading_day) session = create_new_session(trading_day=trading_day, bot_mode=bot_mode)
return {"ok": True, "session": session, "model": model_label()} 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( def send_chat_message(
@@ -90,8 +134,9 @@ def send_chat_message(
if not user_visible and parsed.get("attachment_note"): if not user_visible and parsed.get("attachment_note"):
user_visible = f"(上传了 {parsed['attachment_note']}" user_visible = f"(上传了 {parsed['attachment_note']}"
ctx = build_daily_context(exchanges, trading_day=trading_day) day = (trading_day or "").strip()[:10] or current_trading_day(
day = ctx["trading_day"] reset_hour=trading_day_reset_hour()
)
session = ensure_active_session(trading_day=day) session = ensure_active_session(trading_day=day)
sid = session["id"] sid = session["id"]
history = _history_lines( history = _history_lines(
@@ -106,22 +151,35 @@ def send_chat_message(
attachments=parsed.get("attachment_meta") or [], attachments=parsed.get("attachment_meta") or [],
) )
brief_ctx = format_chat_context_for_chat(ctx, max_chars=CHAT_CONTEXT_MAX_CHARS) bot_mode = (session.get("bot_mode") or CHAT_BOT_TRADING).strip().lower()
excerpt = summary_excerpt_for_chat(day, max_chars=CHAT_SUMMARY_EXCERPT_MAX_CHARS) if bot_mode == CHAT_BOT_GENERAL:
user_prompt = build_general_chat_user_prompt(
user_prompt = build_chat_user_prompt( history_lines=history,
context_text=brief_ctx, user_message=text or user_visible,
trading_day=day, attachment_note=str(parsed.get("attachment_note") or ""),
summary_excerpt=excerpt, )
history_lines=history, if parsed.get("text_append"):
user_message=text or user_visible, user_prompt += "\n\n【附件正文】\n" + parsed["text_append"]
attachment_note=str(parsed.get("attachment_note") or ""), system_prompt = CHAT_GENERAL_SYSTEM
) else:
if parsed.get("text_append"): ctx = build_daily_context(exchanges, trading_day=day)
user_prompt += "\n\n【附件正文】\n" + parsed["text_append"] 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( reply = generate_text(
system=CHAT_SYSTEM, system=system_prompt,
user=user_prompt, user=user_prompt,
temperature=CHAT_TEMPERATURE, temperature=CHAT_TEMPERATURE,
images_b64=parsed.get("images_b64") or None, images_b64=parsed.get("images_b64") or None,
@@ -136,6 +194,7 @@ def send_chat_message(
"ok": True, "ok": True,
"trading_day": day, "trading_day": day,
"session": session, "session": session,
"sessions": list_chat_sessions(),
"reply": reply, "reply": reply,
"model": model_label(), "model": model_label(),
"attachment_warnings": parsed.get("errors") or [], "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() """.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( def build_chat_user_prompt(
*, *,
context_text: str, context_text: str,
+27 -2
View File
@@ -6,7 +6,13 @@ from typing import Callable
from fastapi import APIRouter, File, Form, HTTPException, UploadFile from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field 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.client import model_label
from hub_ai.config import trading_day_reset_hour from hub_ai.config import trading_day_reset_hour
from hub_ai.context import build_daily_context from hub_ai.context import build_daily_context
@@ -27,6 +33,11 @@ class SummaryGenerateBody(BaseModel):
class ChatNewBody(BaseModel): class ChatNewBody(BaseModel):
trading_day: str = "" 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: 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") @router.post("/chat/new")
def api_ai_chat_new(body: ChatNewBody = ChatNewBody()): def api_ai_chat_new(body: ChatNewBody = ChatNewBody()):
day = _day(body.trading_day) 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") @router.post("/chat/send")
async def api_ai_chat_send( async def api_ai_chat_send(
+82 -1
View File
@@ -140,12 +140,28 @@ def get_active_session() -> Optional[dict]:
return None 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() store = load_chat_store()
session = { session = {
"id": uuid.uuid4().hex, "id": uuid.uuid4().hex,
"trading_day": trading_day, "trading_day": trading_day,
"title": title, "title": title,
"bot_mode": _normalize_bot_mode(bot_mode),
"created_at": _now_str(), "created_at": _now_str(),
"updated_at": _now_str(), "updated_at": _now_str(),
"messages": [], "messages": [],
@@ -193,6 +209,71 @@ def append_chat_message(
return target 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: def summary_excerpt_for_chat(trading_day: str, max_chars: int = 600) -> str:
latest = get_latest_summary(trading_day) latest = get_latest_summary(trading_day)
if not latest: 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="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; display: none !important;
} }
@@ -3953,6 +3954,35 @@ body.hub-page-ai #page-ai {
min-width: 0; 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 { body.hub-page-ai .ai-panel {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@@ -4291,6 +4321,186 @@ body.hub-page-ai #page-ai {
color: var(--muted); color: var(--muted);
margin: 0; 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 { .ai-chat-messages {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+193 -11
View File
@@ -644,6 +644,7 @@
const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
if (p.includes("settings")) return "settings"; if (p.includes("settings")) return "settings";
if (p.includes("archive")) return "archive"; if (p.includes("archive")) return "archive";
if (p.includes("dashboard")) return "dashboard";
if (p.includes("funds")) return "funds"; if (p.includes("funds")) return "funds";
if (p.includes("market")) return "market"; if (p.includes("market")) return "market";
if (p.includes("/ai")) return "ai"; if (p.includes("/ai")) return "ai";
@@ -653,6 +654,7 @@
function pageElementId(page) { function pageElementId(page) {
if (page === "settings") return "page-settings"; if (page === "settings") return "page-settings";
if (page === "archive") return "page-archive"; if (page === "archive") return "page-archive";
if (page === "dashboard") return "page-dashboard";
if (page === "funds") return "page-funds"; if (page === "funds") return "page-funds";
if (page === "market") return "page-market"; if (page === "market") return "page-market";
if (page === "ai") return "page-ai"; 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-ai", page === "ai");
document.body.classList.toggle("hub-page-funds", page === "funds"); document.body.classList.toggle("hub-page-funds", page === "funds");
document.body.classList.toggle("hub-page-dashboard", page === "dashboard");
syncHubAiMobileViewport(); syncHubAiMobileViewport();
if (page === "monitor") startMonitorPoll(); if (page === "monitor") startMonitorPoll();
else stopMonitorPoll(); 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 === "settings") loadSettingsUI();
if (page === "ai") loadAiPage(); if (page === "ai") loadAiPage();
if (page === "archive" && window.hubArchivePage) { if (page === "archive" && window.hubArchivePage) {
@@ -1010,6 +1018,10 @@
btn.setAttribute("aria-selected", on ? "true" : "false"); btn.setAttribute("aria-selected", on ? "true" : "false");
}); });
if (mobile && active === "chat") scrollAiChatToEnd(); if (mobile && active === "chat") scrollAiChatToEnd();
if (mobile && active === "history") {
const hist = document.getElementById("ai-chat-history-list");
if (hist) hist.scrollTop = 0;
}
} }
function initAiMobileTabs() { function initAiMobileTabs() {
@@ -3133,6 +3145,8 @@
let aiSummaryLoading = false; let aiSummaryLoading = false;
let aiChatLoading = false; let aiChatLoading = false;
let aiChatSessionCache = null; let aiChatSessionCache = null;
let aiChatSessionsCache = [];
let aiSelectedBotMode = "trading";
function aiPnlClass(v) { function aiPnlClass(v) {
const n = Number(v); const n = Number(v);
@@ -3324,9 +3338,64 @@
requestAnimationFrame(() => requestAnimationFrame(run)); 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 isUser = role === "user";
const label = isUser ? "主人" : "AI教练"; const label = isUser ? "主人" : botMode === "general" ? "助手" : "交易教练";
const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach"; const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach";
const bubbleCls = isUser ? "ai-bubble-user" : "ai-bubble-assistant"; const bubbleCls = isUser ? "ai-bubble-user" : "ai-bubble-assistant";
const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking"); const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking");
@@ -3342,11 +3411,16 @@
.map((a) => `<span class="ai-attach-chip">${esc(a.name || "附件")}</span>`) .map((a) => `<span class="ai-attach-chip">${esc(a.name || "附件")}</span>`)
.join("")}</div>` .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 ( return (
`<div class="ai-msg-row ${rowCls}">` + `<div class="ai-msg-row ${rowCls}">` +
`<span class="ai-msg-role">${label}</span>` + `<span class="ai-msg-role">${label}</span>` +
`${attHtml}` + `${attHtml}` +
`<div class="ai-bubble ${bubbleCls}${mdCls}${isError ? " ai-bubble-error" : ""}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` + `<div class="ai-bubble ${bubbleCls}${mdCls}${isError ? " ai-bubble-error" : ""}${extraClass ? " " + extraClass : ""}">${bubbleInner}</div>` +
`${copyHtml}` +
`</div>` `</div>`
); );
} }
@@ -3357,23 +3431,30 @@
const title = document.getElementById("ai-chat-title"); const title = document.getElementById("ai-chat-title");
if (!box) return; if (!box) return;
const msgs = (session && session.messages) || []; const msgs = (session && session.messages) || [];
const botMode = (session && session.bot_mode) || aiSelectedBotMode || "trading";
if (title) { if (title) {
title.textContent = session && session.title ? `聊天 · ${session.title}` : "聊天"; const modeLabel = botMode === "general" ? "普通聊天" : "交易教练";
title.textContent =
session && session.title ? `${modeLabel} · ${session.title}` : modeLabel;
} }
const showPlaceholder = const showPlaceholder =
!msgs.length && !options.pendingUser && !options.thinking; !msgs.length && !options.pendingUser && !options.thinking;
if (showPlaceholder) { if (showPlaceholder) {
box.innerHTML = const hint =
'<p class="ai-placeholder">主人发消息会立刻出现在右侧;AI教练 会先显示「正在思考…」再回复。可点「附件」上传图片或文档。</p>'; botMode === "general"
? "普通聊天不注入交易快照;发消息后可点气泡下方「复制」。"
: "交易教练会结合四户监控数据陪聊;发消息后可点气泡下方「复制」。可点「附件」上传图片或文档。";
box.innerHTML = `<p class="ai-placeholder">${hint}</p>`;
return; return;
} }
let html = msgs let html = msgs
.map((m) => .map((m, idx) =>
renderAiChatRow( renderAiChatRow(
m.role === "user" ? "user" : "assistant", m.role === "user" ? "user" : "assistant",
m.content || "", m.content || "",
null, null,
m.attachments m.attachments,
{ botMode, msgIdx: idx }
) )
) )
.join(""); .join("");
@@ -3414,7 +3495,54 @@
const r = await apiFetch("/api/ai/chat/session"); const r = await apiFetch("/api/ai/chat/session");
const j = await r.json(); const j = await r.json();
aiChatSessionCache = j.session || null; aiChatSessionCache = j.session || null;
aiChatSessionsCache = j.sessions || [];
renderAiChatMessages(aiChatSessionCache); 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() { async function loadAiPage() {
@@ -3460,17 +3588,22 @@
} }
} }
async function newAiChat() { async function newAiChat(botMode) {
const mode = botMode === "general" ? "general" : "trading";
try { try {
const r = await apiFetch("/api/ai/chat/new", { const r = await apiFetch("/api/ai/chat/new", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({}), body: JSON.stringify({ bot_mode: mode }),
}); });
const j = await r.json(); const j = await r.json();
aiChatSessionCache = j.session || null; aiChatSessionCache = j.session || null;
aiChatSessionsCache = j.sessions || [];
renderAiChatMessages(aiChatSessionCache); renderAiChatMessages(aiChatSessionCache);
showToast("已开始新对话"); renderAiChatHistory(aiChatSessionsCache);
updateAiBotTabs(mode);
applyAiMobileTab("chat");
showToast(mode === "general" ? "已开始普通聊天" : "已开始交易教练对话");
} catch (e) { } catch (e) {
showToast(String(e), true); showToast(String(e), true);
} }
@@ -3501,7 +3634,9 @@
const j = await r.json(); const j = await r.json();
if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); if (!r.ok) throw new Error(j.detail || j.msg || "发送失败");
aiChatSessionCache = j.session || null; aiChatSessionCache = j.session || null;
aiChatSessionsCache = j.sessions || aiChatSessionsCache;
renderAiChatMessages(aiChatSessionCache); renderAiChatMessages(aiChatSessionCache);
renderAiChatHistory(aiChatSessionsCache);
if (fileInput) fileInput.value = ""; if (fileInput) fileInput.value = "";
if (fileLabel) fileLabel.textContent = ""; if (fileLabel) fileLabel.textContent = "";
if (j.attachment_warnings && j.attachment_warnings.length) { if (j.attachment_warnings && j.attachment_warnings.length) {
@@ -3531,10 +3666,57 @@
const aiSummaryBtn = document.getElementById("btn-ai-summary"); const aiSummaryBtn = document.getElementById("btn-ai-summary");
if (aiSummaryBtn) aiSummaryBtn.onclick = () => generateAiSummary(); if (aiSummaryBtn) aiSummaryBtn.onclick = () => generateAiSummary();
const aiChatNewBtn = document.getElementById("btn-ai-chat-new"); const aiChatNewBtn = document.getElementById("btn-ai-chat-new");
if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(); if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat(aiSelectedBotMode);
const aiChatForm = document.getElementById("ai-chat-form"); const aiChatForm = document.getElementById("ai-chat-form");
if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat); 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(); initTpslModal();
initInstanceFrame(); initInstanceFrame();
initFullscreen(); 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'" /> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript> <noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260609-hub-funds-fold" /> <link rel="stylesheet" href="/assets/app.css?v=20260609-hub-funds-fold" />
<link rel="stylesheet" href="/assets/dashboard.css?v=20260611-hub-dashboard" />
</head> </head>
<body> <body>
<div class="app-bg" aria-hidden="true"></div> <div class="app-bg" aria-hidden="true"></div>
@@ -48,6 +49,7 @@
<a href="/monitor" id="nav-monitor">监控区</a> <a href="/monitor" id="nav-monitor">监控区</a>
<a href="/market" id="nav-market">行情区</a> <a href="/market" id="nav-market">行情区</a>
<a href="/archive" id="nav-archive">币种档案</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="/ai" id="nav-ai">AI 教练</a>
<a href="/settings" id="nav-settings">系统设置</a> <a href="/settings" id="nav-settings">系统设置</a>
</nav> </nav>
@@ -312,6 +314,39 @@
</div> </div>
</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 id="instance-frame-shell" class="instance-frame-shell hidden" aria-hidden="true">
<div class="instance-frame-toolbar"> <div class="instance-frame-toolbar">
<button type="button" id="instance-frame-back" class="ghost">← 返回监控</button> <button type="button" id="instance-frame-back" class="ghost">← 返回监控</button>
@@ -408,10 +443,11 @@
<div id="page-ai" class="page hidden"> <div id="page-ai" class="page hidden">
<div class="page-head"> <div class="page-head">
<h1><span class="head-tag">AI</span> 教练</h1> <h1><span class="head-tag">AI</span> 教练</h1>
<p class="page-desc">四户今日总结 · 口语化陪聊(单会话,点「新开对话」清空上下文)</p> <p class="page-desc">四户今日总结 · 交易教练 / 普通聊天 · 右侧可回看历史会话</p>
</div> </div>
<div class="ai-mobile-tabs" role="tablist" aria-label="AI 教练视图"> <div class="ai-mobile-tabs" role="tablist" aria-label="AI 教练视图">
<button type="button" class="ai-mobile-tab is-active" data-ai-tab="chat" role="tab" aria-selected="true">聊天</button> <button type="button" class="ai-mobile-tab is-active" data-ai-tab="chat" role="tab" aria-selected="true">聊天</button>
<button type="button" class="ai-mobile-tab" data-ai-tab="history" role="tab" aria-selected="false">历史</button>
<button type="button" class="ai-mobile-tab" data-ai-tab="summary" role="tab" aria-selected="false">今日总结</button> <button type="button" class="ai-mobile-tab" data-ai-tab="summary" role="tab" aria-selected="false">今日总结</button>
</div> </div>
<div class="ai-layout" data-ai-mobile-tab="chat"> <div class="ai-layout" data-ai-mobile-tab="chat">
@@ -428,26 +464,40 @@
</div> </div>
</section> </section>
<section class="ai-panel ai-chat-panel" data-ai-panel="chat"> <section class="ai-panel ai-chat-panel" data-ai-panel="chat">
<div class="ai-panel-head"> <div class="ai-bot-bar" role="tablist" aria-label="聊天机器人">
<h2 id="ai-chat-title">聊天</h2> <button type="button" class="ai-bot-tab is-active" data-bot="trading" role="tab" aria-selected="true">交易教练</button>
<div class="ai-panel-actions"> <button type="button" class="ai-bot-tab" data-bot="general" role="tab" aria-selected="false">普通聊天</button>
<button type="button" id="btn-ai-chat-new" class="primary">新开对话</button>
</div>
</div> </div>
<div id="ai-chat-messages" class="ai-panel-scroll ai-chat-messages" aria-live="polite"></div> <div class="ai-chat-split">
<form id="ai-chat-form" class="ai-chat-form"> <div class="ai-chat-main">
<div class="ai-chat-compose"> <div class="ai-panel-head">
<textarea id="ai-chat-input" rows="2" placeholder="聊聊行情、心态、纪律、执行…" autocomplete="off"></textarea> <h2 id="ai-chat-title">聊天</h2>
<div class="ai-chat-compose-actions"> <div class="ai-panel-actions">
<label class="ai-chat-upload-btn" title="上传图片或 txt/md/json 文档"> <button type="button" id="btn-ai-chat-new" class="primary">新开对话</button>
<input type="file" id="ai-chat-files" accept="image/*,.txt,.md,.markdown,.json" multiple hidden /> </div>
附件
</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>
<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> </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> </section>
</div> </div>
</div> </div>
@@ -510,7 +560,8 @@
<script src="/assets/chart.js?v=20260609-market-day-split"></script> <script src="/assets/chart.js?v=20260609-market-day-split"></script>
<script src="/assets/archive.js?v=20260608-hub-archive-history"></script> <script src="/assets/archive.js?v=20260608-hub-archive-history"></script>
<script src="/assets/funds.js?v=20260609-hub-funds-fold"></script> <script src="/assets/funds.js?v=20260609-hub-funds-fold"></script>
<script src="/assets/dashboard.js?v=20260611-hub-dashboard"></script>
<script src="/assets/ai_review_render.js?v=2"></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> </body>
</html> </html>