(function () { const COLORS = [ "#22d3ee", "#34d399", "#fbbf24", "#fb7185", "#a78bfa", "#60a5fa", "#f472b6", "#2dd4bf", ]; const PICKER_KEY = "hub-stats-curve-machines"; let windowKey = "7d"; let curveData = null; let summaryData = null; let selectedIds = loadSelectedIds(); let statsVersion = 0; let statsLoading = false; let statsEventSource = null; let statsReconnectTimer = null; function loadSelectedIds() { try { const raw = localStorage.getItem(PICKER_KEY); if (!raw) return null; const arr = JSON.parse(raw); if (Array.isArray(arr)) return new Set(arr.map(String)); } catch (_) {} return null; } function saveSelectedIds() { try { localStorage.setItem(PICKER_KEY, JSON.stringify(Array.from(selectedIds || []))); } catch (_) {} } function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function fmtNum(v, digits) { if (v == null || v === "") return "—"; const n = Number(v); if (!Number.isFinite(n)) return "—"; return n.toFixed(digits == null ? 2 : digits); } function fmtPnl(v) { if (v == null || v === "") return "—"; const n = Number(v); if (!Number.isFinite(n)) return "—"; return (n > 0 ? "+" : "") + n.toFixed(2) + " U"; } function pnlClass(v) { const n = Number(v); if (!Number.isFinite(n) || n === 0) return "pnl-flat"; return n > 0 ? "pnl-up" : "pnl-down"; } function fmtHold(sec) { if (sec == null || sec === "") return "—"; const n = Number(sec); if (!Number.isFinite(n) || n < 0) return "—"; if (n < 60) return Math.round(n) + "s"; if (n < 3600) return (n / 60).toFixed(1) + "m"; return (n / 3600).toFixed(1) + "h"; } function modeLabel(mode) { const m = String(mode || "").toLowerCase(); if (m === "sim") return "模拟"; if (m === "live") return "实盘"; return mode ? String(mode) : "—"; } function setStatus(msg, isErr) { const el = document.getElementById("stats-status"); if (!el) return; el.textContent = msg || ""; el.classList.toggle("err", !!isErr); } function renderKpis(totals) { const grid = document.getElementById("stats-kpi-grid"); if (!grid) return; const t = totals || {}; const items = [ { label: "在线 / 总数", value: (t.online || 0) + " / " + (t.machines || 0) }, { label: "合计余额", value: fmtNum(t.cash_usdt) + " U" }, { label: "窗口已实现", value: fmtPnl(t.net_realized_pnl), cls: pnlClass(t.net_realized_pnl) }, { label: "胜率", value: fmtNum(t.win_rate, 1) + "%" }, { label: "平仓笔数", value: String(t.total_closed != null ? t.total_closed : "—") }, { label: "合计浮盈", value: fmtPnl(t.unrealized_usdt), cls: pnlClass(t.unrealized_usdt) }, ]; grid.innerHTML = items .map(function (it) { return ( '
' + '
' + escapeHtml(it.label) + "
" + '
' + escapeHtml(it.value) + "
" ); }) .join(""); } function renderTable(machines) { const body = document.getElementById("stats-table-body"); if (!body) return; const list = machines || []; if (!list.length) { body.innerHTML = '暂无启用实例'; return; } body.innerHTML = list .map(function (m) { const status = m.online ? '在线' : '离线'; const err = m.error ? '
' + escapeHtml(m.error) + "
" : ""; const mode = String(m.mode || "").toLowerCase(); const modeCls = mode === "live" ? "stats-mode-live" : mode === "sim" ? "stats-mode-sim" : ""; return ( "" + "" + escapeHtml(m.name || m.id) + "" + err + "" + "" + status + "" + '' + escapeHtml(modeLabel(m.mode)) + "" + "" + escapeHtml(fmtNum(m.cash_usdt)) + "" + "" + escapeHtml(fmtNum(m.premium_usdt)) + "" + '' + escapeHtml(fmtPnl(m.unrealized_usdt)) + "" + "" + escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") + "" + "" + escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") + "" + '' + escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") + "" + '' + escapeHtml( m.stats_ok && m.return_rate_pct != null ? fmtNum(m.return_rate_pct, 2) + "%" : "—" ) + "" + "" + escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") + "" + '工作台' + "" ); }) .join(""); } function ensureSelectedIds(series) { const ids = (series || []).map(function (s) { return String(s.id); }); if (selectedIds == null) { selectedIds = new Set(ids); saveSelectedIds(); return; } // 新机器默认勾选 ids.forEach(function (id) { if (!selectedIds.has(id) && !localStorage.getItem(PICKER_KEY + ":seen:" + id)) { selectedIds.add(id); try { localStorage.setItem(PICKER_KEY + ":seen:" + id, "1"); } catch (_) {} } }); saveSelectedIds(); } function renderMachinePicker(data) { const box = document.getElementById("stats-machine-picker"); if (!box) return; const series = (data && data.series) || []; ensureSelectedIds(series); if (!series.length) { box.innerHTML = '暂无可选机器'; return; } box.innerHTML = '' + '' + series .map(function (s, i) { const id = String(s.id); const checked = selectedIds && selectedIds.has(id) ? " checked" : ""; const color = COLORS[i % COLORS.length]; return ( '" ); }) .join(""); } function drawCurve(data) { const canvas = document.getElementById("stats-curve-canvas"); const empty = document.getElementById("stats-curve-empty"); const legend = document.getElementById("stats-curve-legend"); if (!canvas) return; const ctx = canvas.getContext("2d"); const dpr = window.devicePixelRatio || 1; const cssW = canvas.clientWidth || 960; const wrap = canvas.parentElement; const cssH = Math.max(420, (wrap && wrap.clientHeight) || 480); canvas.width = Math.floor(cssW * dpr); canvas.height = Math.floor(cssH * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, cssW, cssH); const showTotal = !!(document.getElementById("stats-show-total") || {}).checked; const lines = []; if (showTotal && data && data.total && data.total.length) { lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.6 }); } if (data && data.series) { data.series.forEach(function (s, i) { const id = String(s.id); if (selectedIds && !selectedIds.has(id)) return; if (!s.points || !s.points.length) return; lines.push({ name: s.name || id, color: COLORS[i % COLORS.length], points: s.points, width: 1.6, }); }); } if (legend) { legend.innerHTML = lines .map(function (ln) { return ( '' + escapeHtml(ln.name) + "" ); }) .join(""); } const allPts = lines.reduce(function (acc, ln) { return acc.concat(ln.points || []); }, []); if (!allPts.length) { if (empty) empty.hidden = false; return; } if (empty) empty.hidden = true; let minT = Math.min.apply( null, allPts.map(function (p) { return p.t; }) ); let maxT = Math.max.apply( null, allPts.map(function (p) { return p.t; }) ); let minV = Math.min.apply( null, allPts.map(function (p) { return p.v; }) ); let maxV = Math.max.apply( null, allPts.map(function (p) { return p.v; }) ); if (maxT <= minT) maxT = minT + 1; if (maxV <= minV) { maxV = minV + 1; minV = minV - 1; } const pad = { l: 56, r: 18, t: 18, b: 32 }; const w = cssW - pad.l - pad.r; const h = cssH - pad.t - pad.b; function xOf(t) { return pad.l + ((t - minT) / (maxT - minT)) * w; } function yOf(v) { return pad.t + (1 - (v - minV) / (maxV - minV)) * h; } ctx.strokeStyle = "rgba(56,189,248,0.15)"; ctx.lineWidth = 1; for (let i = 0; i <= 5; i++) { const y = pad.t + (h * i) / 5; ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(pad.l + w, y); ctx.stroke(); const val = maxV - ((maxV - minV) * i) / 5; ctx.fillStyle = "#8aa0b8"; ctx.font = "11px IBM Plex Sans, sans-serif"; ctx.fillText(val.toFixed(0), 8, y + 4); } lines.forEach(function (ln) { const pts = ln.points || []; if (pts.length < 1) return; ctx.strokeStyle = ln.color; ctx.lineWidth = ln.width || 1.5; ctx.beginPath(); pts.forEach(function (p, idx) { const x = xOf(p.t); const y = yOf(p.v); if (idx === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); }); } async function loadAll(opts) { opts = opts || {}; if (statsLoading) return; statsLoading = true; if (!opts.silent) setStatus("读取缓存…"); try { const [sumRes, curveRes] = await Promise.all([ fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey), { credentials: "same-origin", }), fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey), { credentials: "same-origin", }), ]); const sum = await sumRes.json(); const curve = await curveRes.json(); if (!sum.ok && !(sum.machines && sum.machines.length)) { if (sum.refreshing) { setStatus("后台首次刷新统计中…"); return; } throw new Error(sum.msg || sum.error || "汇总失败"); } if (!curve.ok) throw new Error(curve.msg || "曲线失败"); statsVersion = Number(sum.stats_version || curve.stats_version) || statsVersion; summaryData = sum; curveData = curve; renderKpis(sum.totals || {}); renderTable(sum.machines || []); renderMachinePicker(curve); drawCurve(curve); const sec = Number(sum.poll_interval_sec || curve.poll_interval_sec) || 15; const live = statsEventSource && statsEventSource.readyState === 1; setStatus( (live ? "SSE 已连接 · " : "") + "窗口 " + windowKey + " · 缓存 " + ((sum.totals && sum.totals.online) || 0) + "/" + ((sum.totals && sum.totals.machines) || 0) + (sum.updated_at ? " · " + sum.updated_at : "") + (sum.refreshing ? " · 后台刷新中" : " · 后台每 " + sec + "s 刷新") ); } catch (e) { setStatus(String(e.message || e), true); } finally { statsLoading = false; } } function closeStatsStream() { if (statsEventSource) { statsEventSource.close(); statsEventSource = null; } if (statsReconnectTimer) { clearTimeout(statsReconnectTimer); statsReconnectTimer = null; } } function connectStatsStream() { if (typeof EventSource === "undefined") return; closeStatsStream(); statsEventSource = new EventSource("/api/stats/stream"); statsEventSource.addEventListener("stats", function (ev) { try { const st = JSON.parse(ev.data || "{}"); const ver = Number(st.stats_version) || 0; if (ver && ver !== statsVersion) { loadAll({ silent: true }); } else if (st.refreshing) { setStatus("后台刷新统计中…"); } } catch (_) {} }); statsEventSource.onerror = function () { closeStatsStream(); setStatus("SSE 断开,8s 后重连…"); statsReconnectTimer = setTimeout(function () { connectStatsStream(); loadAll({ silent: true }); }, 8000); }; } function bind() { const tabs = document.getElementById("stats-window-tabs"); if (tabs) { tabs.addEventListener("click", function (ev) { const btn = ev.target.closest("[data-window]"); if (!btn) return; windowKey = btn.getAttribute("data-window") || "7d"; tabs.querySelectorAll(".stats-win-btn").forEach(function (b) { b.classList.toggle("is-active", b === btn); }); loadAll(); }); } const showTotal = document.getElementById("stats-show-total"); if (showTotal) { showTotal.addEventListener("change", function () { if (curveData) drawCurve(curveData); }); } const picker = document.getElementById("stats-machine-picker"); if (picker) { picker.addEventListener("change", function (ev) { const input = ev.target.closest("input[data-machine-id]"); if (!input) return; const id = String(input.getAttribute("data-machine-id") || ""); if (!selectedIds) selectedIds = new Set(); if (input.checked) selectedIds.add(id); else selectedIds.delete(id); saveSelectedIds(); if (curveData) drawCurve(curveData); }); picker.addEventListener("click", function (ev) { const btn = ev.target.closest("[data-act]"); if (!btn || !curveData) return; const act = btn.getAttribute("data-act"); const ids = ((curveData && curveData.series) || []).map(function (s) { return String(s.id); }); if (act === "all") selectedIds = new Set(ids); else if (act === "none") selectedIds = new Set(); saveSelectedIds(); renderMachinePicker(curveData); drawCurve(curveData); }); } window.addEventListener("resize", function () { if (curveData) drawCurve(curveData); }); } bind(); loadAll(); connectStatsStream(); })();