/** * 中控数据看板:后端 SSE 推送版本号,前端拉快照刷新(无轮询闪烁)。 */ (function () { const page = document.getElementById("page-dashboard"); if (!page) return; let dashEventSource = null; let dashReconnectTimer = null; let localDashVersion = 0; let inited = false; let loading = false; const elStatus = document.getElementById("dash-status"); const elBanner = document.getElementById("dash-alert-banner"); const elBannerText = document.getElementById("dash-alert-banner-text"); const elKpi = document.getElementById("dash-kpi-row"); const elAccounts = document.getElementById("dash-accounts"); const elTrades = document.getElementById("dash-trades-body"); const elUpdated = document.getElementById("dash-updated-at"); const elDay = document.getElementById("dash-trading-day"); const btnRefresh = document.getElementById("dash-btn-refresh"); function fmt(n, d) { if (n == null || n === "" || !Number.isFinite(Number(n))) return "—"; return Number(n).toFixed(d == null ? 2 : d); } function pnlClass(v) { const n = Number(v); if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return ""; return n > 0 ? "pos" : "neg"; } function pnlSigned(v, digits) { const n = Number(v); if (!Number.isFinite(n)) return "—"; const abs = fmt(Math.abs(n), digits); if (Math.abs(n) < 1e-9) return `${abs}U`; return `${n > 0 ? "+" : "-"}${abs}U`; } function esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function setStatus(msg, isErr) { if (!elStatus) return; elStatus.textContent = msg || ""; elStatus.className = "dash-status" + (isErr ? " err" : ""); } function renderKpi(totals) { if (!elKpi || !totals) return; const closed = Number(totals.total_pnl_u); const floating = Number(totals.float_pnl_u); const funding = totals.total_funding_usdt; const trading = totals.total_trading_usdt; elKpi.innerHTML = [ kpiCard("交易日", esc(totals.trading_day || "—"), ""), kpiCard("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed)), kpiCard( "平仓笔数", `${totals.closed_count || 0}`, "", `胜 ${totals.win_count || 0} / 负 ${totals.loss_count || 0}` ), kpiCard("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)), kpiCard( "资金合计", funding != null && trading != null ? `${fmt(Number(funding) + Number(trading), 2)}U` : "—", "", `资金 ${fmt(funding, 2)} + 交易 ${fmt(trading, 2)}` ), kpiCard("实盘持仓", `${totals.open_position_count || 0} 仓`, ""), ].join(""); } function kpiCard(label, value, valCls, sub) { return `
${esc(label)}
${value}
${sub ? `
${esc(sub)}
` : ""}
`; } function renderMonitorCountChips(counts) { const mc = counts || {}; const chips = []; const keys = Number(mc.keys) || 0; const orders = Number(mc.orders) || 0; const trends = Number(mc.trends) || 0; const rolls = Number(mc.rolls) || 0; if (keys > 0) chips.push(`关键位 ${keys}`); if (orders > 0) { chips.push(`下单监控 ${orders}`); } if (trends > 0) chips.push(`趋势回调 ${trends}`); if (rolls > 0) chips.push(`顺势加仓 ${rolls}`); return chips; } function renderAccountDetail(ac) { const counts = (ac && ac.monitor_counts) || {}; const positions = Array.isArray(ac && ac.position_lines) ? ac.position_lines : []; const issues = Array.isArray(ac && ac.issues) ? ac.issues : []; const exId = ac && ac.id != null ? String(ac.id) : ""; const chips = renderMonitorCountChips(counts); const expandBtn = exId ? `` : ""; const monitorRow = chips.length || expandBtn ? `
${chips.join("")}${expandBtn}
` : ""; let posHtml = ""; if (positions.length) { posHtml = positions .map((ln) => { const text = esc((ln && ln.text) || ""); if (ln.pnl != null && Number.isFinite(Number(ln.pnl))) { const pnl = Number(ln.pnl); return ( `
` + `${text} 浮${pnlSigned(pnl, 2)}` + `
` ); } return `
${text}
`; }) .join(""); } else if (!chips.length && !issues.length) { posHtml = `
无持仓
`; } const issueHtml = issues .map((text) => `
${esc(text)}
`) .join(""); return `
${monitorRow}
${posHtml}
${issueHtml}
`; } function bindDashboardExpand() { if (!elAccounts) return; elAccounts.querySelectorAll(".dash-ac-expand-btn").forEach((btn) => { btn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); const id = btn.getAttribute("data-dash-ex-id"); if (id && window.hubOpenMonitorExpand) window.hubOpenMonitorExpand(id); }); }); } function renderAccounts(accounts, threshold) { if (!elAccounts) return; const rows = Array.isArray(accounts) ? accounts : []; if (!rows.length) { elAccounts.innerHTML = '
暂无账户数据
'; return; } elAccounts.innerHTML = rows .map((ac) => { const alert = !!ac.loss_alert; const unmon = !ac.monitored; const pnl = Number(ac.pnl_u); const floatPnl = Number(ac.float_pnl_u); const lossPct = Number(ac.daily_loss_pct); const barW = alert && Number.isFinite(lossPct) ? Math.min(100, (lossPct / Math.max(threshold, 1)) * 100) : 0; const badge = alert ? `单日亏损 ≥${threshold}%` : `${esc(ac.status || "—")}`; const lossBar = alert && barW > 0 ? `
` : ""; return `
${esc(ac.name || "—")}
${badge}
资金账户${fmt(ac.funding_usdt, 2)}U
交易账户${fmt(ac.trading_usdt, 2)}U
资金合计${fmt(ac.capital_total_usdt, 2)}U
今日盈亏${pnlSigned(pnl, 2)}
平仓笔数${Number(ac.closed_count) || 0}
浮盈亏${pnlSigned(floatPnl, 2)}
${lossBar} ${renderAccountDetail(ac)}
`; }) .join(""); bindDashboardExpand(); } function renderTrades(trades, accounts) { if (!elTrades) return; const rows = Array.isArray(trades) ? trades : []; if (!rows.length) { elTrades.innerHTML = '
今日暂无平仓
'; return; } const alertNames = new Set( (accounts || []).filter((a) => a.loss_alert).map((a) => String(a.name || "")) ); const body = rows .map((t) => { const pnl = Number(t.pnl_amount); const rowAlert = alertNames.has(String(t.account_name || "")); return ` ${esc(t.trading_day || "—")} ${esc(t.account_name || "—")} ${esc(t.symbol || "—")} ${esc(t.direction || "—")} ${esc(t.result || "—")} ${pnlSigned(pnl, 2)} ${esc(t.closed_at || "—")} `; }) .join(""); elTrades.innerHTML = `
${body}
交易日账户合约方向结果盈亏时间
`; } function renderPayload(data) { const totals = data.totals || {}; const threshold = Number(data.loss_alert_pct_threshold) || 5; const alertCount = Number(data.loss_alert_count) || 0; if (elDay) elDay.textContent = totals.trading_day || data.trading_day || "—"; if (elUpdated) elUpdated.textContent = data.updated_at || "—"; renderKpi(totals); renderAccounts(data.accounts, threshold); renderTrades(data.closed_trades, data.accounts); if (elBanner && elBannerText) { if (alertCount > 0) { const names = (data.accounts || []) .filter((a) => a.loss_alert) .map((a) => a.name) .join("、"); elBanner.classList.add("is-on"); elBannerText.textContent = `${alertCount} 户单日平仓亏损超过资金合计 ${threshold}%:${names}`; } else { elBanner.classList.remove("is-on"); elBannerText.textContent = ""; } } } async function fetchDashboardSnapshot(opts) { const options = opts || {}; if (loading && !options.force) return; loading = true; if (!options.silent) 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 || data.error || "加载失败"); const ver = Number(data.dashboard_version) || 0; if (ver) localDashVersion = ver; renderPayload(data); const sec = Number(data.poll_interval_sec) || 60; setStatus(options.silent ? `SSE 已连接 · 后台每 ${sec}s 聚合` : `已更新 · 后台每 ${sec}s 聚合`); } catch (e) { setStatus(String(e.message || e), true); } finally { loading = false; } } function closeDashboardStream() { if (dashEventSource) { dashEventSource.close(); dashEventSource = null; } if (dashReconnectTimer) { clearTimeout(dashReconnectTimer); dashReconnectTimer = null; } } function connectDashboardStream() { closeDashboardStream(); dashEventSource = new EventSource("/api/dashboard/stream"); dashEventSource.addEventListener("dashboard", (ev) => { try { const st = JSON.parse(ev.data || "{}"); const ver = Number(st.dashboard_version) || 0; if (ver && ver !== localDashVersion) { void fetchDashboardSnapshot({ silent: true }); } else if (st.aggregating) { setStatus("后台聚合中…"); } } catch (_) { /* ignore */ } }); dashEventSource.onerror = () => { closeDashboardStream(); setStatus("SSE 断开,8s 后重连…", true); dashReconnectTimer = setTimeout(() => { if (inited) { connectDashboardStream(); void fetchDashboardSnapshot({ silent: true }); } }, 8000); }; } async function requestDashboardRefresh() { try { await fetch("/api/dashboard/refresh", { method: "POST", credentials: "same-origin" }); } catch (_) { /* ignore */ } } function startLive() { void fetchDashboardSnapshot(); connectDashboardStream(); } function stopLive() { closeDashboardStream(); setStatus(""); } if (btnRefresh) { btnRefresh.addEventListener("click", () => { void requestDashboardRefresh(); void fetchDashboardSnapshot({ force: true }); }); } window.hubDashboardPage = { init() { inited = true; startLive(); }, destroy() { inited = false; stopLive(); }, }; })();