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
|
||||
|
||||
# ---------- 设置 ----------
|
||||
|
||||
|
||||
+183
-22
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user