diff --git a/reaction_board/web/queries.py b/reaction_board/web/queries.py index 0b72c1a..59ba2fa 100644 --- a/reaction_board/web/queries.py +++ b/reaction_board/web/queries.py @@ -44,9 +44,10 @@ from ..timeutil import ( ) from .cache import DASHBOARD_CACHE, TTL_FANS, TTL_VIDEOS, build_cache_key -TRACKED_STATUSES = ("active", "unfaved") -# 站内列表可见:含原站失效 removed(继续展示、badge 可标 status-removed);不含人工 dropped +TRACKED_STATUSES = ("active", "unfaved") # 采集队列口径;站内展示勿用 +# 站内展示/统计:含原站失效 removed(继续展示);不含人工 dropped VISIBLE_STATUSES = ("active", "unfaved", "removed") +_VISIBLE_IN = ",".join(f"'{s}'" for s in VISIBLE_STATUSES) VIDEO_SORTS = { "heat", "view", "like", "coin", "favorite", "share", "reply", "danmaku", "interaction", "growth", "growth_rate", "added", "published", @@ -342,7 +343,7 @@ class DashboardQueries: def _videos( conn: sqlite3.Connection, *, - statuses: Sequence[str] = TRACKED_STATUSES, + statuses: Sequence[str] = VISIBLE_STATUSES, folder_id: int | None = None, q: str | None = None, q_fields: Sequence[str] | None = None, @@ -611,7 +612,7 @@ class DashboardQueries: if first_seen_ts is not None: initial_import_count = int(conn.execute( "SELECT COUNT(*) FROM video WHERE first_seen = ? " - "AND status IN ('active','unfaved')", + f"AND status IN ({_VISIBLE_IN})", (int(first_seen_ts),), ).fetchone()[0]) @@ -682,7 +683,7 @@ class DashboardQueries: "total": sum(by_status.values()), "active_ups": int(conn.execute( "SELECT COUNT(DISTINCT up_mid) FROM video " - "WHERE status IN ('active','unfaved') AND up_mid IS NOT NULL" + f"WHERE status IN ({_VISIBLE_IN}) AND up_mid IS NOT NULL" ).fetchone()[0]), "candidates_pending": candidate_pending, "candidates_total": candidate_total, @@ -1755,7 +1756,7 @@ class DashboardQueries: SELECT COUNT(*) AS total, SUM(CASE WHEN up_follower IS NOT NULL THEN 1 ELSE 0 END) AS covered FROM video - WHERE status IN ('active', 'unfaved') AND up_mid IS NOT NULL + WHERE status IN ('active', 'unfaved', 'removed') AND up_mid IS NOT NULL """ ).fetchone() follower_total = int(follower_row["total"] or 0) @@ -1967,7 +1968,7 @@ class DashboardQueries: """ SELECT b.*, (SELECT COUNT(*) FROM video v WHERE v.up_mid = b.mid - AND v.status IN ('active','unfaved')) AS video_count + AND v.status IN ('active','unfaved','removed')) AS video_count FROM up_blacklist b ORDER BY b.created_at, b.mid """ ).fetchall() diff --git a/reaction_board/web/static/app.js b/reaction_board/web/static/app.js index 67a17a3..50d059d 100644 --- a/reaction_board/web/static/app.js +++ b/reaction_board/web/static/app.js @@ -134,6 +134,15 @@ function toast(message, kind = "info") { }, 2600); } + +document.addEventListener("click", (event) => { + const mark = event.target.closest("[data-removed-tip]"); + if (!mark) return; + event.preventDefault(); + event.stopPropagation(); + toast("该视频已失效"); +}, true); + window.addEventListener("error", (event) => { const target = event.target; if (target instanceof HTMLImageElement) { diff --git a/reaction_board/web/static/components.js b/reaction_board/web/static/components.js index 327221c..18bcb82 100644 --- a/reaction_board/web/static/components.js +++ b/reaction_board/web/static/components.js @@ -13,6 +13,13 @@ export function cover(item, className = "thumb") { loading="lazy" referrerpolicy="no-referrer">`; } + +export function removedCoverMark(item) { + if (item?.status !== "removed") return ""; + return ``; +} + export function rankBadge(rank, className = "rank-badge") { if (!rank) return ""; const top = rank <= 3 ? ` rank-${rank}` : ""; @@ -73,6 +80,7 @@ export function videoCard(item, {
${cover(item, "video-thumb")} ${durationBadge} + ${removedCoverMark(item)}

