feat(ui): 卡片迷你图改用 Chart.js,历史弹窗改为纯表格 - 新增 app/static/vendor/chart.umd.min.js(Chart.js 4.4.3 离线内嵌) - 卡片折线图:Chart.js line(无坐标轴、线色随状态、阈值虚线、动画关闭避免刷新闪烁) - renderAccounts 重绘前销毁旧 Chart 实例防泄漏 - 历史弹窗:移除图表,仅表格(时间/余额,sticky 表头,hover 高亮)

This commit is contained in:
2026-08-05 10:01:54 +08:00
parent cf9e2f5b59
commit 15832772c5
4 changed files with 134 additions and 36 deletions
+72 -36
View File
@@ -14,6 +14,7 @@
const checkingIds = new Set();
let historyCache = {};
let hasRendered = false; // 首次渲染保留进入动画,自动刷新静默更新
const cardCharts = {}; // {accountId: Chart} 卡片迷你图实例
const accountWindows = {}; // {accountId: 'all'|'1h'|'1d'|'1w'|'1mo'|'1yr'}
const windowCache = {}; // {'aid:win': [...]}
@@ -208,7 +209,6 @@
} else {
hist = windowCache[a.id + ":" + win] || historyCache[a.id] || [];
}
const spark = sparkline(hist, a.threshold, st.cls);
const winBtns = WINDOW_KEYS.map((k) =>
`<button class="win-btn ${win === k ? "active" : ""}" data-act="win" data-win="${k}" title="${WINDOW_LABELS[k]}">${k === "all" ? "ALL" : k.toUpperCase()}</button>`
).join("");
@@ -231,10 +231,10 @@
: `<span class="balance-empty">—</span>`}
${th !== null ? `<span class="balance-vs">/ 阈值 ${th}</span>` : ""}
</div>
${spark
${hist.length > 0
? `<div class="spark-wrap">
<div class="spark-title">余额历史 · ${WINDOW_LABELS[win]}${hist.length}${th !== null ? `,虚线阈值 ${th}` : ""}</div>
${spark}
<canvas class="card-chart" data-aid="${a.id}"></canvas>
<div class="window-switch" data-aid="${a.id}">${winBtns}</div>
</div>`
: ""}
@@ -274,37 +274,55 @@
}
}
/* 余额历史迷你折线图(SVG)。含阈值虚线;线色跟随状态。 */
function sparkline(hist, threshold, statusCls) {
const vals = (hist || []).map((h) => Number(h.balance)).filter(Number.isFinite);
if (vals.length === 0) return "";
const W = 100, H = 30, PAD = 3;
const max = Math.max(...vals, threshold > 0 ? threshold : 0);
const min = Math.min(...vals, threshold > 0 ? threshold : 0);
const range = max - min || 1;
const yOf = (v) => (H - PAD - ((v - min) / range) * (H - PAD * 2)).toFixed(1);
const lineColor = statusCls === "warn" ? "var(--warn)"
: statusCls === "err" ? "var(--text-3)"
: statusCls === "disabled" ? "var(--text-3)"
: "var(--ok)";
let thLine = "";
/* 销毁所有卡片迷你图实例(重绘前调用,避免 canvas 替换后泄漏) */
function destroyCardCharts() {
Object.values(cardCharts).forEach((c) => { try { c.destroy(); } catch (_) { /* 忽略 */ } });
for (const k in cardCharts) delete cardCharts[k];
}
/* 卡片迷你折线图(Chart.js):线色跟随状态,阈值画虚线,无坐标轴 */
function createCardChart(canvas, hist, threshold, statusCls) {
const css = getComputedStyle(document.documentElement);
const lineColor = statusCls === "warn" ? (css.getPropertyValue("--warn").trim() || "#d97706")
: statusCls === "err" || statusCls === "disabled" ? (css.getPropertyValue("--text-3").trim() || "#9aa1ad")
: (css.getPropertyValue("--accent").trim() || "#3b6ef6");
const thColor = css.getPropertyValue("--text-3").trim() || "#9aa1ad";
const datasets = [{
label: "余额",
data: hist.map((h) => h.balance),
borderColor: lineColor,
backgroundColor: lineColor + "1f",
fill: true,
tension: 0.35,
pointRadius: 0,
borderWidth: 1.5,
}];
if (threshold > 0) {
const y = yOf(threshold);
thLine = `<line x1="0" y1="${y}" x2="${W}" y2="${y}" stroke="var(--text-3)" stroke-width="1" stroke-dasharray="3 3" opacity="0.75"/>`;
datasets.push({
label: "阈值",
data: [threshold, threshold],
borderColor: thColor,
borderDash: [4, 4],
pointRadius: 0,
borderWidth: 1,
});
}
if (vals.length === 1) {
const x = W / 2, y = yOf(vals[0]);
return `<svg class="sparkline" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">${thLine}<circle cx="${x}" cy="${y}" r="2.2" fill="${lineColor}" vector-effect="non-scaling-stroke"/></svg>`;
}
const pts = vals.map((v, i) => {
const x = (i / (vals.length - 1)) * W;
return `${x.toFixed(1)},${yOf(v)}`;
return new Chart(canvas, {
type: "line",
data: { labels: hist.map((h) => h.checked_at), datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: false, // 自动刷新时不重播动画,避免闪烁
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: { x: { display: false }, y: { display: false } },
},
});
return `<svg class="sparkline" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">${thLine}<polyline points="${pts.join(" ")}" fill="none" stroke="${lineColor}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/></svg>`;
}
function renderAccounts(animate = true) {
const grid = document.getElementById("account-grid");
destroyCardCharts();
const stats = { total: accounts.length, ok: 0, below: 0, error: 0, disabled: 0, pending: 0 };
accounts.forEach((a) => {
const st = statusInfo(a);
@@ -340,6 +358,18 @@
list = [...list].sort((x, y) => STATUS_ORDER[statusInfo(x).cls] - STATUS_ORDER[statusInfo(y).cls]);
grid.innerHTML = list.map((a, i) => accountCard(a, i, animate)).join("");
// 为每张卡片的 canvas 创建 Chart.js 迷你图
grid.querySelectorAll("canvas.card-chart").forEach((canvas) => {
const aid = Number(canvas.dataset.aid);
const acc = accounts.find((a) => a.id === aid);
if (!acc) return;
const win = accountWindows[aid] || "all";
const hist = win === "all"
? (historyCache[aid] || [])
: (windowCache[aid + ":" + win] || historyCache[aid] || []);
if (hist.length === 0) return;
cardCharts[aid] = createCardChart(canvas, hist, acc.threshold, statusInfo(acc).cls);
});
const hint = document.getElementById("empty-hint");
if (accounts.length > 0) {
hint.classList.add("hidden");
@@ -392,11 +422,12 @@
/* ---------- 模态框 ---------- */
function openModal(html, onMount) {
function openModal(html, onMount, onClose) {
const root = document.getElementById("modal-root");
const mask = document.createElement("div");
mask.className = "modal-mask";
mask.innerHTML = html;
if (onClose) mask._onClose = onClose;
mask.addEventListener("click", (e) => { if (e.target === mask) closeModal(mask); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(mask); }, { once: true });
root.appendChild(mask);
@@ -405,7 +436,9 @@
}
function closeModal(mask) {
if (mask) mask.remove();
if (!mask) return;
if (mask._onClose) mask._onClose();
mask.remove();
}
function confirmDialog(text, onOk) {
@@ -513,7 +546,7 @@
});
}
/* 历史弹窗 */
/* 历史弹窗:纯表格(时间 + 余额),无图表 */
async function showHistory(accountId) {
const acc = accounts.find((a) => a.id === accountId);
if (!acc) return;
@@ -530,12 +563,15 @@
body.innerHTML = `<div style="padding:20px 0;text-align:center">暂无记录(检查成功后自动记录)</div>`;
return;
}
const max = Math.max(...hist.map((h) => h.balance), 1e-9);
const bars = hist.map((h) =>
`<i title="${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")} @ ${escapeHtml(h.checked_at)}" style="height:${Math.max(6, (h.balance / max) * 100)}%"></i>`).join("");
const items = [...hist].reverse().map((h) =>
`<div class="history-item"><span>${escapeHtml(h.checked_at)}</span><span class="h-bal">${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")}</span></div>`).join("");
body.innerHTML = `<div class="spark">${bars}</div>${items}`;
const rows = [...hist].reverse().map((h) =>
`<tr><td>${escapeHtml(h.checked_at)}</td><td class="h-bal">${fmtBalance(h.balance)} ${escapeHtml(plat.currency || "")}</td></tr>`).join("");
body.innerHTML = `
<div class="hist-list-wrap">
<table class="hist-table">
<thead><tr><th>时间</th><th>余额</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
} catch (e) {
mask.querySelector("#hist-body").textContent = "加载失败:" + e.message;
}
+1
View File
@@ -139,6 +139,7 @@
<div id="toast-root"></div>
<script src="/static/icons.js"></script>
<script src="/static/vendor/chart.umd.min.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
+41
View File
@@ -358,6 +358,41 @@ label { font-size: 12px; font-weight: 500; color: var(--text-2); margin-bottom:
}
.spark i:hover { opacity: 1; }
/* 历史弹窗表格 */
.hist-list-wrap {
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.hist-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
.hist-table th, .hist-table td {
padding: 7px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.hist-table th {
position: sticky;
top: 0;
background: var(--bg-elev);
color: var(--text-3);
font-weight: 500;
font-size: 11px;
}
.hist-table tbody tr:last-child td { border-bottom: none; }
.hist-table td.h-bal {
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--text);
}
.hist-table tbody tr { transition: background 120ms ease; }
.hist-table tbody tr:hover { background: var(--bg-elev-2); }
/* 确认弹窗 */
.confirm-modal .modal { width: 340px; }
.confirm-text { color: var(--text-2); font-size: 13px; margin-bottom: 18px; line-height: 1.6; }
@@ -562,6 +597,12 @@ label { font-size: 12px; font-weight: 500; color: var(--text-2); margin-bottom:
width: 100%;
height: 30px;
}
/* 卡片迷你图(Chart.js canvas */
.card-chart {
display: block;
width: 100%;
height: 36px;
}
.spark-title {
font-size: 10px;
color: var(--text-3);
File diff suppressed because one or more lines are too long