- | 日期 | 粉丝 | 状态 |
+ | 日期 | 粉丝 | 较上一成功日 |
${(fans.recent_points || []).slice().reverse().map((point) => `
| ${escapeHtml(point.stat_date)} |
${point.ok ? formatNumber(point.follower) : "—"} |
- ${point.ok ? "成功" : "失败"} |
+ ${point.delta === null || point.delta === undefined ? "—" : formatDelta(point.delta)} |
`).join("")}
@@ -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""