feat(web): add fan date range modes and delta column

This commit is contained in:
kairee committed 2026-11-01 02:36:48 +08:00
1 parent 998d0fd0c0
commit 274eccba93
6 files changed
+253 -22

No files matched your search

+120 -15
View File
@@ -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
]
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)
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:
series.append(row["follower"])
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
]
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"],
+2
View File
@@ -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
+19
View File
@@ -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 {
+41 -7
View File
@@ -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 = `<section class="page fans-page">
@@ -43,10 +51,22 @@ export async function renderFans(root, { query }) {
${sectionHeader("粉丝曲线")}
<div class="chip-row scroll-x">
${RANGES.map(([value, label]) => `<button type="button"
class="chip${days === value ? " active" : ""}" data-days="${value}">${label}</button>`).join("")}
class="chip${!customRange && days === value ? " active" : ""}" data-days="${value}">${label}</button>`).join("")}
</div>
<form class="date-range" id="fan-range">
<input type="date" name="start" value="${escapeAttr(start)}" aria-label="开始日期">
<span>至</span>
<input type="date" name="end" value="${escapeAttr(end)}" aria-label="结束日期">
<button class="button" type="submit">应用</button>
${customRange ? `<button class="button ghost" type="button" id="fan-range-clear">清除</button>` : ""}
</form>
<div class="chart" id="fans-chart"></div>
<p class="section-note">只绘制粉丝数,不叠加 reaction 视频数据;缺失日断开,不插值。</p>
<p class="section-note">
只绘制粉丝数,不叠加 reaction 视频数据;缺失日断开,不插值。
${fans.mode === "monthly"
? "当前范围超过一年,按每月粉丝峰值所在日期绘制。"
: `当前按日绘制(${escapeHtml(fans.range_start || "—")} ~ ${escapeHtml(fans.range_end || "—")})。`}
</p>
</section>
<section class="panel">
@@ -54,13 +74,13 @@ export async function renderFans(root, { query }) {
<p class="section-note">默认展示最近 ${fans.recent_days || 14} 天;更早的数据只在曲线中显示。</p>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>日期</th><th>粉丝</th><th>状态</th></tr></thead>
<thead><tr><th>日期</th><th>粉丝</th><th>较上一成功日</th></tr></thead>
<tbody>
${(fans.recent_points || []).slice().reverse().map((point) => `
<tr class="${point.ok ? "" : "failed-row"}">
<td>${escapeHtml(point.stat_date)}</td>
<td>${point.ok ? formatNumber(point.follower) : "—"}</td>
<td>${point.ok ? "成功" : "失败"}</td>
<td>${point.delta === null || point.delta === undefined ? "—" : formatDelta(point.delta)}</td>
</tr>`).join("")}
</tbody>
</table>
@@ -69,11 +89,25 @@ export async function renderFans(root, { query }) {
</section>`;
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) {
+51
View File
@@ -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/粉丝/候选/系统测试全部通过")
+20
View File
@@ -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""