diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index 47906f1..4bdd5f6 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -474,11 +474,21 @@ class DashboardQueries: return None current = ok_rows[-1] previous = ok_rows[-2] if len(ok_rows) >= 2 else None + if previous is not None: + try: + gap = ( + date.fromisoformat(str(current["stat_date"])) + - date.fromisoformat(str(previous["stat_date"])) + ).days + except ValueError: + gap = 0 + if gap != 1: + previous = None baseline = None if latest: cutoff = _date_minus(latest, 7) - candidates = [row for row in ok_rows if str(row["stat_date"]) <= cutoff] - baseline = candidates[-1] if candidates else None + by_date = {str(row["stat_date"]): row for row in ok_rows} + baseline = by_date.get(cutoff) first = ok_rows[0] return { "mid": mid, @@ -716,7 +726,7 @@ class DashboardQueries: def list_ups( self, *, - sort: str = "views", + sort: str = "avg_views", within_days: int | None = None, q: str | None = None, min_videos: int = 1, @@ -944,9 +954,19 @@ class DashboardQueries: 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 + if previous is not None: + try: + gap = ( + date.fromisoformat(str(current["stat_date"])) + - date.fromisoformat(str(previous["stat_date"])) + ).days + except ValueError: + gap = 0 + if gap != 1: + previous = None cutoff = _date_minus(latest, 7) - baselines = [row for row in ok_rows if str(row["stat_date"]) <= cutoff] - baseline = baselines[-1] if baselines else None + by_date = {str(row["stat_date"]): row for row in ok_rows} + baseline = by_date.get(cutoff) first = ok_rows[0] if ok_rows else rows[0] return { "mid": selected, diff --git a/reaction_board/web/server.py b/reaction_board/web/server.py index 2cfd4aa..ad07fd3 100644 --- a/reaction_board/web/server.py +++ b/reaction_board/web/server.py @@ -314,7 +314,7 @@ border-color:#333a42;color:#f3f4f6}} scope = _param(query, "scope", "video") if scope == "up": self._send_json(HTTPStatus.OK, queries.list_ups( - sort=_param(query, "sort", "views"), + sort=_param(query, "sort", "avg_views"), within_days=_optional_int(query, "within_days"), q=_optional_str(query, "q"), min_videos=_int_param(query, "min_videos", 1, 1, 10000), @@ -344,7 +344,7 @@ border-color:#333a42;color:#f3f4f6}} return if path == "/api/ups": self._send_json(HTTPStatus.OK, queries.list_ups( - sort=_param(query, "sort", "views"), + sort=_param(query, "sort", "avg_views"), within_days=_optional_int(query, "within_days"), q=_optional_str(query, "q"), min_videos=_int_param(query, "min_videos", 1, 1, 10000), diff --git a/reaction_board/web/static/style.css b/reaction_board/web/static/style.css index 2ffa6a9..42e0d77 100644 --- a/reaction_board/web/static/style.css +++ b/reaction_board/web/static/style.css @@ -698,7 +698,7 @@ h1, h2, h3, p { margin: 0; } .detail-hero { flex-direction: row; align-items: flex-start; } .hero-cover { width: 300px; flex: 0 0 300px; } .video-thumb { width: 112px; } - .video-card { grid-template-columns: 28px 112px minmax(0, 1fr) 14px; } + .video-card, .up-card { grid-template-columns: 28px 112px minmax(0, 1fr) 14px; } .video-card.no-rank, .up-card.no-rank { grid-template-columns: 112px minmax(0, 1fr) 14px; } .secondary-stat.optional { display: inline; } } @@ -753,7 +753,7 @@ h1, h2, h3, p { margin: 0; } } @media (max-width: 380px) { - .video-card { grid-template-columns: 22px 82px minmax(0, 1fr) 10px; gap: 7px; } + .video-card, .up-card { grid-template-columns: 22px 82px minmax(0, 1fr) 10px; gap: 7px; } .video-card.no-rank, .up-card.no-rank { grid-template-columns: 82px minmax(0, 1fr) 10px; } .video-thumb { width: 82px; } .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/reaction_board/web/static/views/rankings.js b/reaction_board/web/static/views/rankings.js index d33f04f..1588dea 100644 --- a/reaction_board/web/static/views/rankings.js +++ b/reaction_board/web/static/views/rankings.js @@ -11,8 +11,9 @@ const VIDEO_SORTS = [ ["interaction", "互动率"], ["growth", "增长"], ["growth_rate", "增速"], ]; const UP_SORTS = [ - ["views", "总播放"], ["count", "视频数"], ["avg_views", "平均播放"], - ["interaction", "总互动"], ["avg_interaction", "平均互动"], ["latest", "最近发布"], + ["avg_views", "平均播放"], ["avg_interaction", "平均互动"], + ["interaction", "总互动"], ["latest", "最近发布"], + ["count", "视频数"], ["views", "总播放"], ]; const METRIC_PRIMARY = new Set(["view", "like", "coin", "favorite", "share", "reply", "danmaku"]); @@ -75,7 +76,7 @@ function listHtml(data, scope, sort, startRank) { export async function renderRankings(root, { query }) { const scope = query.get("scope") === "up" ? "up" : "video"; - const defaultSort = scope === "up" ? "views" : "heat"; + const defaultSort = scope === "up" ? "avg_views" : "heat"; const sort = query.get("sort") || defaultSort; const within = query.get("within_days") || "all"; const growthDays = query.get("growth_days") || "7"; @@ -133,7 +134,7 @@ export async function renderRankings(root, { query }) { root.querySelectorAll("[data-scope]").forEach((button) => button.addEventListener("click", () => { replaceQuery({ scope: button.dataset.scope, - sort: button.dataset.scope === "up" ? "views" : "heat", + sort: button.dataset.scope === "up" ? "avg_views" : "heat", page: null, }); })); diff --git a/reaction_board/web/static/views/up.js b/reaction_board/web/static/views/up.js index 7b766f5..c765d0f 100644 --- a/reaction_board/web/static/views/up.js +++ b/reaction_board/web/static/views/up.js @@ -35,7 +35,7 @@ export async function renderUp(root, { params, query }) { root.innerHTML = `
- ← 返回 UP 榜 + ← 返回 UP 榜 B 站空间
@@ -65,10 +65,10 @@ export async function renderUp(root, { params, query }) {

按该 UP 全部视频的每日播放合计绘制;缺失日期断开。

-
+ ${videos.length > 1 ? `
${sectionHeader("单视频播放对比")} ${topForBar.length ? `
` : emptyState("暂无视频数据")} -
+
` : ""}
diff --git a/tests/test_web_queries.py b/tests/test_web_queries.py index 4c89f64..584503d 100644 --- a/tests/test_web_queries.py +++ b/tests/test_web_queries.py @@ -171,6 +171,7 @@ def test_up_queries(): db, q = setup(tmp) try: ups = q.list_ups() + assert ups["sort"] == "avg_views" assert ups["total"] == 2 assert ups["items"][0]["up_mid"] == 1 assert ups["items"][0]["video_count"] == 2 @@ -212,6 +213,15 @@ def test_fans_candidates_system_and_export(): wide = q.fans(days=30) assert wide["dates"] == list(DATES) + # 缺失前一天时不能把跨多天的变化标成“日增” + db.write(lambda conn: conn.execute( + "DELETE FROM up_stat WHERE mid=37754047 AND stat_date=?", + ("2026-10-30",), + )) + gapped = q.fans(days=3) + assert gapped["delta_1d"] is None + assert gapped["delta_7d"] is None + candidates = q.candidates() assert candidates["total"] == 2 assert candidates["pending"] == 1 diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 60d7611..d9d805a 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -89,6 +89,10 @@ def test_readonly_routes_and_static(): items = json.loads(payload)["data"]["items"] assert items and items[0]["aid"] == 102 + status, _, payload = running.request("/api/ups") + assert status == 200 + assert json.loads(payload)["data"]["sort"] == "avg_views" + status, headers, payload = running.request("/api/videos", method="HEAD") assert status == 200 and payload == b"" assert "Content-Length" in headers