feat: 账号卡片余额趋势折线图 + 时间窗口切换(all/1h/1d/1w/1mo/1yr) - api: history 接口支持 window 参数与窗口内均匀降采样;新增批量 /api/history - ui: 阈值进度条替换为 SVG 迷你折线图(状态色 + 阈值虚线),卡片级窗口切换器 - test: 窗口过滤、降采样、批量历史用例,共 54 个
This commit is contained in:
+79
-5
@@ -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
|
||||
|
||||
# ---------- 设置 ----------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user