数据统计页:多机 KPI 对比与余额曲线采样。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-03 02:59:20 +08:00
parent 16f32fec2a
commit 36fbcc2d1b
11 changed files with 1036 additions and 48 deletions
+135
View File
@@ -795,3 +795,138 @@ input.readonly {
.cond-st.st-open { color: var(--ok); background: rgba(52, 211, 153, 0.12); }
.cond-st.st-done { color: var(--muted); background: rgba(138, 160, 184, 0.12); }
.cond-table-wrap { margin-top: 0; }
/* —— 数据统计 —— */
.stats-head {
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 4px;
}
.stats-window-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.stats-win-btn {
margin: 0;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(8, 14, 24, 0.7);
color: var(--muted);
font-weight: 600;
box-shadow: none;
}
.stats-win-btn.is-active {
color: #041018;
border-color: transparent;
background: linear-gradient(90deg, var(--accent-deep), var(--accent));
}
#stats-status.err { color: var(--bad); }
.stats-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.stats-kpi-card {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(56, 189, 248, 0.14);
background: rgba(5, 12, 22, 0.55);
}
.stats-kpi-label {
font-size: 0.75rem;
color: var(--muted);
margin-bottom: 6px;
}
.stats-kpi-value {
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.stats-card-head-inline {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.stats-card-head-inline h2 {
margin: 0 0 4px;
font-family: var(--font-display);
font-size: 1.05rem;
letter-spacing: 0.04em;
}
.stats-card-head-inline .muted {
margin: 0;
max-width: 640px;
line-height: 1.45;
}
.stats-curve-toggle {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
color: var(--muted);
font-size: 0.86rem;
}
.stats-curve-toggle input { width: auto; margin: 0; }
.stats-chart-wrap {
position: relative;
width: 100%;
min-height: 320px;
border-radius: 12px;
border: 1px solid rgba(56, 189, 248, 0.12);
background: rgba(4, 10, 18, 0.65);
overflow: hidden;
}
#stats-curve-canvas {
display: block;
width: 100%;
height: 320px;
}
.stats-chart-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.stats-legend {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-top: 10px;
}
.stats-legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: var(--muted);
}
.stats-legend-item i {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.stats-table th,
.stats-table td {
text-align: center;
vertical-align: middle;
}
.stats-table td:first-child,
.stats-table th:first-child {
text-align: left;
}
.stats-err-tip {
margin-top: 4px;
font-size: 0.75rem;
}
body.page-stats .main {
max-width: 1400px;
}
+330
View File
@@ -0,0 +1,330 @@
(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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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();
})();