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 logging
import secrets
import sqlite3
from contextlib import asynccontextmanager
from pathlib import Path
@@ -65,6 +66,38 @@ def require_auth(authorization: str | None = Header(default=None)) -> None:
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:
cfg = cfg or load_config()
db.init_db()
@@ -279,14 +312,55 @@ def create_app(cfg: Config | None = None) -> FastAPI:
return {"ok": True}
@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:
rows = conn.execute(
"SELECT balance, checked_at FROM balance_history WHERE account_id=? "
"ORDER BY id DESC LIMIT ?",
(aid, limit),
f"SELECT balance, checked_at FROM balance_history WHERE {where} ORDER BY id",
params,
).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 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);
}
}
+12
View File
@@ -54,6 +54,18 @@
<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>
<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="empty-hint" class="empty-hint hidden">
<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; }
.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()
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:
def test_update_settings(self, client, tmp_path):