f5c553844f
Co-authored-by: Cursor <cursoragent@cursor.com>
617 lines
22 KiB
JavaScript
617 lines
22 KiB
JavaScript
/**
|
|
* 中控数据看板:后端 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 optPremiumCcyOf(p) {
|
|
const ccy = String((p && p.premium_ccy) || "").trim().toUpperCase();
|
|
if (ccy) return ccy;
|
|
const mode = String((p && p.margin_mode) || "").toLowerCase();
|
|
const inst = String((p && p.inst_id) || "");
|
|
if (mode === "coin" || (inst.indexOf("-USD-") >= 0 && inst.indexOf("_UM") < 0)) {
|
|
return (inst.split("-")[0] || "ETH").toUpperCase() || "ETH";
|
|
}
|
|
return "USDC";
|
|
}
|
|
|
|
function pnlSignedOpt(v, ccy, spotPx) {
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n)) return "—";
|
|
const unit = String(ccy || "USDC").toUpperCase();
|
|
if (unit === "ETH" || unit === "BTC") {
|
|
const abs = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0";
|
|
const sign = n > 0 ? "+" : n < 0 ? "-" : "";
|
|
const coinTxt = `${sign}${abs} ${unit}`;
|
|
const px = Number(spotPx);
|
|
if (!Number.isFinite(px) || !(px > 0)) return coinTxt;
|
|
const u = n * px;
|
|
const uAbs = Math.abs(u).toFixed(2);
|
|
const uSign = u < 0 ? "-" : u > 0 ? "+" : "";
|
|
return `${coinTxt} / ${uSign}${uAbs}U`;
|
|
}
|
|
return pnlSigned(n, 2);
|
|
}
|
|
|
|
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 showAccountPnlPref() {
|
|
if (typeof window.hubShowAccountPnlPref === "function") {
|
|
return !!window.hubShowAccountPnlPref();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function renderKpi(totals) {
|
|
if (!elKpi || !totals) return;
|
|
const showPnl = showAccountPnlPref();
|
|
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;
|
|
const funds =
|
|
funding != null && trading != null ? Number(funding) + Number(trading) : NaN;
|
|
const totalPos = Number(totals.open_position_count) || 0;
|
|
const optPos = Number(totals.options_open_position_count) || 0;
|
|
const perpPos =
|
|
totals.perpetual_open_position_count != null
|
|
? Number(totals.perpetual_open_position_count) || 0
|
|
: Math.max(0, totalPos - optPos);
|
|
const items = [kpiItem("交易日", esc(totals.trading_day || "—"))];
|
|
if (showPnl) {
|
|
items.push(kpiItem("资金合计", Number.isFinite(funds) ? `${fmt(funds, 2)}U` : "—"));
|
|
}
|
|
items.push(
|
|
kpiItem("总持仓数量", `${totalPos}`),
|
|
kpiItem("期权持仓", `${optPos}`),
|
|
kpiItem("永续持仓", `${perpPos}`),
|
|
kpiItem("平仓数量", `${totals.closed_count || 0}`),
|
|
kpiItem("平仓盈亏", pnlSigned(closed, 2), pnlClass(closed))
|
|
);
|
|
if (showPnl) {
|
|
items.push(kpiItem("浮盈亏", pnlSigned(floating, 2), pnlClass(floating)));
|
|
}
|
|
elKpi.innerHTML = `<div class="dash-kpi-summary">${items.join("")}</div>`;
|
|
}
|
|
|
|
function kpiItem(label, value, valCls) {
|
|
return `<div class="dash-kpi-item">
|
|
<div class="dash-kpi-label">${esc(label)}</div>
|
|
<div class="dash-kpi-value ${valCls || ""}">${value}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function dashOptionsExpiryCd(expMs) {
|
|
const ms = expMs != null && expMs !== "" ? String(expMs) : "";
|
|
if (!ms) return "—";
|
|
return `<span class="opt-expiry-cd" data-opt-exp-ms="${esc(ms)}">—</span>`;
|
|
}
|
|
|
|
function shortDashInst(instId) {
|
|
const s = String(instId || "");
|
|
if (s.length <= 18) return s;
|
|
return s.slice(0, 8) + "…" + s.slice(-6);
|
|
}
|
|
|
|
function accountPerpLines(ac) {
|
|
const positions = Array.isArray(ac && ac.position_lines) ? ac.position_lines : [];
|
|
if (ac && ac.options_layout) {
|
|
return positions.filter((ln) => (ln && ln.kind) !== "options");
|
|
}
|
|
return positions;
|
|
}
|
|
|
|
function accountHasOpenPositions(ac) {
|
|
const perp = accountPerpLines(ac);
|
|
const optionsPositions = Array.isArray(ac && ac.options_positions) ? ac.options_positions : [];
|
|
return perp.length > 0 || (ac && ac.options_layout && optionsPositions.length > 0);
|
|
}
|
|
|
|
function exchangeLinkCell(ac) {
|
|
const name = esc((ac && ac.name) || "—");
|
|
const exId = ac && ac.id != null ? String(ac.id) : "";
|
|
if (!exId) return name;
|
|
return (
|
|
`<button type="button" class="dash-ex-link" data-dash-ex-id="${esc(exId)}" ` +
|
|
`title="打开监控区放大查看">${name}</button>`
|
|
);
|
|
}
|
|
|
|
function priceCell(fmtVal, raw) {
|
|
if (fmtVal != null && String(fmtVal).trim() !== "") return esc(String(fmtVal));
|
|
if (raw != null && Number.isFinite(Number(raw))) return esc(fmt(raw, 4).replace(/\.?0+$/, ""));
|
|
return "—";
|
|
}
|
|
|
|
function floatPnlCell(ln) {
|
|
const pnl = ln && ln.pnl != null ? Number(ln.pnl) : NaN;
|
|
if (!Number.isFinite(pnl)) return "—";
|
|
return `<span class="${pnlClass(pnl)}">${fmt(pnl, 2)}</span>`;
|
|
}
|
|
|
|
function slTpCell(ln, kind) {
|
|
if (kind === "tp") {
|
|
const note = String((ln && ln.tp_note) || "").trim();
|
|
if (note) return `<span class="dash-tp-program">${esc(note)}</span>`;
|
|
}
|
|
const raw = kind === "sl" ? ln && ln.stop_loss : ln && ln.take_profit;
|
|
if (raw == null || raw === "") return "—";
|
|
if (Number.isFinite(Number(raw))) {
|
|
return esc(fmt(raw, 4).replace(/\.?0+$/, ""));
|
|
}
|
|
return esc(String(raw));
|
|
}
|
|
|
|
function collectUnifiedPositions(accounts) {
|
|
const perp = [];
|
|
const options = [];
|
|
(Array.isArray(accounts) ? accounts : []).forEach((ac) => {
|
|
if (!accountHasOpenPositions(ac)) return;
|
|
accountPerpLines(ac).forEach((ln) => {
|
|
if (!ln) return;
|
|
perp.push({ ac: ac, ln: ln });
|
|
});
|
|
if (ac && ac.options_layout) {
|
|
(Array.isArray(ac.options_positions) ? ac.options_positions : []).forEach((p) => {
|
|
if (!p) return;
|
|
options.push({ ac: ac, p: p });
|
|
});
|
|
}
|
|
});
|
|
return { perp: perp, options: options };
|
|
}
|
|
|
|
function sourceBadgeClass(source) {
|
|
const s = String(source || "");
|
|
if (s.indexOf("对冲") >= 0) return "is-hedge";
|
|
if (s.indexOf("纯期权") >= 0 || s === "期权") return "is-opt";
|
|
if (s.indexOf("顺势") >= 0) return "is-roll";
|
|
if (s.indexOf("趋势") >= 0) return "is-trend";
|
|
if (s.indexOf("关键位") >= 0) return "is-key";
|
|
if (s.indexOf("下单") >= 0) return "is-order";
|
|
return "is-none";
|
|
}
|
|
|
|
function sourceTypeCell(source) {
|
|
const s = String(source || "—");
|
|
return `<span class="dash-pos-source ${sourceBadgeClass(s)}">${esc(s)}</span>`;
|
|
}
|
|
|
|
function directionCell(side) {
|
|
const s = String(side || "").toLowerCase();
|
|
if (s === "long" || s === "buy") {
|
|
return `<span class="dash-side dash-side-long">做多</span>`;
|
|
}
|
|
if (s === "short" || s === "sell") {
|
|
return `<span class="dash-side dash-side-short">做空</span>`;
|
|
}
|
|
return esc(side || "—");
|
|
}
|
|
|
|
function renderUnifiedPerpTable(rows) {
|
|
if (!rows.length) return "";
|
|
const showPnl = showAccountPnlPref();
|
|
const body = rows
|
|
.map(({ ac, ln }) => {
|
|
const source = String((ln && ln.source) || "—");
|
|
const symbol = esc((ln && (ln.symbol || ln.text)) || "—");
|
|
const contracts =
|
|
ln && ln.contracts != null && ln.contracts !== "" ? esc(String(ln.contracts)) : "—";
|
|
return `<tr>
|
|
<td>${exchangeLinkCell(ac)}</td>
|
|
<td>${sourceTypeCell(source)}</td>
|
|
<td>${symbol}</td>
|
|
<td>${directionCell(ln && ln.side)}</td>
|
|
<td>${priceCell(ln && ln.entry_price_fmt, ln && ln.entry_price)}</td>
|
|
<td>${priceCell(ln && ln.mark_price_fmt, ln && ln.mark_price)}</td>
|
|
<td>${contracts}</td>
|
|
<td>${slTpCell(ln, "sl")}</td>
|
|
<td>${slTpCell(ln, "tp")}</td>
|
|
${showPnl ? `<td>${floatPnlCell(ln)}</td>` : ""}
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
return `<div class="dash-pos-block">
|
|
<div class="dash-ac-section-label">永续持仓</div>
|
|
<div class="dash-table-wrap">
|
|
<table class="dash-table dash-pos-table">
|
|
<thead><tr>
|
|
<th>交易所</th><th>类型</th><th>合约</th><th>方向</th><th>开仓价</th><th>标记价</th><th>张数</th><th>止损</th><th>止盈</th>${
|
|
showPnl ? "<th>浮盈</th>" : ""
|
|
}
|
|
</tr></thead>
|
|
<tbody>${body}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function optionsNetPnl(p) {
|
|
if (!p || typeof p !== "object") return null;
|
|
if (p.net_pnl != null && Number.isFinite(Number(p.net_pnl))) return Number(p.net_pnl);
|
|
const preview = p.close_preview || {};
|
|
if (preview.bid_invalid) {
|
|
const upl = p.upl != null ? Number(p.upl) : NaN;
|
|
return Number.isFinite(upl) ? upl : null;
|
|
}
|
|
if (preview.estimated_pnl != null && Number.isFinite(Number(preview.estimated_pnl))) {
|
|
return Number(preview.estimated_pnl);
|
|
}
|
|
const covered = Number(preview.covered_sheets);
|
|
if (
|
|
preview.total_received != null &&
|
|
Number.isFinite(covered) &&
|
|
covered > 0 &&
|
|
p.premium_paid != null
|
|
) {
|
|
const n = Number(preview.total_received) - Number(p.premium_paid);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
const upl = p.upl != null ? Number(p.upl) : NaN;
|
|
return Number.isFinite(upl) ? upl : null;
|
|
}
|
|
|
|
function optionsHedgePlanId(p) {
|
|
if (!p || typeof p !== "object") return "";
|
|
const hedge = p.hedge_plan_target;
|
|
if (hedge && typeof hedge === "object" && hedge.plan_id != null && hedge.plan_id !== "") {
|
|
return String(hedge.plan_id);
|
|
}
|
|
if (p.source_plan_id != null && p.source_plan_id !== "") return String(p.source_plan_id);
|
|
const target = String(p.target_monitor_text || "");
|
|
const m = target.match(/对冲\s*#\s*(\d+)/);
|
|
return m ? m[1] : "";
|
|
}
|
|
|
|
function optionsGroupKey(ac, p) {
|
|
const ex = ac && ac.id != null ? String(ac.id) : String((ac && ac.name) || "");
|
|
const planId = optionsHedgePlanId(p);
|
|
const source = String((p && (p.source_label || p.source)) || "");
|
|
if (planId && source.indexOf("对冲") >= 0) return "hedge:" + ex + ":" + planId;
|
|
if (planId && /对冲#/.test(String((p && p.target_monitor_text) || ""))) {
|
|
return "hedge:" + ex + ":" + planId;
|
|
}
|
|
return "solo:" + ex + ":" + String((p && p.inst_id) || Math.random());
|
|
}
|
|
|
|
function optionsTypeLabel(p) {
|
|
const source = String((p && (p.source_label || p.source)) || "纯期权").trim() || "纯期权";
|
|
const planId = optionsHedgePlanId(p);
|
|
if (planId && (source.indexOf("对冲") >= 0 || /对冲#/.test(String((p && p.target_monitor_text) || "")))) {
|
|
const base = source.indexOf("对冲") >= 0 ? source.replace(/\s*#\s*\d+\s*$/, "") : "对冲";
|
|
return base + "#" + planId;
|
|
}
|
|
return source;
|
|
}
|
|
|
|
function optionsRoiPct(p) {
|
|
if (!p || typeof p !== "object") return null;
|
|
const preview = p.close_preview || {};
|
|
if (preview.estimated_pnl_ratio_pct != null && Number.isFinite(Number(preview.estimated_pnl_ratio_pct))) {
|
|
return Number(preview.estimated_pnl_ratio_pct);
|
|
}
|
|
const net = optionsNetPnl(p);
|
|
const paid = Number(p.premium_paid);
|
|
if (net != null && Number.isFinite(paid) && paid > 0) {
|
|
return (net / paid) * 100;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function renderOptionsLegRow(ac, p, showPnl) {
|
|
const optType =
|
|
(p.opt_type || "").toUpperCase() === "C"
|
|
? "Call"
|
|
: (p.opt_type || "").toUpperCase() === "P"
|
|
? "Put"
|
|
: p.opt_type || "—";
|
|
const source = optionsTypeLabel(p);
|
|
const target = String(p.target_monitor_text || "—");
|
|
const targetCls = target && target !== "—" ? "dash-target-monitor is-on" : "dash-target-monitor";
|
|
const net = optionsNetPnl(p);
|
|
const roi = optionsRoiPct(p);
|
|
let html = `<tr>
|
|
<td>${exchangeLinkCell(ac)}</td>
|
|
<td>${sourceTypeCell(source)}</td>
|
|
<td title="${esc(p.inst_id || "")}">${esc(shortDashInst(p.inst_id))}</td>
|
|
<td>${esc(optType)}</td>
|
|
<td>${dashOptionsExpiryCd(p.exp_time_ms != null ? p.exp_time_ms : p.exp_time)}</td>
|
|
<td>${p.idx_px != null ? fmt(p.idx_px, 0) : "—"}</td>
|
|
<td><span class="${targetCls}">${esc(target)}</span></td>`;
|
|
if (showPnl) {
|
|
html += `<td class="${pnlClass(net)}">${
|
|
net != null
|
|
? pnlSignedOpt(
|
|
net,
|
|
optPremiumCcyOf(p),
|
|
Number(p.idx_px != null ? p.idx_px : p.idxPx) || null
|
|
)
|
|
: "—"
|
|
}</td>
|
|
<td class="${pnlClass(roi)}">${roi != null ? esc(Number(roi).toFixed(2)) + "%" : "—"}</td>`;
|
|
}
|
|
html += "</tr>";
|
|
return html;
|
|
}
|
|
|
|
function renderUnifiedOptionsTable(rows) {
|
|
if (!rows.length) return "";
|
|
const showPnl = showAccountPnlPref();
|
|
// 同所同计划相邻,Call 在前 Put 在后;不额外画分组框
|
|
const sorted = rows.slice().sort((a, b) => {
|
|
const ka = optionsGroupKey(a.ac, a.p);
|
|
const kb = optionsGroupKey(b.ac, b.p);
|
|
if (ka !== kb) return ka.localeCompare(kb);
|
|
const ta = String((a.p && a.p.opt_type) || "").toUpperCase();
|
|
const tb = String((b.p && b.p.opt_type) || "").toUpperCase();
|
|
if (ta === tb) return 0;
|
|
if (ta === "C") return -1;
|
|
if (tb === "C") return 1;
|
|
return ta.localeCompare(tb);
|
|
});
|
|
const body = sorted.map(({ ac, p }) => renderOptionsLegRow(ac, p, showPnl)).join("");
|
|
|
|
return `<div class="dash-pos-block dash-options-block">
|
|
<div class="dash-ac-section-label">期权持仓</div>
|
|
<div class="dash-table-wrap dash-options-table-wrap">
|
|
<table class="dash-table dash-options-table">
|
|
<thead><tr>
|
|
<th>交易所</th><th>类型</th><th>合约</th><th>Call/Put</th><th>到期倒计时</th><th>指数</th><th>目标监控</th>${
|
|
showPnl ? "<th>净盈亏</th><th>收益率</th>" : ""
|
|
}
|
|
</tr></thead>
|
|
<tbody>${body}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function bindDashboardExpand() {
|
|
if (!elAccounts) return;
|
|
elAccounts.querySelectorAll("[data-dash-ex-id]").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) {
|
|
const unified = collectUnifiedPositions(accounts);
|
|
const perpHtml = renderUnifiedPerpTable(unified.perp);
|
|
const optionsHtml = renderUnifiedOptionsTable(unified.options);
|
|
if (!perpHtml && !optionsHtml) {
|
|
elAccounts.innerHTML = '<div class="dash-empty">当前无持仓账户</div>';
|
|
return;
|
|
}
|
|
const alertAccounts = (Array.isArray(accounts) ? accounts : []).filter((a) => a && a.loss_alert);
|
|
const alertNote = alertAccounts.length
|
|
? `<div class="dash-pos-alert-note">风险: ${esc(
|
|
alertAccounts.map((a) => a.name || "").filter(Boolean).join("、")
|
|
)} 单日亏损 ≥${threshold}%</div>`
|
|
: "";
|
|
elAccounts.innerHTML = `<article class="dash-ac-card dash-pos-unified-card">
|
|
<div class="dash-ac-pos-body">
|
|
${perpHtml}
|
|
${optionsHtml}
|
|
${alertNote}
|
|
</div>
|
|
</article>`;
|
|
bindDashboardExpand();
|
|
if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
|
|
OptionsExpiryCountdown.ensureTimer();
|
|
}
|
|
}
|
|
|
|
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 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) || 5;
|
|
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();
|
|
},
|
|
refresh() {
|
|
if (!inited) return;
|
|
void fetchDashboardSnapshot({ silent: true, force: true });
|
|
},
|
|
};
|
|
})();
|