feat: 账号卡片余额趋势折线图 + 时间窗口切换(all/1h/1d/1w/1mo/1yr) - api: history 接口支持 window 参数与窗口内均匀降采样;新增批量 /api/history - ui: 阈值进度条替换为 SVG 迷你折线图(状态色 + 阈值虚线),卡片级窗口切换器 - test: 窗口过滤、降采样、批量历史用例,共 54 个
This commit is contained in:
+183
-22
@@ -8,6 +8,18 @@
|
||||
let platforms = [];
|
||||
let settings = null;
|
||||
let refreshTimer = null;
|
||||
let filterStatus = "all";
|
||||
let filterPlatform = 0;
|
||||
const checkingIds = new Set();
|
||||
let historyCache = {};
|
||||
const accountWindows = {}; // {accountId: 'all'|'1h'|'1d'|'1w'|'1mo'|'1yr'}
|
||||
const windowCache = {}; // {'aid:win': [...]}
|
||||
|
||||
const WINDOW_LABELS = {
|
||||
all: "全部记录", "1h": "最近 1 小时", "1d": "最近 1 天",
|
||||
"1w": "最近 1 周", "1mo": "最近 1 个月", "1yr": "最近 1 年",
|
||||
};
|
||||
const WINDOW_KEYS = ["all", "1h", "1d", "1w", "1mo", "1yr"];
|
||||
|
||||
/* ---------- 工具 ---------- */
|
||||
|
||||
@@ -54,6 +66,15 @@
|
||||
return s;
|
||||
}
|
||||
|
||||
/* 内联 SVG 小图标(lucide 风格描边) */
|
||||
const ICONS = {
|
||||
refresh: '<svg viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>',
|
||||
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
|
||||
pencil: '<svg viewBox="0 0 24 24"><path d="M17 3l4 4L8 20l-5 1 1-5L17 3z"/></svg>',
|
||||
trash: '<svg viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M6 6l1 14h10l1-14"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>',
|
||||
alert: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>',
|
||||
};
|
||||
|
||||
/* 容错 JSON 解析:允许用户写 Python 风格的单引号;失败返回 null */
|
||||
function parseJsonInput(text, label, errorId) {
|
||||
let s = (text || "").trim();
|
||||
@@ -120,9 +141,10 @@
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [acc, plat] = await Promise.all([api("/accounts"), api("/platforms")]);
|
||||
const [acc, plat, hist] = await Promise.all([api("/accounts"), api("/platforms"), api("/history")]);
|
||||
accounts = acc;
|
||||
platforms = plat;
|
||||
historyCache = hist || {};
|
||||
renderAccounts();
|
||||
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms();
|
||||
const d = new Date();
|
||||
@@ -155,41 +177,115 @@
|
||||
const bal = fmtBalance(a.last_balance);
|
||||
const th = fmtBalance(a.threshold);
|
||||
const below = a.last_status === "ok" && bal !== null && Number(a.last_balance) < a.threshold;
|
||||
const cardCls = ["account-card", st.cls === "warn" ? "below" : "", st.cls === "err" ? "error" : "",
|
||||
st.cls === "disabled" ? "disabled" : ""].join(" ").trim();
|
||||
const pct = th && bal !== null ? Math.min(100, (Number(a.last_balance) / Number(th)) * 100) : 100;
|
||||
const fillCls = below ? "threshold-fill below" : "threshold-fill";
|
||||
const checking = checkingIds.has(a.id);
|
||||
const cardCls = ["account-card",
|
||||
st.cls === "warn" ? "below" : "",
|
||||
st.cls === "err" ? "error" : "",
|
||||
st.cls === "disabled" ? "disabled" : "",
|
||||
st.cls === "pending" ? "pending" : "",
|
||||
].join(" ").trim();
|
||||
const win = accountWindows[a.id] || "all";
|
||||
let hist;
|
||||
if (win === "all") {
|
||||
hist = historyCache[a.id] || [];
|
||||
} 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("");
|
||||
const checkBtn = checking
|
||||
? `<button class="btn sm check-btn loading" disabled><span class="spinner"></span>检查中</button>`
|
||||
: `<button class="btn sm check-btn" data-act="check" data-id="${a.id}" ${!a.enabled ? "disabled title=启用后可用" : ""}>${ICONS.refresh}立即检查</button>`;
|
||||
return `
|
||||
<div class="${cardCls}" style="animation-delay:${Math.min(idx, 12) * 40}ms">
|
||||
<div class="card-top">
|
||||
<div class="card-head">
|
||||
<div class="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div>
|
||||
<div class="card-title">
|
||||
<div class="card-platform">${escapeHtml(plat.name || "未知平台")} · ${escapeHtml(plat.currency || "")}</div>
|
||||
<div class="card-name" title="${escapeHtml(a.name)}">${escapeHtml(a.name)}</div>
|
||||
</div>
|
||||
<span class="badge-pill ${st.cls}">${st.text}</span>
|
||||
</div>
|
||||
<div class="balance-row">
|
||||
${bal !== null
|
||||
? `<span class="balance-value ${below ? "below" : ""}">${bal}</span><span class="balance-currency">${escapeHtml(plat.currency || "")}</span>`
|
||||
: `<span class="balance-empty">—</span>`}
|
||||
${th !== null ? `<span class="balance-vs">/ 阈值 ${th}</span>` : ""}
|
||||
</div>
|
||||
<div class="threshold-bar" title="阈值 ${th ?? "—"} ${escapeHtml(plat.currency || "")}">
|
||||
<div class="${fillCls}" style="width:${bal === null ? 0 : pct}%"></div>
|
||||
</div>
|
||||
${spark
|
||||
? `<div class="spark-wrap">
|
||||
<div class="spark-title">余额历史 · ${WINDOW_LABELS[win]}(${hist.length} 条${th !== null ? `,虚线阈值 ${th}` : ""})</div>
|
||||
${spark}
|
||||
<div class="window-switch" data-aid="${a.id}">${winBtns}</div>
|
||||
</div>`
|
||||
: ""}
|
||||
<div class="card-meta">
|
||||
<span class="badge-pill ${st.cls}">${st.text}</span>
|
||||
<span>上次检查:${fmtTime(a.last_check_at)}</span>
|
||||
${a.last_error ? `<span style="color:var(--err)">${escapeHtml(a.last_error)}</span>` : ""}
|
||||
<span class="meta-item">${ICONS.clock}上次 ${fmtTime(a.last_check_at)}</span>
|
||||
<span class="meta-item">${ICONS.refresh}每 ${plat.interval_seconds || (settings ? settings.global_interval_seconds : 300)}s</span>
|
||||
</div>
|
||||
${a.last_error ? `<div class="card-error" title="${escapeHtml(a.last_error)}">${ICONS.alert}${escapeHtml(a.last_error)}</div>` : ""}
|
||||
<div class="card-actions">
|
||||
<button class="btn sm" data-act="history" data-id="${a.id}">历史</button>
|
||||
<button class="btn sm" data-act="check" data-id="${a.id}" ${!a.enabled ? "disabled" : ""}>立即检查</button>
|
||||
<button class="btn sm" data-act="edit" data-id="${a.id}">编辑</button>
|
||||
<button class="btn sm danger" data-act="del" data-id="${a.id}">删除</button>
|
||||
${checkBtn}
|
||||
<button class="btn sm icon-btn" data-act="history" data-id="${a.id}" title="余额历史">${ICONS.clock}</button>
|
||||
<button class="btn sm icon-btn" data-act="edit" data-id="${a.id}" title="编辑账号">${ICONS.pencil}</button>
|
||||
<button class="btn sm icon-btn danger" data-act="del" data-id="${a.id}" title="删除账号">${ICONS.trash}</button>
|
||||
<label class="switch" title="${a.enabled ? "点击停用监控" : "点击启用监控"}">
|
||||
<input type="checkbox" data-act="toggle" data-id="${a.id}" ${a.enabled ? "checked" : ""}>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const STATUS_ORDER = { warn: 0, err: 1, pending: 2, ok: 3, disabled: 4 };
|
||||
|
||||
/* 切换某账号折线图的时间窗口;窗口数据按需拉取并缓存 */
|
||||
async function setWindow(aid, win) {
|
||||
accountWindows[aid] = win;
|
||||
renderAccounts();
|
||||
if (win === "all") return;
|
||||
const key = aid + ":" + win;
|
||||
if (windowCache[key]) return;
|
||||
try {
|
||||
const data = await api(`/accounts/${aid}/history?window=${win}&limit=120`);
|
||||
windowCache[key] = data;
|
||||
renderAccounts();
|
||||
} catch (e) {
|
||||
toast(e.message, "err");
|
||||
}
|
||||
}
|
||||
|
||||
/* 余额历史迷你折线图(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 = "";
|
||||
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"/>`;
|
||||
}
|
||||
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 `<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() {
|
||||
const grid = document.getElementById("account-grid");
|
||||
const stats = { total: accounts.length, ok: 0, below: 0, error: 0, disabled: 0, pending: 0 };
|
||||
@@ -208,10 +304,31 @@
|
||||
document.getElementById("stat-disabled").textContent = stats.disabled;
|
||||
document.getElementById("stat-pending").textContent = stats.pending;
|
||||
|
||||
grid.innerHTML = accounts.map(accountCard).join("");
|
||||
// 同步平台筛选下拉
|
||||
const sel = document.getElementById("filter-platform");
|
||||
const cur = filterPlatform;
|
||||
const opts = ['<option value="0">全部平台</option>'].concat(
|
||||
platforms.map((p) => `<option value="${p.id}">${escapeHtml(p.name)}</option>`)
|
||||
).join("");
|
||||
if (sel.innerHTML !== opts) sel.innerHTML = opts;
|
||||
if (!platforms.some((p) => p.id === cur)) filterPlatform = 0;
|
||||
sel.value = filterPlatform;
|
||||
|
||||
// 筛选 + 排序(低于阈值/异常优先)
|
||||
let list = accounts.filter((a) => {
|
||||
if (filterPlatform !== 0 && a.platform_id !== filterPlatform) return false;
|
||||
if (filterStatus === "all") return true;
|
||||
return statusInfo(a).cls === filterStatus;
|
||||
});
|
||||
list = [...list].sort((x, y) => STATUS_ORDER[statusInfo(x).cls] - STATUS_ORDER[statusInfo(y).cls]);
|
||||
|
||||
grid.innerHTML = list.map(accountCard).join("");
|
||||
const hint = document.getElementById("empty-hint");
|
||||
if (accounts.length > 0) {
|
||||
hint.classList.add("hidden");
|
||||
if (list.length === 0) {
|
||||
grid.innerHTML = '<div class="empty-filter">没有符合条件的账号,试试调整筛选条件。</div>';
|
||||
}
|
||||
} else {
|
||||
hint.classList.remove("hidden");
|
||||
hint.innerHTML = platforms.length === 0
|
||||
@@ -469,6 +586,18 @@
|
||||
document.getElementById("save-settings-btn").onclick = saveSettings;
|
||||
document.getElementById("change-pwd-btn").onclick = changePassword;
|
||||
|
||||
document.getElementById("filter-pills").addEventListener("click", (e) => {
|
||||
const pill = e.target.closest(".pill");
|
||||
if (!pill) return;
|
||||
filterStatus = pill.dataset.filter;
|
||||
document.querySelectorAll("#filter-pills .pill").forEach((p) => p.classList.toggle("active", p === pill));
|
||||
renderAccounts();
|
||||
});
|
||||
document.getElementById("filter-platform").addEventListener("change", (e) => {
|
||||
filterPlatform = Number(e.target.value);
|
||||
renderAccounts();
|
||||
});
|
||||
|
||||
document.getElementById("account-grid").addEventListener("click", onAccountAction);
|
||||
document.getElementById("platform-list").addEventListener("click", onPlatformAction);
|
||||
}
|
||||
@@ -512,19 +641,51 @@
|
||||
const id = Number(btn.dataset.id);
|
||||
const act = btn.dataset.act;
|
||||
if (act === "edit") accountForm(accounts.find((a) => a.id === id));
|
||||
else if (act === "del") {
|
||||
else if (act === "toggle") {
|
||||
const checked = btn.checked;
|
||||
try {
|
||||
await api("/accounts/" + id, { method: "PUT", body: JSON.stringify({ enabled: checked }) });
|
||||
toast(checked ? "账号已启用" : "账号已停用");
|
||||
await refresh();
|
||||
} catch (err2) { toast(err2.message, "err"); await refresh(); }
|
||||
} else if (act === "del") {
|
||||
const a = accounts.find((x) => x.id === id);
|
||||
confirmDialog(`确定删除账号「${escapeHtml(a ? a.name : id)}」?其历史记录将一并删除,此操作不可恢复。`, async () => {
|
||||
try { await api("/accounts/" + id, { method: "DELETE" }); toast("账号已删除"); await refresh(); }
|
||||
catch (err2) { toast(err2.message, "err"); }
|
||||
});
|
||||
} else if (act === "check") {
|
||||
btn.disabled = true;
|
||||
try { await api(`/accounts/${id}/check`, { method: "POST" }); toast("已开始检查"); }
|
||||
catch (err2) { toast(err2.message, "err"); btn.disabled = false; }
|
||||
setTimeout(refresh, 1500);
|
||||
if (checkingIds.has(id)) return;
|
||||
checkingIds.add(id);
|
||||
renderAccounts();
|
||||
const before = accounts.find((a) => a.id === id);
|
||||
const prev = before ? before.last_check_at : null;
|
||||
try {
|
||||
await api(`/accounts/${id}/check`, { method: "POST" });
|
||||
} catch (err2) {
|
||||
toast(err2.message, "err");
|
||||
}
|
||||
// 轮询等待本次检查落库(last_check_at 变化),最长 120s
|
||||
const deadline = Date.now() + 120000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
try { await refresh(); } catch (_) { /* 忽略刷新错误 */ }
|
||||
const acc = accounts.find((a) => a.id === id);
|
||||
if (!acc || acc.last_check_at !== prev) break;
|
||||
}
|
||||
checkingIds.delete(id);
|
||||
renderAccounts();
|
||||
const acc = accounts.find((a) => a.id === id);
|
||||
if (acc && acc.last_status === "ok") {
|
||||
const cur = (platforms.find((p) => p.id === acc.platform_id) || {}).currency || "";
|
||||
toast(`检查完成:余额 ${fmtBalance(acc.last_balance)} ${cur}`);
|
||||
}
|
||||
} else if (act === "history") {
|
||||
showHistory(id);
|
||||
} else if (act === "win") {
|
||||
const wrap = btn.closest(".window-switch");
|
||||
const aid = Number(wrap ? wrap.dataset.aid : 0);
|
||||
if (aid) setWindow(aid, btn.dataset.win);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user