${title}

@@ -150,6 +158,7 @@ export function mediaCard(item, { rank = null, highlight = "" } = {}) {
${cover(item, "media-thumb")} ${durationBadge} + ${removedCoverMark(item)}
${escapeHtml(item.title || "(无标题)")} diff --git a/reaction_board/web/static/style.css b/reaction_board/web/static/style.css index ee0c450..ce449f7 100644 --- a/reaction_board/web/static/style.css +++ b/reaction_board/web/static/style.css @@ -388,6 +388,32 @@ a.live-chip:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--bo background: color-mix(in srgb, var(--accent) 70%, transparent); color: #fff; } + +.detail-cover-wrap { position: relative; } +.cover-removed-mark { + position: absolute; + z-index: 2; + top: 6px; + right: 6px; + display: grid; + place-items: center; + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: 999px; + background: color-mix(in srgb, var(--warning) 82%, #1a1208); + color: #fff8f0; + font-size: 13px; + font-weight: 700; + line-height: 1; + cursor: help; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35); +} +.cover-removed-mark:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} .cover-duration { position: absolute; z-index: 1; diff --git a/reaction_board/web/static/views/video.js b/reaction_board/web/static/views/video.js index 2cdc2da..6a31907 100644 --- a/reaction_board/web/static/views/video.js +++ b/reaction_board/web/static/views/video.js @@ -1,6 +1,7 @@ import { api } from "../api.js"; import { badge, centerActiveChips, cover, emptyState, metricChips, metricGrid, + removedCoverMark, sectionHeader, videoCard, } from "../components.js"; import { lineChart, themeColor } from "../charts.js"; @@ -53,7 +54,10 @@ export async function renderVideo(root, { params, query }) {
- ${cover(detail, "hero-cover")} +
+ ${cover(detail, "hero-cover")} + ${removedCoverMark(detail)} +

${escapeHtml(detail.title)}

diff --git a/tests/test_removed_cover_mark.py b/tests/test_removed_cover_mark.py new file mode 100644 index 0000000..cf9d3ca --- /dev/null +++ b/tests/test_removed_cover_mark.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""失效封面徽标静态落地检查: uv run python tests/test_removed_cover_mark.py""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +STATIC = ROOT / "reaction_board" / "web" / "static" + + +def test_removed_cover_mark_markup(): + components = (STATIC / "components.js").read_text(encoding="utf-8") + assert "export function removedCoverMark" in components + assert 'data-removed-tip' in components + assert "该视频已失效" in components + assert "${removedCoverMark(item)}" in components + + app = (STATIC / "app.js").read_text(encoding="utf-8") + assert 'closest("[data-removed-tip]")' in app + assert 'toast("该视频已失效")' in app + + css = (STATIC / "style.css").read_text(encoding="utf-8") + assert ".cover-removed-mark" in css + + video = (STATIC / "views" / "video.js").read_text(encoding="utf-8") + assert "removedCoverMark(detail)" in video + + +def main(): + test_removed_cover_mark_markup() + print("OK: 失效封面徽标静态落地检查通过") + + +if __name__ == "__main__": + main() diff --git a/tests/test_web_queries.py b/tests/test_web_queries.py index 8dbdffb..88f0d46 100644 --- a/tests/test_web_queries.py +++ b/tests/test_web_queries.py @@ -38,11 +38,11 @@ def test_overview(): assert overview["data_date"] == DATES[-1] assert overview["snapshot_days"] == 3 assert overview["is_first_day"] is False - assert overview["initial_import_count"] == 2 + assert overview["initial_import_count"] == 3 # 含 removed 104 assert overview["counts"]["active"] == 2 assert overview["counts"]["unfaved"] == 1 assert overview["counts"]["removed"] == 1 - assert overview["counts"]["active_ups"] == 2 + assert overview["counts"]["active_ups"] == 3 # 含 UP C(仅 removed) assert overview["counts"]["candidates_pending"] == 1 assert overview["totals"]["view"] == 1200 + 5200 + 120 assert overview["interactions"] > 0 @@ -68,7 +68,7 @@ def test_video_listing_filters_and_sorting(): heat = q.list_videos(sort="heat") aids = [item["aid"] for item in heat["items"]] - # 热度榜仍按门槛过滤;104 removed 无成功快照,不进热度 + # 热度门槛榜口径含 removed;104 无成功快照仍不进热度 assert 104 not in aids # 综合热度榜应用最小播放门槛 2000:101(1200)与 103(120)被排除 assert aids == [102], aids @@ -111,6 +111,27 @@ def test_video_listing_filters_and_sorting(): db.close() + +def test_heat_ranking_includes_removed_with_metrics(): + """removed 进入热度门槛榜口径;有成功快照且过门槛时可见。""" + with tempfile.TemporaryDirectory() as tmp: + db, q = setup(tmp) + try: + db.write(lambda conn: conn.execute( + "UPDATE snapshot SET ok = 1, view = 5000, like = 10, coin = 1, " + "favorite = 1, share = 0, reply = 0, danmaku = 0, err = NULL " + "WHERE aid = 104 AND stat_date = ?", + (DATES[1],), + )) + DASHBOARD_CACHE.clear() + heat = q.list_videos(sort="heat") + aids = [item["aid"] for item in heat["items"]] + assert 104 in aids + assert next(item for item in heat["items"] if item["aid"] == 104)["status"] == "removed" + finally: + db.close() + + def test_growth_and_cold_start(): with tempfile.TemporaryDirectory() as tmp: db, q = setup(tmp) @@ -191,13 +212,14 @@ def test_up_queries(): try: ups = q.list_ups() assert ups["sort"] == "avg_views" - assert ups["total"] == 2 + assert ups["total"] == 3 # 含仅有 removed 视频的 UP C assert ups["items"][0]["up_mid"] == 1 assert ups["items"][0]["video_count"] == 2 assert ups["items"][0]["total_views"] == 6400 assert ups["items"][0]["avg_views"] == 3200.0 assert ups["items"][0]["up_face"] == "https://i0.hdslb.com/bfs/face/a.jpg" assert ups["items"][0]["follower_latest"] == 12345 + assert {item["up_mid"] for item in ups["items"]} == {1, 2, 3} one = q.list_ups(min_videos=2) assert one["total"] == 1 @@ -228,8 +250,8 @@ def test_up_queries(): # 大小写不敏感 lower = q.list_ups(q="up b") assert lower["total"] == 1 and lower["items"][0]["up_mid"] == 2 - # 命中多个 UP - assert q.list_ups(q="UP")["total"] == 2 + # 命中多个 UP(含 UP C) + assert q.list_ups(q="UP")["total"] == 3 finally: db.close() @@ -479,7 +501,7 @@ def test_year_filter_and_published_sort(): assert added["total"] == 4 # 含 removed ups = q.list_ups(sort="efficiency", year=2026) - assert ups["year"] == 2026 and ups["total"] == 2 + assert ups["year"] == 2026 and ups["total"] == 3 ups_none = q.list_ups(sort="views", year=2025) assert ups_none["total"] == 0 finally: @@ -506,9 +528,9 @@ def test_about_payload(): "growth_rate_prior": 1000, } assert about["heat_weights"]["view"] == 0.30 - assert about["followers"]["videos_total"] == 3 + assert about["followers"]["videos_total"] == 4 # 含 removed assert about["followers"]["videos_covered"] == 3 - assert about["followers"]["videos_missing"] == 0 + assert about["followers"]["videos_missing"] == 1 assert about["folders"][0]["media_id"] == 3700023351 assert about["preview"] is False finally: