/**
* 中控数据看板:后端 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 (
``
);
}
return ``;
})
.join("");
} else if (!chips.length && !issues.length) {
posHtml = ``;
}
const issueHtml = issues
.map((text) => ``)
.join("");
return ``;
}
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 = ``;
}
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();
},
};
})();