feat: 账号卡片余额趋势折线图 + 时间窗口切换(all/1h/1d/1w/1mo/1yr) - api: history 接口支持 window 参数与窗口内均匀降采样;新增批量 /api/history - ui: 阈值进度条替换为 SVG 迷你折线图(状态色 + 阈值虚线),卡片级窗口切换器 - test: 窗口过滤、降采样、批量历史用例,共 54 个

This commit is contained in:
2026-08-05 00:32:22 +08:00
parent edf19e121b
commit 1475821519
5 changed files with 504 additions and 27 deletions
+79 -5
View File
@@ -6,6 +6,7 @@ import base64
import json import json
import logging import logging
import secrets import secrets
import sqlite3
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -65,6 +66,38 @@ def require_auth(authorization: str | None = Header(default=None)) -> None:
raise HTTPException(status_code=401, detail="登录已失效") raise HTTPException(status_code=401, detail="登录已失效")
WINDOW_OFFSETS = {
"1h": "-1 hour",
"1d": "-1 day",
"1w": "-7 days",
"1mo": "-1 month",
"1yr": "-1 year",
}
def _window_time(window: str) -> str | None:
"""把窗口键转成 SQLite 时间边界(localtime 字符串)。未知窗口返回 None。"""
offset = WINDOW_OFFSETS.get(window)
if offset is None:
return None
with db.get_conn() as conn:
row = conn.execute(
"SELECT datetime('now', 'localtime', ?) AS t", (offset,)
).fetchone()
return row["t"]
def _downsample(rows: list[sqlite3.Row], limit: int) -> list[sqlite3.Row]:
"""按索引均匀抽样到 limit 条,保证首尾尽量覆盖。"""
if len(rows) <= limit:
return rows
step = len(rows) / limit
picked = [rows[int(i * step)] for i in range(limit)]
if picked[-1] is not rows[-1]:
picked[-1] = rows[-1]
return picked
def create_app(cfg: Config | None = None) -> FastAPI: def create_app(cfg: Config | None = None) -> FastAPI:
cfg = cfg or load_config() cfg = cfg or load_config()
db.init_db() db.init_db()
@@ -279,14 +312,55 @@ def create_app(cfg: Config | None = None) -> FastAPI:
return {"ok": True} return {"ok": True}
@app.get("/api/accounts/{aid}/history", dependencies=[Depends(require_auth)]) @app.get("/api/accounts/{aid}/history", dependencies=[Depends(require_auth)])
def account_history(aid: int, limit: int = Query(default=30, ge=1, le=200)): def account_history(
aid: int,
limit: int = Query(default=120, ge=2, le=500),
window: str = Query(default="all", pattern="^(all|1h|1d|1w|1mo|1yr)$"),
):
where = "account_id=?"
params: list = [aid]
t0 = _window_time(window)
if t0 is not None:
where += " AND checked_at >= ?"
params.append(t0)
with db.get_conn() as conn: with db.get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT balance, checked_at FROM balance_history WHERE account_id=? " f"SELECT balance, checked_at FROM balance_history WHERE {where} ORDER BY id",
"ORDER BY id DESC LIMIT ?", params,
(aid, limit),
).fetchall() ).fetchall()
return [dict(r) for r in reversed(rows)] return [
{"balance": r["balance"], "checked_at": r["checked_at"]}
for r in _downsample(rows, limit)
]
@app.get("/api/history", dependencies=[Depends(require_auth)])
def all_history(
limit: int = Query(default=30, ge=2, le=200),
window: str = Query(default="all", pattern="^(all|1h|1d|1w|1mo|1yr)$"),
):
"""批量返回所有账号的余额历史(窗口内均匀降采样),供监控页趋势图一次拉取。"""
where = "1=1"
params: list = []
t0 = _window_time(window)
if t0 is not None:
where += " AND checked_at >= ?"
params.append(t0)
with db.get_conn() as conn:
rows = conn.execute(
f"""SELECT account_id, balance, checked_at FROM (
SELECT h.*, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY id) AS rn,
COUNT(*) OVER (PARTITION BY account_id) AS total
FROM balance_history h WHERE {where}
) WHERE rn = 1 OR rn = total OR rn % MAX(1, total / ?) = 0
ORDER BY account_id, rn""",
(*params, max(limit, 1)),
).fetchall()
result: dict[int, list] = {}
for r in rows:
result.setdefault(r["account_id"], []).append(
{"balance": r["balance"], "checked_at": r["checked_at"]}
)
return result
# ---------- 设置 ---------- # ---------- 设置 ----------
+183 -22
View File
@@ -8,6 +8,18 @@
let platforms = []; let platforms = [];
let settings = null; let settings = null;
let refreshTimer = 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; 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 */ /* 容错 JSON 解析:允许用户写 Python 风格的单引号;失败返回 null */
function parseJsonInput(text, label, errorId) { function parseJsonInput(text, label, errorId) {
let s = (text || "").trim(); let s = (text || "").trim();
@@ -120,9 +141,10 @@
async function refresh() { async function refresh() {
try { 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; accounts = acc;
platforms = plat; platforms = plat;
historyCache = hist || {};
renderAccounts(); renderAccounts();
if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms(); if (!document.getElementById("view-platforms").classList.contains("hidden")) renderPlatforms();
const d = new Date(); const d = new Date();
@@ -155,41 +177,115 @@
const bal = fmtBalance(a.last_balance); const bal = fmtBalance(a.last_balance);
const th = fmtBalance(a.threshold); const th = fmtBalance(a.threshold);
const below = a.last_status === "ok" && bal !== null && Number(a.last_balance) < 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" : "", const checking = checkingIds.has(a.id);
st.cls === "disabled" ? "disabled" : ""].join(" ").trim(); const cardCls = ["account-card",
const pct = th && bal !== null ? Math.min(100, (Number(a.last_balance) / Number(th)) * 100) : 100; st.cls === "warn" ? "below" : "",
const fillCls = below ? "threshold-fill below" : "threshold-fill"; 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 ` return `
<div class="${cardCls}" style="animation-delay:${Math.min(idx, 12) * 40}ms"> <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="badge" style="background:${b.color}">${escapeHtml(b.abbr)}</div>
<div class="card-title"> <div class="card-title">
<div class="card-platform">${escapeHtml(plat.name || "未知平台")} · ${escapeHtml(plat.currency || "")}</div> <div class="card-platform">${escapeHtml(plat.name || "未知平台")} · ${escapeHtml(plat.currency || "")}</div>
<div class="card-name" title="${escapeHtml(a.name)}">${escapeHtml(a.name)}</div> <div class="card-name" title="${escapeHtml(a.name)}">${escapeHtml(a.name)}</div>
</div> </div>
<span class="badge-pill ${st.cls}">${st.text}</span>
</div> </div>
<div class="balance-row"> <div class="balance-row">
${bal !== null ${bal !== null
? `<span class="balance-value ${below ? "below" : ""}">${bal}</span><span class="balance-currency">${escapeHtml(plat.currency || "")}</span>` ? `<span class="balance-value ${below ? "below" : ""}">${bal}</span><span class="balance-currency">${escapeHtml(plat.currency || "")}</span>`
: `<span class="balance-empty">—</span>`} : `<span class="balance-empty">—</span>`}
${th !== null ? `<span class="balance-vs">/ 阈值 ${th}</span>` : ""}
</div> </div>
<div class="threshold-bar" title="阈值 ${th ?? "—"} ${escapeHtml(plat.currency || "")}"> ${spark
<div class="${fillCls}" style="width:${bal === null ? 0 : pct}%"></div> ? `<div class="spark-wrap">
</div> <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"> <div class="card-meta">
<span class="badge-pill ${st.cls}">${st.text}</span> <span class="meta-item">${ICONS.clock}上次 ${fmtTime(a.last_check_at)}</span>
<span>上次检查:${fmtTime(a.last_check_at)}</span> <span class="meta-item">${ICONS.refresh}${plat.interval_seconds || (settings ? settings.global_interval_seconds : 300)}s</span>
${a.last_error ? `<span style="color:var(--err)">${escapeHtml(a.last_error)}</span>` : ""}
</div> </div>
${a.last_error ? `<div class="card-error" title="${escapeHtml(a.last_error)}">${ICONS.alert}${escapeHtml(a.last_error)}</div>` : ""}
<div class="card-actions"> <div class="card-actions">
<button class="btn sm" data-act="history" data-id="${a.id}">历史</button> ${checkBtn}
<button class="btn sm" data-act="check" data-id="${a.id}" ${!a.enabled ? "disabled" : ""}>立即检查</button> <button class="btn sm icon-btn" data-act="history" data-id="${a.id}" title="余额历史">${ICONS.clock}</button>
<button class="btn sm" data-act="edit" data-id="${a.id}">编辑</button> <button class="btn sm icon-btn" data-act="edit" data-id="${a.id}" title="编辑账号">${ICONS.pencil}</button>
<button class="btn sm danger" data-act="del" data-id="${a.id}">删除</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>
</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() { function renderAccounts() {
const grid = document.getElementById("account-grid"); const grid = document.getElementById("account-grid");
const stats = { total: accounts.length, ok: 0, below: 0, error: 0, disabled: 0, pending: 0 }; 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-disabled").textContent = stats.disabled;
document.getElementById("stat-pending").textContent = stats.pending; 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"); const hint = document.getElementById("empty-hint");
if (accounts.length > 0) { if (accounts.length > 0) {
hint.classList.add("hidden"); hint.classList.add("hidden");
if (list.length === 0) {
grid.innerHTML = '<div class="empty-filter">没有符合条件的账号,试试调整筛选条件。</div>';
}
} else { } else {
hint.classList.remove("hidden"); hint.classList.remove("hidden");
hint.innerHTML = platforms.length === 0 hint.innerHTML = platforms.length === 0
@@ -469,6 +586,18 @@
document.getElementById("save-settings-btn").onclick = saveSettings; document.getElementById("save-settings-btn").onclick = saveSettings;
document.getElementById("change-pwd-btn").onclick = changePassword; 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("account-grid").addEventListener("click", onAccountAction);
document.getElementById("platform-list").addEventListener("click", onPlatformAction); document.getElementById("platform-list").addEventListener("click", onPlatformAction);
} }
@@ -512,19 +641,51 @@
const id = Number(btn.dataset.id); const id = Number(btn.dataset.id);
const act = btn.dataset.act; const act = btn.dataset.act;
if (act === "edit") accountForm(accounts.find((a) => a.id === id)); 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); const a = accounts.find((x) => x.id === id);
confirmDialog(`确定删除账号「${escapeHtml(a ? a.name : id)}」?其历史记录将一并删除,此操作不可恢复。`, async () => { confirmDialog(`确定删除账号「${escapeHtml(a ? a.name : id)}」?其历史记录将一并删除,此操作不可恢复。`, async () => {
try { await api("/accounts/" + id, { method: "DELETE" }); toast("账号已删除"); await refresh(); } try { await api("/accounts/" + id, { method: "DELETE" }); toast("账号已删除"); await refresh(); }
catch (err2) { toast(err2.message, "err"); } catch (err2) { toast(err2.message, "err"); }
}); });
} else if (act === "check") { } else if (act === "check") {
btn.disabled = true; if (checkingIds.has(id)) return;
try { await api(`/accounts/${id}/check`, { method: "POST" }); toast("已开始检查"); } checkingIds.add(id);
catch (err2) { toast(err2.message, "err"); btn.disabled = false; } renderAccounts();
setTimeout(refresh, 1500); 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") { } else if (act === "history") {
showHistory(id); 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);
} }
} }
+12
View File
@@ -54,6 +54,18 @@
<div class="stat disabled"><span id="stat-disabled">0</span><label>已禁用</label></div> <div class="stat disabled"><span id="stat-disabled">0</span><label>已禁用</label></div>
<div class="stat pending"><span id="stat-pending">0</span><label>待检查</label></div> <div class="stat pending"><span id="stat-pending">0</span><label>待检查</label></div>
</div> </div>
<div class="filter-bar">
<div class="filter-pills" id="filter-pills">
<button class="pill active" data-filter="all">全部</button>
<button class="pill warn" data-filter="below">低于阈值</button>
<button class="pill err" data-filter="error">异常</button>
<button class="pill pending" data-filter="pending">待检查</button>
<button class="pill disabled" data-filter="disabled">已禁用</button>
</div>
<select id="filter-platform" class="filter-select" title="按平台筛选">
<option value="0">全部平台</option>
</select>
</div>
<div id="account-grid" class="account-grid"></div> <div id="account-grid" class="account-grid"></div>
<div id="empty-hint" class="empty-hint hidden"> <div id="empty-hint" class="empty-hint hidden">
<p>还没有账号。先到「平台」页添加一个平台,再回来添加账号。</p> <p>还没有账号。先到「平台」页添加一个平台,再回来添加账号。</p>
+180
View File
@@ -407,3 +407,183 @@ label { font-size: 12px; font-weight: 500; color: var(--text-2); margin-bottom:
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } * { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
.account-card { opacity: 1; transform: none; } .account-card { opacity: 1; transform: none; }
} }
/* ===== 账号监控:筛选栏 ===== */
.filter-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.filter-pills { display: flex; gap: 6px; flex-wrap: wrap; }
.pill {
font-family: inherit;
font-size: 12px;
font-weight: 500;
padding: 5px 13px;
border-radius: 999px;
border: 1px solid var(--border-strong);
background: var(--bg-elev);
color: var(--text-2);
cursor: pointer;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease, transform 120ms var(--ease-out);
}
.pill:hover { color: var(--text); border-color: var(--text-3); }
.pill:active { transform: scale(0.96); }
.pill.active { background: var(--accent); border-color: transparent; color: #fff; }
.pill.warn.active { background: var(--warn); }
.pill.err.active { background: var(--err); }
.pill.pending.active { background: var(--text-2); }
.pill.disabled.active { background: var(--text-3); }
.filter-select { width: auto; min-width: 130px; }
/* ===== 账号卡片:重构 ===== */
.card-head { display: flex; align-items: center; gap: 10px; }
.card-head .card-title { flex: 1; min-width: 0; }
.card-head .badge-pill { margin-left: auto; flex-shrink: 0; }
.balance-vs {
margin-left: auto;
font-size: 11px;
color: var(--text-3);
white-space: nowrap;
}
.card-meta { display: flex; flex-direction: column; gap: 3px; }
.meta-item { display: inline-flex; align-items: center; gap: 5px; }
.meta-item svg { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }
.card-error {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--err);
background: var(--err-soft);
padding: 4px 8px;
border-radius: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-error svg { width: 11px; height: 11px; stroke: currentColor; fill: none; stroke-width: 2; flex-shrink: 0; }
/* 阈值条(新名,旧 .threshold-bar 保留) */
.threshold-track {
height: 4px;
border-radius: 2px;
background: var(--bg-elev-2);
overflow: hidden;
}
/* 操作区 */
.card-actions { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.card-actions .btn { display: inline-flex; align-items: center; gap: 5px; }
.card-actions .btn svg {
width: 13px; height: 13px;
stroke: currentColor; fill: none; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round;
flex-shrink: 0;
}
.icon-btn { padding: 6px; line-height: 0; }
.icon-btn.danger:hover { color: var(--err); background: var(--err-soft); }
/* 检查按钮 loading 态 */
.check-btn.loading { pointer-events: none; opacity: 0.75; }
.spinner {
width: 11px; height: 11px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
display: inline-block;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 启用/停用开关 */
.switch {
position: relative;
width: 36px; height: 20px;
flex-shrink: 0;
margin-left: auto;
cursor: pointer;
}
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.switch .slider {
position: absolute; inset: 0;
background: var(--bg-elev-2);
border: 1px solid var(--border-strong);
border-radius: 999px;
transition: background 180ms ease, border-color 180ms ease;
}
.switch .slider::before {
content: "";
position: absolute;
width: 14px; height: 14px;
border-radius: 50%;
left: 2px; top: 2px;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
transition: transform 180ms var(--ease-out);
}
.switch input:checked + .slider { background: var(--ok); border-color: transparent; }
.switch input:checked + .slider::before { transform: translateX(16px); }
.switch:active .slider::before { transform: scale(0.9); }
.switch input:checked:active + .slider::before { transform: translateX(16px) scale(0.9); }
/* 筛选空结果 */
.empty-filter {
text-align: center;
color: var(--text-3);
padding: 48px 0;
font-size: 13px;
}
/* 余额历史迷你折线图 */
.spark-wrap {
background: var(--bg-elev-2);
border-radius: 6px;
padding: 6px 8px 4px;
overflow: hidden;
}
.sparkline {
display: block;
width: 100%;
height: 30px;
}
.spark-title {
font-size: 10px;
color: var(--text-3);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.window-switch {
display: flex;
gap: 2px;
justify-content: flex-end;
margin-top: 3px;
}
.win-btn {
font-family: inherit;
font-size: 9.5px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 1px 5px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
transition: color 120ms ease, background 120ms ease, transform 100ms var(--ease-out);
}
.win-btn:hover { color: var(--text); background: var(--bg-elev-2); }
.win-btn:active { transform: scale(0.92); }
.win-btn.active { color: var(--accent); background: var(--accent-soft); }
/* 禁用卡片:原因提示保留可读性 */
.account-card.disabled .balance-value { color: var(--text-3); }
.account-card.pending { border-color: var(--border-strong); }
+50
View File
@@ -144,6 +144,56 @@ class TestAccounts:
hist = client.get(f"/api/accounts/{aid}/history", headers=h).json() hist = client.get(f"/api/accounts/{aid}/history", headers=h).json()
assert [x["balance"] for x in hist] == [1.5, 2.5] assert [x["balance"] for x in hist] == [1.5, 2.5]
def test_batch_history(self, client, test_db):
from app import db
h = _auth(client)
pid = self._make_platform(client, h)
a1 = client.post("/api/accounts", json={"platform_id": pid, "name": "x", "api_key": "k"}, headers=h).json()["id"]
a2 = client.post("/api/accounts", json={"platform_id": pid, "name": "y", "api_key": "k2"}, headers=h).json()["id"]
with db.get_conn() as conn:
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (a1, 1.0))
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (a1, 2.0))
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (a2, 9.0))
data = client.get("/api/history?limit=30", headers=h).json()
assert [x["balance"] for x in data[str(a1)]] == [1.0, 2.0]
assert [x["balance"] for x in data[str(a2)]] == [9.0]
assert len(data) == 2
def test_history_window_filter(self, client, test_db):
from app import db
h = _auth(client)
pid = self._make_platform(client, h)
aid = client.post("/api/accounts", json={"platform_id": pid, "name": "x", "api_key": "k"}, headers=h).json()["id"]
with db.get_conn() as conn:
conn.execute(
"INSERT INTO balance_history (account_id, balance, checked_at) VALUES (?, ?, datetime('now','localtime','-30 days'))",
(aid, 100.0),
)
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (aid, 1.0))
all_rows = client.get(f"/api/accounts/{aid}/history", headers=h).json()
assert len(all_rows) == 2
day_rows = client.get(f"/api/accounts/{aid}/history?window=1d", headers=h).json()
assert len(day_rows) == 1 and day_rows[0]["balance"] == 1.0
yr_rows = client.get(f"/api/accounts/{aid}/history?window=1yr", headers=h).json()
assert len(yr_rows) == 2
def test_history_downsample(self, client, test_db):
from app import db
h = _auth(client)
pid = self._make_platform(client, h)
aid = client.post("/api/accounts", json={"platform_id": pid, "name": "x", "api_key": "k"}, headers=h).json()["id"]
with db.get_conn() as conn:
for i in range(50):
conn.execute("INSERT INTO balance_history (account_id, balance) VALUES (?, ?)", (aid, i))
rows = client.get(f"/api/accounts/{aid}/history?limit=10", headers=h).json()
assert len(rows) == 10
assert rows[0]["balance"] == 0 and rows[-1]["balance"] == 49 # 首尾保留
# 非法窗口参数被拒
assert client.get(f"/api/accounts/{aid}/history?window=2h", headers=h).status_code == 422
class TestSettings: class TestSettings:
def test_update_settings(self, client, tmp_path): def test_update_settings(self, client, tmp_path):