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 `${thLine}`; + } + const pts = vals.map((v, i) => { + const x = (i / (vals.length - 1)) * W; + return `${x.toFixed(1)},${yOf(v)}`; + }); + return `${thLine}`; + } + 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
+
+
+ + + + + +
+ +