feat(hub): mobile AI tabs and dashboard position lines

Mobile AI coach uses four top tabs (trading, general, history, new) with single-panel view and wider desktop history. Dashboard account cards show key levels and positions one per line with colored float PnL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-11 11:16:28 +08:00
parent c59a17f9ac
commit 1042fdeef3
7 changed files with 185 additions and 39 deletions
+37
View File
@@ -652,6 +652,43 @@ def format_account_remark(ac: dict) -> str:
return "".join(parts) return "".join(parts)
def format_dashboard_account_lines(ac: dict) -> list[dict[str, Any]]:
"""数据看板分户卡片:监控与持仓逐行展示(含浮盈亏数值供前端着色)。"""
lines: list[dict[str, Any]] = []
mon = ac.get("monitor_lines") or {}
for row in (mon.get("keys") or [])[:3]:
if row:
lines.append({"kind": "monitor", "text": str(row)})
for row in (mon.get("orders") or [])[:3]:
if row:
lines.append({"kind": "monitor", "text": str(row)})
for row in (mon.get("trends") or [])[:2]:
if row:
lines.append({"kind": "monitor", "text": str(row)})
for row in (mon.get("rolls") or [])[:2]:
if row:
lines.append({"kind": "monitor", "text": str(row)})
for p in _filter_open_positions(ac.get("positions") or []):
sym = p.get("symbol") or "?"
side = p.get("side") or "?"
upnl = _position_float_pnl(p)
lines.append(
{
"kind": "position",
"text": f"{sym} {side}",
"pnl": round(upnl, 4),
}
)
if not lines:
issues = ac.get("issues") or []
if issues:
for iss in issues[:3]:
lines.append({"kind": "issue", "text": str(iss)})
else:
lines.append({"kind": "empty", "text": ""})
return lines
def collect_closed_trades_snapshot( def collect_closed_trades_snapshot(
accounts: list[dict], accounts: list[dict],
*, *,
+2
View File
@@ -8,6 +8,7 @@ from hub_ai.context import (
build_daily_context, build_daily_context,
collect_closed_trades_snapshot, collect_closed_trades_snapshot,
format_account_remark, format_account_remark,
format_dashboard_account_lines,
) )
from hub_ai.config import trading_day_reset_hour from hub_ai.config import trading_day_reset_hour
from hub_trades_lib import current_trading_day from hub_trades_lib import current_trading_day
@@ -63,6 +64,7 @@ def _enrich_account_row(ac: dict) -> dict:
"float_pnl_u": ac.get("float_pnl_u"), "float_pnl_u": ac.get("float_pnl_u"),
"open_position_count": ac.get("open_position_count"), "open_position_count": ac.get("open_position_count"),
"remark": format_account_remark(ac), "remark": format_account_remark(ac),
"remark_lines": format_dashboard_account_lines(ac),
"issues": ac.get("issues") or [], "issues": ac.get("issues") or [],
"daily_loss_pct": loss_pct, "daily_loss_pct": loss_pct,
"loss_alert": loss_alert, "loss_alert": loss_alert,
+30 -21
View File
@@ -3914,25 +3914,32 @@ body.hub-page-ai #page-ai {
} }
body.hub-page-ai .ai-mobile-tabs { body.hub-page-ai .ai-mobile-tabs {
display: flex; display: grid;
gap: 8px; grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 8px; gap: 6px;
margin-bottom: 6px;
flex-shrink: 0; flex-shrink: 0;
width: 100%; width: 100%;
} }
body.hub-page-ai .ai-mobile-tab { body.hub-page-ai .ai-mobile-tab {
flex: 1; min-height: 38px;
min-height: 40px; padding: 6px 4px;
padding: 8px 12px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
background: var(--inset-surface); background: var(--inset-surface);
color: var(--muted); color: var(--muted);
font-family: var(--font); font-family: var(--font);
font-size: 0.82rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
line-height: 1.2;
text-align: center;
}
body.hub-page-ai .ai-mobile-tab-action {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border-soft));
} }
body.hub-page-ai .ai-mobile-tab.is-active { body.hub-page-ai .ai-mobile-tab.is-active {
@@ -3942,7 +3949,7 @@ body.hub-page-ai #page-ai {
box-shadow: none; box-shadow: none;
} }
body.hub-page-ai #page-ai .page-desc { body.hub-page-ai #page-ai .page-head {
display: none; display: none;
} }
@@ -3957,7 +3964,8 @@ body.hub-page-ai #page-ai {
overflow: hidden; overflow: hidden;
} }
body.hub-page-ai .ai-layout[data-ai-mobile-tab="chat"] .ai-chat-panel, body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-panel,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-panel,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-panel { body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-panel {
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 auto;
@@ -3967,10 +3975,19 @@ body.hub-page-ai #page-ai {
min-width: 0; min-width: 0;
} }
body.hub-page-ai .ai-layout[data-ai-mobile-tab="chat"] .ai-chat-history-panel { body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-history-panel,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-history-panel {
display: none !important; display: none !important;
} }
body.hub-page-ai .ai-layout[data-ai-mobile-tab="trading"] .ai-chat-main,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="general"] .ai-chat-main {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
}
body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-main, body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-main,
body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-topbar { body.hub-page-ai .ai-layout[data-ai-mobile-tab="history"] .ai-chat-topbar {
display: none !important; display: none !important;
@@ -4003,7 +4020,7 @@ body.hub-page-ai #page-ai {
} }
body.hub-page-ai .ai-chat-topbar { body.hub-page-ai .ai-chat-topbar {
gap: 6px; display: none;
} }
body.hub-page-ai .ai-bot-tab { body.hub-page-ai .ai-bot-tab {
@@ -4039,15 +4056,7 @@ body.hub-page-ai #page-ai {
} }
body.hub-page-ai .ai-chat-session-head { body.hub-page-ai .ai-chat-session-head {
margin: 0; display: none;
padding: 0 2px 4px;
}
body.hub-page-ai .ai-chat-session-head h2 {
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.02em;
} }
body.hub-page-ai .ai-chat-messages { body.hub-page-ai .ai-chat-messages {
@@ -4470,7 +4479,7 @@ body.hub-page-ai #page-ai {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(200px, 260px); grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
gap: 0; gap: 0;
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-soft); border: 1px solid var(--border-soft);
+48 -12
View File
@@ -1038,22 +1038,43 @@
} }
const AI_MOBILE_TAB_KEY = "hub_ai_mobile_tab"; const AI_MOBILE_TAB_KEY = "hub_ai_mobile_tab";
const AI_MOBILE_CHAT_TABS = new Set(["trading", "general"]);
function normalizeAiMobileTab(tab) {
const raw = (tab || "").trim().toLowerCase();
if (raw === "chat") return "trading";
if (AI_MOBILE_CHAT_TABS.has(raw) || raw === "history") return raw;
return "trading";
}
function applyAiMobileTab(tab) { function applyAiMobileTab(tab) {
const layout = document.querySelector(".ai-layout"); const layout = document.querySelector(".ai-layout");
const tabs = document.querySelectorAll(".ai-mobile-tab"); const tabs = document.querySelectorAll(".ai-mobile-tab");
if (!layout) return; if (!layout) return;
const mobile = isMobileLayout(); const mobile = isMobileLayout();
const active = mobile ? tab || localStorage.getItem(AI_MOBILE_TAB_KEY) || "chat" : "both"; if (!mobile) {
if (mobile) layout.dataset.aiMobileTab = active; delete layout.dataset.aiMobileTab;
else delete layout.dataset.aiMobileTab; tabs.forEach((btn) => {
btn.classList.remove("is-active");
btn.setAttribute("aria-selected", "false");
});
return;
}
const active = normalizeAiMobileTab(
tab || localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"
);
layout.dataset.aiMobileTab = active;
tabs.forEach((btn) => { tabs.forEach((btn) => {
const on = mobile && btn.dataset.aiTab === active; const t = btn.dataset.aiTab || "";
const on = t === active;
btn.classList.toggle("is-active", on); btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false"); btn.setAttribute("aria-selected", on ? "true" : "false");
}); });
if (mobile && active === "chat") scrollAiChatToEnd(); if (AI_MOBILE_CHAT_TABS.has(active)) {
if (mobile && active === "history") { updateAiBotTabs(active);
scrollAiChatToEnd();
}
if (active === "history") {
const hist = document.getElementById("ai-chat-history-list"); const hist = document.getElementById("ai-chat-history-list");
if (hist) hist.scrollTop = 0; if (hist) hist.scrollTop = 0;
} }
@@ -1064,10 +1085,16 @@
if (!tabs.length) return; if (!tabs.length) return;
tabs.forEach((btn) => { tabs.forEach((btn) => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const tab = btn.dataset.aiTab || "chat"; const tab = btn.dataset.aiTab || "trading";
if (tab === "new") {
const prev = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading");
const botMode = prev === "general" ? "general" : "trading";
void newAiChat(botMode);
return;
}
localStorage.setItem(AI_MOBILE_TAB_KEY, tab); localStorage.setItem(AI_MOBILE_TAB_KEY, tab);
applyAiMobileTab(tab); applyAiMobileTab(tab);
if (tab === "chat") { if (AI_MOBILE_CHAT_TABS.has(tab)) {
const input = document.getElementById("ai-chat-input"); const input = document.getElementById("ai-chat-input");
if (input && isMobileLayout()) input.focus(); if (input && isMobileLayout()) input.focus();
} }
@@ -3388,8 +3415,13 @@
aiChatSessionsCache = j.sessions || []; aiChatSessionsCache = j.sessions || [];
renderAiChatMessages(aiChatSessionCache); renderAiChatMessages(aiChatSessionCache);
renderAiChatHistory(aiChatSessionsCache); renderAiChatHistory(aiChatSessionsCache);
updateAiBotTabs((aiChatSessionCache && aiChatSessionCache.bot_mode) || "trading"); const mode =
applyAiMobileTab("chat"); (aiChatSessionCache && aiChatSessionCache.bot_mode) === "general" ? "general" : "trading";
updateAiBotTabs(mode);
if (isMobileLayout()) {
localStorage.setItem(AI_MOBILE_TAB_KEY, mode);
applyAiMobileTab(mode);
}
scrollAiChatToEnd(); scrollAiChatToEnd();
} catch (e) { } catch (e) {
showToast(String(e), true); showToast(String(e), true);
@@ -3421,7 +3453,8 @@
async function loadAiPage() { async function loadAiPage() {
applyAiMobileTab(); applyAiMobileTab();
await loadAiChatSession(); await loadAiChatSession();
if (isMobileLayout() && (localStorage.getItem(AI_MOBILE_TAB_KEY) || "chat") === "chat") { const mobTab = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading");
if (isMobileLayout() && AI_MOBILE_CHAT_TABS.has(mobTab)) {
const input = document.getElementById("ai-chat-input"); const input = document.getElementById("ai-chat-input");
if (input && !aiChatLoading) { if (input && !aiChatLoading) {
setTimeout(() => input.focus(), 80); setTimeout(() => input.focus(), 80);
@@ -3443,7 +3476,10 @@
renderAiChatMessages(aiChatSessionCache); renderAiChatMessages(aiChatSessionCache);
renderAiChatHistory(aiChatSessionsCache); renderAiChatHistory(aiChatSessionsCache);
updateAiBotTabs(mode); updateAiBotTabs(mode);
applyAiMobileTab("chat"); if (isMobileLayout()) {
localStorage.setItem(AI_MOBILE_TAB_KEY, mode);
applyAiMobileTab(mode);
}
showToast(mode === "general" ? "已开始普通聊天" : "已开始交易教练对话"); showToast(mode === "general" ? "已开始普通聊天" : "已开始交易教练对话");
} catch (e) { } catch (e) {
showToast(String(e), true); showToast(String(e), true);
+31
View File
@@ -317,6 +317,37 @@ body.hub-page-dashboard .page#page-dashboard {
color: var(--dash-muted); color: var(--dash-muted);
line-height: 1.4; line-height: 1.4;
word-break: break-word; word-break: break-word;
display: flex;
flex-direction: column;
gap: 3px;
}
.dash-ac-remark-line {
margin: 0;
padding: 3px 0;
border-top: 1px solid color-mix(in srgb, var(--dash-card-border) 65%, transparent);
}
.dash-ac-remark-line:first-child {
border-top: none;
padding-top: 0;
}
.dash-ac-remark-mon {
color: var(--dash-muted);
}
.dash-ac-remark-pos {
color: var(--dash-text);
}
.dash-ac-remark-pos .pos,
.dash-ac-remark-pos .neg {
font-weight: 600;
}
.dash-ac-remark-issue {
color: var(--dash-warn);
} }
.dash-table-wrap { .dash-table-wrap {
+30 -1
View File
@@ -90,6 +90,35 @@
</div>`; </div>`;
} }
function renderRemarkLines(ac) {
const lines = Array.isArray(ac && ac.remark_lines) ? ac.remark_lines : [];
if (!lines.length) {
const fallback = esc((ac && ac.remark) || "—");
return `<div class="dash-ac-remark"><div class="dash-ac-remark-line">${fallback}</div></div>`;
}
return `<div class="dash-ac-remark">${lines
.map((ln) => {
const kind = ln && ln.kind;
const text = esc((ln && ln.text) || "");
if (kind === "position" && ln.pnl != null && Number.isFinite(Number(ln.pnl))) {
const pnl = Number(ln.pnl);
return (
`<div class="dash-ac-remark-line dash-ac-remark-pos">` +
`${text} 浮<span class="${pnlClass(pnl)}">${pnlSigned(pnl, 2)}</span>` +
`</div>`
);
}
const cls =
kind === "monitor"
? "dash-ac-remark-line dash-ac-remark-mon"
: kind === "issue"
? "dash-ac-remark-line dash-ac-remark-issue"
: "dash-ac-remark-line";
return `<div class="${cls}">${text}</div>`;
})
.join("")}</div>`;
}
function renderAccounts(accounts, threshold) { function renderAccounts(accounts, threshold) {
if (!elAccounts) return; if (!elAccounts) return;
const rows = Array.isArray(accounts) ? accounts : []; const rows = Array.isArray(accounts) ? accounts : [];
@@ -129,7 +158,7 @@
<div class="dash-ac-metric"><span>浮盈亏</span><strong class="${pnlClass(floatPnl)}">${pnlSigned(floatPnl, 2)}</strong></div> <div class="dash-ac-metric"><span>浮盈亏</span><strong class="${pnlClass(floatPnl)}">${pnlSigned(floatPnl, 2)}</strong></div>
</div> </div>
${lossBar} ${lossBar}
<div class="dash-ac-remark">${esc(ac.remark || "—")}</div> ${renderRemarkLines(ac)}
</article>`; </article>`;
}) })
.join(""); .join("");
+7 -5
View File
@@ -15,8 +15,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<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=20260611-hub-ai-mobile" /> <link rel="stylesheet" href="/assets/app.css?v=20260611-hub-ai-tabs" />
<link rel="stylesheet" href="/assets/dashboard.css?v=20260611-hub-dash-sse" /> <link rel="stylesheet" href="/assets/dashboard.css?v=20260611-dash-remark-lines" />
</head> </head>
<body> <body>
<div class="app-bg" aria-hidden="true"></div> <div class="app-bg" aria-hidden="true"></div>
@@ -444,8 +444,10 @@
<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="trading" role="tab" aria-selected="true">交易教练</button>
<button type="button" class="ai-mobile-tab" data-ai-tab="general" role="tab" aria-selected="false">普通聊天</button>
<button type="button" class="ai-mobile-tab" data-ai-tab="history" role="tab" aria-selected="false">历史</button> <button type="button" class="ai-mobile-tab" data-ai-tab="history" role="tab" aria-selected="false">历史</button>
<button type="button" class="ai-mobile-tab ai-mobile-tab-action" data-ai-tab="new" role="tab" aria-selected="false" title="新开对话">新开</button>
</div> </div>
<div class="ai-layout" data-ai-mobile-tab="chat"> <div class="ai-layout" data-ai-mobile-tab="chat">
<section class="ai-panel ai-chat-panel" data-ai-panel="chat"> <section class="ai-panel ai-chat-panel" data-ai-panel="chat">
@@ -553,8 +555,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-dash-sse"></script> <script src="/assets/dashboard.js?v=20260611-dash-remark-lines"></script>
<script src="/assets/ai_review_render.js?v=2"></script> <script src="/assets/ai_review_render.js?v=2"></script>
<script src="/assets/app.js?v=20260611-hub-ai-mobile"></script> <script src="/assets/app.js?v=20260611-hub-ai-tabs"></script>
</body> </body>
</html> </html>