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:
+72
-36
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
Vendored
+20
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user