From 274eccba9306098080cc1ab43eec961038c16b97 Mon Sep 17 00:00:00 2001 From: kaireewu Date: Sun, 1 Nov 2026 02:36:48 +0800 Subject: [PATCH] feat(web): add fan date range modes and delta column --- reaction_board/web/queries.py | 135 +++++++++++++++++++++--- reaction_board/web/server.py | 2 + reaction_board/web/static/style.css | 19 ++++ reaction_board/web/static/views/fans.js | 48 +++++++-- tests/test_web_queries.py | 51 +++++++++ tests/test_web_server.py | 20 ++++ 6 files changed, 253 insertions(+), 22 deletions(-) diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index f2efe07..c813e70 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -7,6 +7,7 @@ from __future__ import annotations +import calendar import json import math import sqlite3 @@ -89,6 +90,84 @@ def _safe_float(value: Any) -> float | None: return None +def _with_fan_deltas( + rows: Sequence[Mapping[str, Any]], recent_start: str +) -> list[dict[str, Any]]: + """给最近记录附加“相较上一成功日期”的增量。""" + result: list[dict[str, Any]] = [] + last_ok: int | None = None + for row in rows: + day = str(row["stat_date"]) + ok = int(row.get("ok") or 0) == 1 + delta: int | None = None + if ok: + follower = int(row.get("follower") or 0) + if last_ok is not None: + delta = follower - last_ok + last_ok = follower + if day >= recent_start: + result.append({**dict(row), "delta": delta}) + return result + + +def _daily_fan_series( + rows: Sequence[Mapping[str, Any]], start: str, end: str +) -> tuple[list[str], list[int | None]]: + by_date = {str(row["stat_date"]): row for row in rows} + dates = _date_range(start, end) + series: list[int | None] = [] + for day in dates: + row = by_date.get(day) + if row is None or int(row.get("ok") or 0) != 1: + series.append(None) + else: + series.append(int(row["follower"] or 0)) + return dates, series + + +def _monthly_peak_series( + rows: Sequence[Mapping[str, Any]], start: str, end: str +) -> tuple[list[str], list[int | None]]: + """超过一年时按自然月聚合,每月取粉丝峰值所在的日期。""" + start_day = date.fromisoformat(start) + end_day = date.fromisoformat(end) + peaks: dict[tuple[int, int], tuple[str, int]] = {} + for row in rows: + if int(row.get("ok") or 0) != 1: + continue + day = date.fromisoformat(str(row["stat_date"])) + key = (day.year, day.month) + value = int(row["follower"] or 0) + current = peaks.get(key) + if current is None or value > current[1] or ( + value == current[1] and str(row["stat_date"]) > current[0] + ): + peaks[key] = (str(row["stat_date"]), value) + + dates: list[str] = [] + series: list[int | None] = [] + year, month = start_day.year, start_day.month + while (year, month) <= (end_day.year, end_day.month): + peak = peaks.get((year, month)) + if peak is not None: + dates.append(peak[0]) + series.append(peak[1]) + else: + last_day = calendar.monthrange(year, month)[1] + placeholder = date(year, month, last_day) + if placeholder < start_day: + placeholder = start_day + if placeholder > end_day: + placeholder = end_day + dates.append(placeholder.isoformat()) + series.append(None) + month += 1 + if month > 12: + month = 1 + year += 1 + return dates, series + + class DashboardQueries: def __init__( self, @@ -899,12 +978,22 @@ class DashboardQueries: mid: int | None = None, *, days: int = 90, + start: str | None = None, + end: str | None = None, 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 之间") + for label, value in (("start", start), ("end", end)): + if value: + try: + date.fromisoformat(value) + except ValueError as exc: + raise ValueError(f"{label} 必须是 YYYY-MM-DD") from exc + if start and end and start > end: + raise ValueError("start 不能晚于 end") def run(conn: sqlite3.Connection) -> dict[str, Any]: cfg = self.config_store.get() @@ -925,7 +1014,8 @@ class DashboardQueries: "mid": None, "name": None, "available": False, "options": options, "dates": [], "series": [], "points": [], "recent_points": [], "days": days, - "recent_days": recent_days, + "mode": "daily", "range_start": None, "range_end": None, + "span_days": 0, "recent_days": recent_days, } rows = [ dict(row) for row in conn.execute( @@ -944,24 +1034,35 @@ class DashboardQueries: "points": [], "recent_points": [], "days": days, - "recent_days": recent_days, + "mode": "daily", "range_start": None, "range_end": None, + "span_days": 0, "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 + recent_points = _with_fan_deltas(rows, recent_start) + if start or end: + range_start = max(str(start or first_date), first_date) + range_end = min(str(end or latest), latest) + if range_start > range_end: + raise ValueError("所选日期范围内没有粉丝数据") + else: + range_start = max(_date_minus(latest, days - 1), first_date) + range_end = latest + span_days = ( + date.fromisoformat(range_end) - date.fromisoformat(range_start) + ).days + 1 + mode = "daily" if span_days <= 366 else "monthly" + points = [ + row for row in rows + if range_start <= str(row["stat_date"]) <= range_end ] - 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} - series = [] - for day in dates: - row = by_date.get(day) - if row is None or int(row["ok"]) != 1: - series.append(None) - else: - series.append(row["follower"]) + if mode == "daily": + dates, series = _daily_fan_series(points, range_start, range_end) + else: + dates, series = _monthly_peak_series( + points, range_start, range_end + ) ok_rows = [row for row in rows if int(row["ok"]) == 1] current = ok_rows[-1] if ok_rows else rows[-1] previous = ok_rows[-2] if len(ok_rows) >= 2 else None @@ -985,9 +1086,13 @@ class DashboardQueries: "available": bool(ok_rows), "options": options, "days": days, + "mode": mode, + "range_start": range_start, + "range_end": range_end, + "span_days": span_days, "dates": dates, "series": series, - "points": rows, + "points": points, "recent_points": recent_points, "recent_days": recent_days, "stat_date": current["stat_date"], diff --git a/reaction_board/web/server.py b/reaction_board/web/server.py index 1dd5517..6b38463 100644 --- a/reaction_board/web/server.py +++ b/reaction_board/web/server.py @@ -365,6 +365,8 @@ 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), + start=_optional_str(query, "start"), + end=_optional_str(query, "end"), recent_days=_int_param(query, "recent_days", 14, 1, 3650), )) return diff --git a/reaction_board/web/static/style.css b/reaction_board/web/static/style.css index 4933fbd..a79de13 100644 --- a/reaction_board/web/static/style.css +++ b/reaction_board/web/static/style.css @@ -531,6 +531,24 @@ h1, h2, h3, p { margin: 0; } .filter-row .select { width: 100%; max-width: none; } } .growth-days { padding-top: 2px; } +.date-range { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 8px; +} +.date-range input[type="date"] { + min-height: 38px; + padding: 8px 11px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: inherit; + font: inherit; + font-size: 13px; +} +.date-range span { color: var(--muted); font-size: 12px; } .rank-list { display: flex; flex-direction: column; gap: 8px; } .pager { @@ -632,6 +650,7 @@ h1, h2, h3, p { margin: 0; } .data-table th { position: sticky; top: 0; background: var(--surface-2); color: var(--muted); font-size: 11px; font-weight: 600; } .data-table tr:last-child td { border-bottom: 0; } .data-table .failed-row td { color: var(--muted); background: color-mix(in srgb, var(--danger) 5%, transparent); } +.fans-page .data-table { min-width: 0; } .summary-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; } .hero-number { diff --git a/reaction_board/web/static/views/fans.js b/reaction_board/web/static/views/fans.js index 4e12f8b..8262957 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 { - escapeHtml, formatCount, formatDelta, formatNumber, + escapeAttr, escapeHtml, formatCount, formatDelta, formatNumber, } from "../format.js"; import { replaceQuery } from "../router.js"; @@ -11,7 +11,15 @@ const RANGES = [["7", "7 天"], ["30", "30 天"], ["90", "90 天"], ["3650", " export async function renderFans(root, { query }) { const days = query.get("days") || "90"; const mid = query.get("mid"); - const fans = await api("/api/fans", { days, mid }); + const start = query.get("start") || ""; + const end = query.get("end") || ""; + const customRange = Boolean(start || end); + const fans = await api("/api/fans", { + days: customRange ? null : days, + start, + end, + mid, + }); const options = fans.options || []; root.innerHTML = `
@@ -43,10 +51,22 @@ export async function renderFans(root, { query }) { ${sectionHeader("粉丝曲线")}
${RANGES.map(([value, label]) => ``).join("")} + class="chip${!customRange && days === value ? " active" : ""}" data-days="${value}">${label}`).join("")}
+
+ + 至 + + + ${customRange ? `` : ""} +
-

只绘制粉丝数,不叠加 reaction 视频数据;缺失日断开,不插值。

+

+ 只绘制粉丝数,不叠加 reaction 视频数据;缺失日断开,不插值。 + ${fans.mode === "monthly" + ? "当前范围超过一年,按每月粉丝峰值所在日期绘制。" + : `当前按日绘制(${escapeHtml(fans.range_start || "—")} ~ ${escapeHtml(fans.range_end || "—")})。`} +

@@ -54,13 +74,13 @@ export async function renderFans(root, { query }) {

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

- + ${(fans.recent_points || []).slice().reverse().map((point) => ` - + `).join("")}
日期粉丝状态
日期粉丝较上一成功日
${escapeHtml(point.stat_date)} ${point.ok ? formatNumber(point.follower) : "—"}${point.ok ? "成功" : "失败"}${point.delta === null || point.delta === undefined ? "—" : formatDelta(point.delta)}
@@ -69,11 +89,25 @@ export async function renderFans(root, { query }) {
`; root.querySelectorAll("[data-days]").forEach((button) => button.addEventListener("click", () => { - replaceQuery({ days: button.dataset.days }); + replaceQuery({ days: button.dataset.days, start: null, end: null }); })); root.querySelector("#fan-mid")?.addEventListener("change", (event) => { replaceQuery({ mid: event.target.value }); }); + root.querySelector("#fan-range")?.addEventListener("submit", (event) => { + event.preventDefault(); + const form = new FormData(event.target); + const nextStart = String(form.get("start") || "").trim(); + const nextEnd = String(form.get("end") || "").trim(); + if (nextStart && nextEnd && nextStart > nextEnd) { + window.alert("开始日期不能晚于结束日期"); + return; + } + replaceQuery({ start: nextStart || null, end: nextEnd || null, days: null }); + }); + root.querySelector("#fan-range-clear")?.addEventListener("click", () => { + replaceQuery({ start: null, end: null, days: null }); + }); centerActiveChips(root); const chartElement = root.querySelector("#fans-chart"); if (chartElement) { diff --git a/tests/test_web_queries.py b/tests/test_web_queries.py index 788e17c..05215cc 100644 --- a/tests/test_web_queries.py +++ b/tests/test_web_queries.py @@ -197,6 +197,52 @@ def test_up_queries(): db.close() +def test_fans_custom_range_modes(): + with tempfile.TemporaryDirectory() as tmp: + db, q = setup(tmp) + try: + daily = q.fans(start="2026-10-29", end="2026-10-31") + assert daily["mode"] == "daily" + assert daily["range_start"] == "2026-10-29" + assert daily["range_end"] == "2026-10-31" + assert daily["dates"] == list(DATES) + assert daily["series"] == [1000, 1010, 1020] + + # 超过一年触发按月峰值模式 + db.write(lambda conn: conn.execute( + "INSERT OR REPLACE INTO up_stat" + "(stat_date, mid, follower, following, captured_at, ok) " + "VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)" + )) + monthly = q.fans(start="2025-01-01", end="2026-10-31") + assert monthly["mode"] == "monthly" + assert monthly["span_days"] == 669 + assert monthly["dates"][0] == "2025-01-01" + assert monthly["series"][0] == 500 + assert monthly["dates"][-1] == "2026-10-31" + assert monthly["series"][-1] == 1020 + + # 同月多条时取峰值日期,并优先较晚日期 + db.write(lambda conn: conn.execute( + "INSERT OR REPLACE INTO up_stat" + "(stat_date, mid, follower, following, captured_at, ok) " + "VALUES ('2026-10-15', 37754047, 5000, NULL, 1, 1), " + "('2026-10-16', 37754047, 5000, NULL, 1, 1)" + )) + monthly = q.fans(start="2025-01-01", end="2026-10-31") + assert monthly["dates"][-1] == "2026-10-16" + assert monthly["series"][-1] == 5000 + + try: + q.fans(start="2026-11-01", end="2026-10-01") + except ValueError: + pass + else: + raise AssertionError("start 晚于 end 应报错") + finally: + db.close() + + def test_fans_candidates_system_and_export(): with tempfile.TemporaryDirectory() as tmp: db, q = setup(tmp) @@ -223,6 +269,10 @@ def test_fans_candidates_system_and_export(): recent = q.fans(days=90) assert recent["recent_days"] == 14 assert [point["stat_date"] for point in recent["recent_points"]] == list(DATES) + deltas = {point["stat_date"]: point["delta"] for point in recent["recent_points"]} + assert deltas["2026-10-29"] == 100 # 对比 2026-10-01 的 900 + assert deltas["2026-10-30"] == 10 + assert deltas["2026-10-31"] == 10 one_day = q.fans(days=90, recent_days=1) assert [point["stat_date"] for point in one_day["recent_points"]] == [DATES[-1]] @@ -262,6 +312,7 @@ def main_test(): test_growth_and_cold_start() test_video_detail_and_trend() test_up_queries() + test_fans_custom_range_modes() test_fans_candidates_system_and_export() print("OK: Web 查询层总览/榜单/详情/UP/粉丝/候选/系统测试全部通过") diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 7d67261..5c01728 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -110,6 +110,26 @@ def test_readonly_routes_and_static(): point["stat_date"] >= "2026-10-18" for point in fans["recent_points"] ) + assert all("delta" in point for point in fans["recent_points"]) + + status, _, payload = running.request( + "/api/fans?start=2026-10-29&end=2026-10-31" + ) + assert status == 200 + data = json.loads(payload)["data"] + assert data["mode"] == "daily" + assert data["dates"] == ["2026-10-29", "2026-10-30", "2026-10-31"] + + running.db.write(lambda conn: conn.execute( + "INSERT OR REPLACE INTO up_stat" + "(stat_date, mid, follower, following, captured_at, ok) " + "VALUES ('2025-01-01', 37754047, 500, NULL, 1, 1)" + )) + status, _, payload = running.request( + "/api/fans?start=2025-01-01&end=2026-10-31" + ) + assert status == 200 + assert json.loads(payload)["data"]["mode"] == "monthly" status, headers, payload = running.request("/api/videos", method="HEAD") assert status == 200 and payload == b""