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
# ---------- 设置 ----------