数据统计改为后台热缓存与 SSE,曲线放大置底并支持选机,对比增加模式与收益率。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1656,6 +1656,62 @@ input.readonly {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.stats-curve-toggle input { width: auto; margin: 0; }
|
||||
.stats-curve-panel--large { margin-top: 18px; }
|
||||
.stats-machine-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
margin: 0 0 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.stats-machine-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.18);
|
||||
background: rgba(5, 12, 22, 0.65);
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.stats-machine-chip input { width: auto; margin: 0; }
|
||||
.stats-machine-chip i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.stats-pick-all {
|
||||
border: 1px solid rgba(56, 189, 248, 0.22);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stats-pick-all:hover { color: var(--text); border-color: rgba(56, 189, 248, 0.4); }
|
||||
.stats-mode-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
color: var(--muted);
|
||||
}
|
||||
.stats-mode-badge.stats-mode-sim {
|
||||
color: #67e8f9;
|
||||
border-color: rgba(34, 211, 238, 0.35);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
.stats-mode-badge.stats-mode-live {
|
||||
color: #fbbf24;
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
}
|
||||
.stats-chart-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -1665,11 +1721,19 @@ input.readonly {
|
||||
background: rgba(4, 10, 18, 0.65);
|
||||
overflow: hidden;
|
||||
}
|
||||
.stats-chart-wrap--large {
|
||||
min-height: 480px;
|
||||
height: min(62vh, 560px);
|
||||
}
|
||||
#stats-curve-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
.stats-chart-wrap--large #stats-curve-canvas {
|
||||
height: 100%;
|
||||
min-height: 480px;
|
||||
}
|
||||
.stats-chart-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
+515
-330
@@ -1,330 +1,515 @@
|
||||
(function () {
|
||||
const COLORS = [
|
||||
"#22d3ee",
|
||||
"#34d399",
|
||||
"#fbbf24",
|
||||
"#fb7185",
|
||||
"#a78bfa",
|
||||
"#60a5fa",
|
||||
"#f472b6",
|
||||
"#2dd4bf",
|
||||
];
|
||||
|
||||
let windowKey = "7d";
|
||||
let curveData = null;
|
||||
let summaryData = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.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 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 (
|
||||
'<div class="stats-kpi-card">' +
|
||||
'<div class="stats-kpi-label">' +
|
||||
escapeHtml(it.label) +
|
||||
"</div>" +
|
||||
'<div class="stats-kpi-value ' +
|
||||
escapeHtml(it.cls || "") +
|
||||
'">' +
|
||||
escapeHtml(it.value) +
|
||||
"</div></div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTable(machines) {
|
||||
const body = document.getElementById("stats-table-body");
|
||||
if (!body) return;
|
||||
const list = machines || [];
|
||||
if (!list.length) {
|
||||
body.innerHTML = '<tr><td colspan="10" class="muted">暂无启用实例</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = list
|
||||
.map(function (m) {
|
||||
const status = m.online
|
||||
? '<span class="status-on">在线</span>'
|
||||
: '<span class="status-off">离线</span>';
|
||||
const err = m.error
|
||||
? '<div class="muted stats-err-tip">' + escapeHtml(m.error) + "</div>"
|
||||
: "";
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td><strong>" +
|
||||
escapeHtml(m.name || m.id) +
|
||||
"</strong>" +
|
||||
err +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
status +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtNum(m.cash_usdt)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtNum(m.premium_usdt)) +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(m.unrealized_usdt) +
|
||||
'">' +
|
||||
escapeHtml(fmtPnl(m.unrealized_usdt)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(m.net_realized_pnl) +
|
||||
'">' +
|
||||
escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
|
||||
"</td>" +
|
||||
'<td><a class="btn btn-sm" href="/desk?id=' +
|
||||
encodeURIComponent(String(m.id)) +
|
||||
'">工作台</a></td>' +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.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 cssH = 320;
|
||||
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 showMachines = !!(document.getElementById("stats-show-machines") || {}).checked;
|
||||
const lines = [];
|
||||
if (data && data.total && data.total.length) {
|
||||
lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.4 });
|
||||
}
|
||||
if (showMachines && data && data.series) {
|
||||
data.series.forEach(function (s, i) {
|
||||
if (!s.points || !s.points.length) return;
|
||||
lines.push({
|
||||
name: s.name || String(s.id),
|
||||
color: COLORS[i % COLORS.length],
|
||||
points: s.points,
|
||||
width: 1.4,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (legend) {
|
||||
legend.innerHTML = lines
|
||||
.map(function (ln) {
|
||||
return (
|
||||
'<span class="stats-legend-item"><i style="background:' +
|
||||
ln.color +
|
||||
'"></i>' +
|
||||
escapeHtml(ln.name) +
|
||||
"</span>"
|
||||
);
|
||||
})
|
||||
.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: 52, r: 16, t: 16, b: 28 };
|
||||
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 <= 4; i++) {
|
||||
const y = pad.t + (h * i) / 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.l, y);
|
||||
ctx.lineTo(pad.l + w, y);
|
||||
ctx.stroke();
|
||||
const val = maxV - ((maxV - minV) * i) / 4;
|
||||
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() {
|
||||
setStatus("加载中…");
|
||||
try {
|
||||
const [sumRes, curveRes] = await Promise.all([
|
||||
fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey)),
|
||||
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey)),
|
||||
]);
|
||||
const sum = await sumRes.json();
|
||||
const curve = await curveRes.json();
|
||||
if (!sum.ok) throw new Error(sum.msg || "汇总失败");
|
||||
if (!curve.ok) throw new Error(curve.msg || "曲线失败");
|
||||
summaryData = sum;
|
||||
curveData = curve;
|
||||
renderKpis(sum.totals);
|
||||
renderTable(sum.machines);
|
||||
drawCurve(curve);
|
||||
setStatus(
|
||||
"窗口 " +
|
||||
windowKey +
|
||||
" · 在线 " +
|
||||
(sum.totals.online || 0) +
|
||||
"/" +
|
||||
(sum.totals.machines || 0) +
|
||||
" · 曲线采样点以巡检累积"
|
||||
);
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
}
|
||||
|
||||
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 toggle = document.getElementById("stats-show-machines");
|
||||
if (toggle) {
|
||||
toggle.addEventListener("change", function () {
|
||||
if (curveData) drawCurve(curveData);
|
||||
});
|
||||
}
|
||||
window.addEventListener("resize", function () {
|
||||
if (curveData) drawCurve(curveData);
|
||||
});
|
||||
}
|
||||
|
||||
bind();
|
||||
loadAll();
|
||||
})();
|
||||
(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, ">")
|
||||
.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 (
|
||||
'<div class="stats-kpi-card">' +
|
||||
'<div class="stats-kpi-label">' +
|
||||
escapeHtml(it.label) +
|
||||
"</div>" +
|
||||
'<div class="stats-kpi-value ' +
|
||||
escapeHtml(it.cls || "") +
|
||||
'">' +
|
||||
escapeHtml(it.value) +
|
||||
"</div></div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTable(machines) {
|
||||
const body = document.getElementById("stats-table-body");
|
||||
if (!body) return;
|
||||
const list = machines || [];
|
||||
if (!list.length) {
|
||||
body.innerHTML = '<tr><td colspan="12" class="muted">暂无启用实例</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = list
|
||||
.map(function (m) {
|
||||
const status = m.online
|
||||
? '<span class="status-on">在线</span>'
|
||||
: '<span class="status-off">离线</span>';
|
||||
const err = m.error
|
||||
? '<div class="muted stats-err-tip">' + escapeHtml(m.error) + "</div>"
|
||||
: "";
|
||||
const mode = String(m.mode || "").toLowerCase();
|
||||
const modeCls = mode === "live" ? "stats-mode-live" : mode === "sim" ? "stats-mode-sim" : "";
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td><strong>" +
|
||||
escapeHtml(m.name || m.id) +
|
||||
"</strong>" +
|
||||
err +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
status +
|
||||
"</td>" +
|
||||
'<td><span class="stats-mode-badge ' +
|
||||
modeCls +
|
||||
'">' +
|
||||
escapeHtml(modeLabel(m.mode)) +
|
||||
"</span></td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtNum(m.cash_usdt)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtNum(m.premium_usdt)) +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(m.unrealized_usdt) +
|
||||
'">' +
|
||||
escapeHtml(fmtPnl(m.unrealized_usdt)) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(m.net_realized_pnl) +
|
||||
'">' +
|
||||
escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
|
||||
"</td>" +
|
||||
'<td class="' +
|
||||
pnlClass(m.return_rate_pct) +
|
||||
'">' +
|
||||
escapeHtml(
|
||||
m.stats_ok && m.return_rate_pct != null ? fmtNum(m.return_rate_pct, 2) + "%" : "—"
|
||||
) +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
|
||||
"</td>" +
|
||||
'<td><a class="btn btn-sm" href="/desk?id=' +
|
||||
encodeURIComponent(String(m.id)) +
|
||||
'">工作台</a></td>' +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.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 = '<span class="muted">暂无可选机器</span>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML =
|
||||
'<button type="button" class="stats-pick-all" data-act="all">全选</button>' +
|
||||
'<button type="button" class="stats-pick-all" data-act="none">全不选</button>' +
|
||||
series
|
||||
.map(function (s, i) {
|
||||
const id = String(s.id);
|
||||
const checked = selectedIds && selectedIds.has(id) ? " checked" : "";
|
||||
const color = COLORS[i % COLORS.length];
|
||||
return (
|
||||
'<label class="stats-machine-chip">' +
|
||||
'<input type="checkbox" data-machine-id="' +
|
||||
escapeHtml(id) +
|
||||
'"' +
|
||||
checked +
|
||||
">" +
|
||||
'<i style="background:' +
|
||||
color +
|
||||
'"></i>' +
|
||||
escapeHtml(s.name || id) +
|
||||
"</label>"
|
||||
);
|
||||
})
|
||||
.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 (
|
||||
'<span class="stats-legend-item"><i style="background:' +
|
||||
ln.color +
|
||||
'"></i>' +
|
||||
escapeHtml(ln.name) +
|
||||
"</span>"
|
||||
);
|
||||
})
|
||||
.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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user