From d8e9a7b0f09b03b439191804e87959cbbbeabc3d Mon Sep 17 00:00:00 2001 From: kaireewu Date: Sun, 1 Nov 2026 02:14:10 +0800 Subject: [PATCH] feat(web): show cumulative fan range and limit recent records --- reaction_board/web/queries.py | 21 +++++++++++++++++++-- reaction_board/web/server.py | 1 + reaction_board/web/static/views/fans.js | 13 ++++++------- tests/test_web_queries.py | 13 +++++++++++++ tests/test_web_server.py | 9 +++++++++ 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index 382bc87..f2efe07 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -894,9 +894,17 @@ class DashboardQueries: # ------------------------------------------------------------ fans - def fans(self, mid: int | None = None, *, days: int = 90) -> dict[str, Any]: + def fans( + self, + mid: int | None = None, + *, + days: int = 90, + recent_days: int = 14, + ) -> dict[str, Any]: if days < 1: raise ValueError("days 必须大于 0") + if recent_days < 1 or recent_days > 3650: + raise ValueError("recent_days 必须在 1~3650 之间") def run(conn: sqlite3.Connection) -> dict[str, Any]: cfg = self.config_store.get() @@ -916,7 +924,8 @@ class DashboardQueries: return { "mid": None, "name": None, "available": False, "options": options, "dates": [], "series": [], - "points": [], "days": days, + "points": [], "recent_points": [], "days": days, + "recent_days": recent_days, } rows = [ dict(row) for row in conn.execute( @@ -933,10 +942,16 @@ class DashboardQueries: "dates": [], "series": [], "points": [], + "recent_points": [], "days": days, + "recent_days": recent_days, } latest = max(str(row["stat_date"]) for row in rows if int(row["ok"]) == 1) if any(int(row["ok"]) == 1 for row in rows) else str(rows[-1]["stat_date"]) first_date = str(rows[0]["stat_date"]) + recent_start = _date_minus(latest, recent_days - 1) + recent_points = [ + row for row in rows if str(row["stat_date"]) >= recent_start + ] start = max(_date_minus(latest, days - 1), first_date) dates = _date_range(start, latest) by_date = {str(row["stat_date"]): row for row in rows} @@ -973,6 +988,8 @@ class DashboardQueries: "dates": dates, "series": series, "points": rows, + "recent_points": recent_points, + "recent_days": recent_days, "stat_date": current["stat_date"], "follower": current.get("follower"), "following": current.get("following"), diff --git a/reaction_board/web/server.py b/reaction_board/web/server.py index 220e2be..1dd5517 100644 --- a/reaction_board/web/server.py +++ b/reaction_board/web/server.py @@ -365,6 +365,7 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} self._send_json(HTTPStatus.OK, queries.fans( _optional_int(query, "mid"), days=_int_param(query, "days", 90, 1, 3650), + recent_days=_int_param(query, "recent_days", 14, 1, 3650), )) return if path == "/api/candidates": diff --git a/reaction_board/web/static/views/fans.js b/reaction_board/web/static/views/fans.js index 9e54273..4e12f8b 100644 --- a/reaction_board/web/static/views/fans.js +++ b/reaction_board/web/static/views/fans.js @@ -2,7 +2,7 @@ import { api } from "../api.js"; import { centerActiveChips, emptyState, sectionHeader } from "../components.js"; import { lineChart, themeColor } from "../charts.js"; import { - escapeAttr, escapeHtml, formatCount, formatDate, formatDelta, + escapeHtml, formatCount, formatDelta, formatNumber, } from "../format.js"; import { replaceQuery } from "../router.js"; @@ -36,8 +36,7 @@ export async function renderFans(root, { query }) {
日增${fans.delta_1d === null ? "—" : formatDelta(fans.delta_1d)}
7 日净增${fans.delta_7d === null ? "—" : formatDelta(fans.delta_7d)}
-
累计增长${formatDelta(fans.delta_cum)}
-
关注数${formatCount(fans.following)}
+
累计增长(${escapeHtml(fans.first_date || "—")} 以来)${formatDelta(fans.delta_cum)}
@@ -52,15 +51,15 @@ export async function renderFans(root, { query }) {
${sectionHeader("最近记录")} +

默认展示最近 ${fans.recent_days || 14} 天;更早的数据只在曲线中显示。

- + - ${(fans.points || []).slice().reverse().map((point) => ` + ${(fans.recent_points || []).slice().reverse().map((point) => ` - - + `).join("")} diff --git a/tests/test_web_queries.py b/tests/test_web_queries.py index 2410d37..788e17c 100644 --- a/tests/test_web_queries.py +++ b/tests/test_web_queries.py @@ -213,6 +213,19 @@ def test_fans_candidates_system_and_export(): wide = q.fans(days=30) assert wide["dates"] == list(DATES) + # “最近记录”只返回最近 14 天,早于窗口的记录不进入表格 + db.write(lambda conn: conn.execute( + "INSERT OR REPLACE INTO up_stat" + "(stat_date, mid, follower, following, captured_at, ok) " + "VALUES (?, ?, ?, NULL, 1, 1)", + ("2026-10-01", 37754047, 900), + )) + recent = q.fans(days=90) + assert recent["recent_days"] == 14 + assert [point["stat_date"] for point in recent["recent_points"]] == list(DATES) + one_day = q.fans(days=90, recent_days=1) + assert [point["stat_date"] for point in one_day["recent_points"]] == [DATES[-1]] + # 缺失前一天时不能把跨多天的变化标成“日增” db.write(lambda conn: conn.execute( "DELETE FROM up_stat WHERE mid=37754047 AND stat_date=?", diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 2f512c4..7d67261 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -102,6 +102,15 @@ def test_readonly_routes_and_static(): assert status == 200 assert json.loads(payload)["data"]["sort"] == "avg_views" + status, _, payload = running.request("/api/fans?days=7&recent_days=14") + assert status == 200 + fans = json.loads(payload)["data"] + assert fans["recent_days"] == 14 + assert all( + point["stat_date"] >= "2026-10-18" + for point in fans["recent_points"] + ) + status, headers, payload = running.request("/api/videos", method="HEAD") assert status == 200 and payload == b"" assert "Content-Length" in headers
日期粉丝关注状态
日期粉丝状态
${escapeHtml(point.stat_date)}${point.ok ? formatCount(point.follower) : "—"}${point.ok ? formatCount(point.following) : "—"}${point.ok ? formatNumber(point.follower) : "—"} ${point.ok ? "成功" : "失败"}