diff --git a/app/api.py b/app/api.py
index b654f3e..f0ba78b 100644
--- a/app/api.py
+++ b/app/api.py
@@ -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
# ---------- 设置 ----------
diff --git a/app/static/app.js b/app/static/app.js
index a372c3f..ec00b25 100644
--- a/app/static/app.js
+++ b/app/static/app.js
@@ -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: '',
+ clock: '',
+ pencil: '',
+ trash: '',
+ alert: '',
+ };
+
/* 容错 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) =>
+ ``
+ ).join("");
+ const checkBtn = checking
+ ? ``
+ : ``;
return `
-
+
${escapeHtml(b.abbr)}
${escapeHtml(plat.name || "未知平台")} · ${escapeHtml(plat.currency || "")}
${escapeHtml(a.name)}
+
${st.text}
${bal !== null
? `${bal}${escapeHtml(plat.currency || "")}`
: `—`}
+ ${th !== null ? `/ 阈值 ${th}` : ""}
-
+ ${spark
+ ? `
+
余额历史 · ${WINDOW_LABELS[win]}(${hist.length} 条${th !== null ? `,虚线阈值 ${th}` : ""})
+ ${spark}
+
${winBtns}
+
`
+ : ""}
- ${st.text}
- 上次检查:${fmtTime(a.last_check_at)}
- ${a.last_error ? `${escapeHtml(a.last_error)}` : ""}
+ ${ICONS.clock}上次 ${fmtTime(a.last_check_at)}
+ ${ICONS.refresh}每 ${plat.interval_seconds || (settings ? settings.global_interval_seconds : 300)}s
+ ${a.last_error ? `
${ICONS.alert}${escapeHtml(a.last_error)}
` : ""}
-
-
-
-
+ ${checkBtn}
+
+
+
+
`;
}
+ 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 = `
`;
+ }
+ if (vals.length === 1) {
+ const x = W / 2, y = yOf(vals[0]);
+ return `
`;
+ }
+ const pts = vals.map((v, i) => {
+ const x = (i / (vals.length - 1)) * W;
+ return `${x.toFixed(1)},${yOf(v)}`;
+ });
+ return `
`;
+ }
+
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 = ['
'].concat(
+ platforms.map((p) => `
`)
+ ).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 = '
没有符合条件的账号,试试调整筛选条件。
';
+ }
} 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);
}
}
diff --git a/app/static/index.html b/app/static/index.html
index f99aedc..7144e13 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -54,6 +54,18 @@
0
0
+
+
+
+
+
+
+
+
+
+
还没有账号。先到「平台」页添加一个平台,再回来添加账号。
diff --git a/app/static/style.css b/app/static/style.css
index 8a3d820..6ecbc5b 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -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); }
diff --git a/tests/test_api.py b/tests/test_api.py
index 811a1c2..a817a2b 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -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):