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:
@@ -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,
|
||||||
|
|||||||
@@ -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 [],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* 中控数据看板:总览 / 分户 / 平仓明细,60s 自动刷新。
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
const page = document.getElementById("page-dashboard");
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
const POLL_MS = 60 * 1000;
|
||||||
|
let timer = null;
|
||||||
|
let inited = false;
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
const elStatus = document.getElementById("dash-status");
|
||||||
|
const elBanner = document.getElementById("dash-alert-banner");
|
||||||
|
const elBannerText = document.getElementById("dash-alert-banner-text");
|
||||||
|
const elKpi = document.getElementById("dash-kpi-row");
|
||||||
|
const elAccounts = document.getElementById("dash-accounts");
|
||||||
|
const elTrades = document.getElementById("dash-trades-body");
|
||||||
|
const elUpdated = document.getElementById("dash-updated-at");
|
||||||
|
const elDay = document.getElementById("dash-trading-day");
|
||||||
|
const btnRefresh = document.getElementById("dash-btn-refresh");
|
||||||
|
|
||||||
|
function fmt(n, d) {
|
||||||
|
if (n == null || n === "" || !Number.isFinite(Number(n))) return "—";
|
||||||
|
return Number(n).toFixed(d == null ? 2 : d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pnlClass(v) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "";
|
||||||
|
return n > 0 ? "pos" : "neg";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pnlSigned(v, digits) {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return "—";
|
||||||
|
const abs = fmt(Math.abs(n), digits);
|
||||||
|
if (Math.abs(n) < 1e-9) return `${abs}U`;
|
||||||
|
return `${n > 0 ? "+" : "-"}${abs}U`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg, isErr) {
|
||||||
|
if (!elStatus) return;
|
||||||
|
elStatus.textContent = msg || "";
|
||||||
|
elStatus.className = "dash-status" + (isErr ? " err" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKpi(totals) {
|
||||||
|
if (!elKpi || !totals) return;
|
||||||
|
const closed = Number(totals.total_pnl_u);
|
||||||
|
const floating = Number(totals.float_pnl_u);
|
||||||
|
const funding = totals.total_funding_usdt;
|
||||||
|
const trading = totals.total_trading_usdt;
|
||||||
|
elKpi.innerHTML = [
|
||||||
|
kpiCard("交易日", esc(totals.trading_day || "—"), ""),
|
||||||
|
kpiCard("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)),
|
||||||
|
kpiCard(
|
||||||
|
"平仓笔数",
|
||||||
|
`${totals.closed_count || 0}`,
|
||||||
|
"",
|
||||||
|
`胜 ${totals.win_count || 0} / 负 ${totals.loss_count || 0}`
|
||||||
|
),
|
||||||
|
kpiCard("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)),
|
||||||
|
kpiCard(
|
||||||
|
"资金合计",
|
||||||
|
funding != null && trading != null
|
||||||
|
? `${fmt(Number(funding) + Number(trading), 2)}U`
|
||||||
|
: "—",
|
||||||
|
"",
|
||||||
|
`资金 ${fmt(funding, 2)} + 交易 ${fmt(trading, 2)}`
|
||||||
|
),
|
||||||
|
kpiCard("实盘持仓", `${totals.open_position_count || 0} 仓`, ""),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function kpiCard(label, value, valCls, sub) {
|
||||||
|
return `<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("");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user