feat(web): include removed in visible stats; cover bang badge
站内展示/统计统一 VISIBLE(含 removed):热度、UP 列表、总览 active_ups/初导入计数、说明页粉丝覆盖、黑名单视频数。采集队列仍 只 active/unfaved。失效片封面右上角感叹号徽标,点击/tap 提示 「该视频已失效」;手动/候选入库仍可复活 removed→active。
This commit is contained in:
1 parent
6c73904b25
commit
0c660c6474
7 files changed
+122
-17
No files matched your search
@@ -44,9 +44,10 @@ from ..timeutil import (
|
|||||||
)
|
)
|
||||||
from .cache import DASHBOARD_CACHE, TTL_FANS, TTL_VIDEOS, build_cache_key
|
from .cache import DASHBOARD_CACHE, TTL_FANS, TTL_VIDEOS, build_cache_key
|
||||||
|
|
||||||
TRACKED_STATUSES = ("active", "unfaved")
|
TRACKED_STATUSES = ("active", "unfaved") # 采集队列口径;站内展示勿用
|
||||||
# 站内列表可见:含原站失效 removed(继续展示、badge 可标 status-removed);不含人工 dropped
|
# 站内展示/统计:含原站失效 removed(继续展示);不含人工 dropped
|
||||||
VISIBLE_STATUSES = ("active", "unfaved", "removed")
|
VISIBLE_STATUSES = ("active", "unfaved", "removed")
|
||||||
|
_VISIBLE_IN = ",".join(f"'{s}'" for s in VISIBLE_STATUSES)
|
||||||
VIDEO_SORTS = {
|
VIDEO_SORTS = {
|
||||||
"heat", "view", "like", "coin", "favorite", "share", "reply", "danmaku",
|
"heat", "view", "like", "coin", "favorite", "share", "reply", "danmaku",
|
||||||
"interaction", "growth", "growth_rate", "added", "published",
|
"interaction", "growth", "growth_rate", "added", "published",
|
||||||
@@ -342,7 +343,7 @@ class DashboardQueries:
|
|||||||
def _videos(
|
def _videos(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
*,
|
*,
|
||||||
statuses: Sequence[str] = TRACKED_STATUSES,
|
statuses: Sequence[str] = VISIBLE_STATUSES,
|
||||||
folder_id: int | None = None,
|
folder_id: int | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
q_fields: Sequence[str] | None = None,
|
q_fields: Sequence[str] | None = None,
|
||||||
@@ -611,7 +612,7 @@ class DashboardQueries:
|
|||||||
if first_seen_ts is not None:
|
if first_seen_ts is not None:
|
||||||
initial_import_count = int(conn.execute(
|
initial_import_count = int(conn.execute(
|
||||||
"SELECT COUNT(*) FROM video WHERE first_seen = ? "
|
"SELECT COUNT(*) FROM video WHERE first_seen = ? "
|
||||||
"AND status IN ('active','unfaved')",
|
f"AND status IN ({_VISIBLE_IN})",
|
||||||
(int(first_seen_ts),),
|
(int(first_seen_ts),),
|
||||||
).fetchone()[0])
|
).fetchone()[0])
|
||||||
|
|
||||||
@@ -682,7 +683,7 @@ class DashboardQueries:
|
|||||||
"total": sum(by_status.values()),
|
"total": sum(by_status.values()),
|
||||||
"active_ups": int(conn.execute(
|
"active_ups": int(conn.execute(
|
||||||
"SELECT COUNT(DISTINCT up_mid) FROM video "
|
"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]),
|
).fetchone()[0]),
|
||||||
"candidates_pending": candidate_pending,
|
"candidates_pending": candidate_pending,
|
||||||
"candidates_total": candidate_total,
|
"candidates_total": candidate_total,
|
||||||
@@ -1755,7 +1756,7 @@ class DashboardQueries:
|
|||||||
SELECT COUNT(*) AS total,
|
SELECT COUNT(*) AS total,
|
||||||
SUM(CASE WHEN up_follower IS NOT NULL THEN 1 ELSE 0 END) AS covered
|
SUM(CASE WHEN up_follower IS NOT NULL THEN 1 ELSE 0 END) AS covered
|
||||||
FROM video
|
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()
|
).fetchone()
|
||||||
follower_total = int(follower_row["total"] or 0)
|
follower_total = int(follower_row["total"] or 0)
|
||||||
@@ -1967,7 +1968,7 @@ class DashboardQueries:
|
|||||||
"""
|
"""
|
||||||
SELECT b.*,
|
SELECT b.*,
|
||||||
(SELECT COUNT(*) FROM video v WHERE v.up_mid = b.mid
|
(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
|
FROM up_blacklist b ORDER BY b.created_at, b.mid
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|||||||
@@ -134,6 +134,15 @@ function toast(message, kind = "info") {
|
|||||||
}, 2600);
|
}, 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) => {
|
window.addEventListener("error", (event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (target instanceof HTMLImageElement) {
|
if (target instanceof HTMLImageElement) {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export function cover(item, className = "thumb") {
|
|||||||
loading="lazy" referrerpolicy="no-referrer"></div>`;
|
loading="lazy" referrerpolicy="no-referrer"></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function removedCoverMark(item) {
|
||||||
|
if (item?.status !== "removed") return "";
|
||||||
|
return `<button type="button" class="cover-removed-mark" data-removed-tip
|
||||||
|
title="该视频已失效" aria-label="该视频已失效">!</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function rankBadge(rank, className = "rank-badge") {
|
export function rankBadge(rank, className = "rank-badge") {
|
||||||
if (!rank) return "";
|
if (!rank) return "";
|
||||||
const top = rank <= 3 ? ` rank-${rank}` : "";
|
const top = rank <= 3 ? ` rank-${rank}` : "";
|
||||||
@@ -73,6 +80,7 @@ export function videoCard(item, {
|
|||||||
<div class="video-cover">
|
<div class="video-cover">
|
||||||
${cover(item, "video-thumb")}
|
${cover(item, "video-thumb")}
|
||||||
${durationBadge}
|
${durationBadge}
|
||||||
|
${removedCoverMark(item)}
|
||||||
</div>
|
</div>
|
||||||
<div class="video-card-body">
|
<div class="video-card-body">
|
||||||
<h3 class="video-title">${title}</h3>
|
<h3 class="video-title">${title}</h3>
|
||||||
@@ -150,6 +158,7 @@ export function mediaCard(item, { rank = null, highlight = "" } = {}) {
|
|||||||
<div class="media-cover">
|
<div class="media-cover">
|
||||||
${cover(item, "media-thumb")}
|
${cover(item, "media-thumb")}
|
||||||
${durationBadge}
|
${durationBadge}
|
||||||
|
${removedCoverMark(item)}
|
||||||
</div>
|
</div>
|
||||||
<span class="media-title">${escapeHtml(item.title || "(无标题)")}</span>
|
<span class="media-title">${escapeHtml(item.title || "(无标题)")}</span>
|
||||||
<span class="media-byline">
|
<span class="media-byline">
|
||||||
|
|||||||
@@ -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);
|
background: color-mix(in srgb, var(--accent) 70%, transparent);
|
||||||
color: #fff;
|
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 {
|
.cover-duration {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import {
|
import {
|
||||||
badge, centerActiveChips, cover, emptyState, metricChips, metricGrid,
|
badge, centerActiveChips, cover, emptyState, metricChips, metricGrid,
|
||||||
|
removedCoverMark,
|
||||||
sectionHeader, videoCard,
|
sectionHeader, videoCard,
|
||||||
} from "../components.js";
|
} from "../components.js";
|
||||||
import { lineChart, themeColor } from "../charts.js";
|
import { lineChart, themeColor } from "../charts.js";
|
||||||
@@ -53,7 +54,10 @@ export async function renderVideo(root, { params, query }) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="detail-hero">
|
<div class="detail-hero">
|
||||||
${cover(detail, "hero-cover")}
|
<div class="detail-cover-wrap">
|
||||||
|
${cover(detail, "hero-cover")}
|
||||||
|
${removedCoverMark(detail)}
|
||||||
|
</div>
|
||||||
<div class="detail-hero-body">
|
<div class="detail-hero-body">
|
||||||
<h1>${escapeHtml(detail.title)}</h1>
|
<h1>${escapeHtml(detail.title)}</h1>
|
||||||
<div class="detail-meta">
|
<div class="detail-meta">
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -38,11 +38,11 @@ def test_overview():
|
|||||||
assert overview["data_date"] == DATES[-1]
|
assert overview["data_date"] == DATES[-1]
|
||||||
assert overview["snapshot_days"] == 3
|
assert overview["snapshot_days"] == 3
|
||||||
assert overview["is_first_day"] is False
|
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"]["active"] == 2
|
||||||
assert overview["counts"]["unfaved"] == 1
|
assert overview["counts"]["unfaved"] == 1
|
||||||
assert overview["counts"]["removed"] == 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["counts"]["candidates_pending"] == 1
|
||||||
assert overview["totals"]["view"] == 1200 + 5200 + 120
|
assert overview["totals"]["view"] == 1200 + 5200 + 120
|
||||||
assert overview["interactions"] > 0
|
assert overview["interactions"] > 0
|
||||||
@@ -68,7 +68,7 @@ def test_video_listing_filters_and_sorting():
|
|||||||
|
|
||||||
heat = q.list_videos(sort="heat")
|
heat = q.list_videos(sort="heat")
|
||||||
aids = [item["aid"] for item in heat["items"]]
|
aids = [item["aid"] for item in heat["items"]]
|
||||||
# 热度榜仍按门槛过滤;104 removed 无成功快照,不进热度
|
# 热度门槛榜口径含 removed;104 无成功快照仍不进热度
|
||||||
assert 104 not in aids
|
assert 104 not in aids
|
||||||
# 综合热度榜应用最小播放门槛 2000:101(1200)与 103(120)被排除
|
# 综合热度榜应用最小播放门槛 2000:101(1200)与 103(120)被排除
|
||||||
assert aids == [102], aids
|
assert aids == [102], aids
|
||||||
@@ -111,6 +111,27 @@ def test_video_listing_filters_and_sorting():
|
|||||||
db.close()
|
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():
|
def test_growth_and_cold_start():
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
db, q = setup(tmp)
|
db, q = setup(tmp)
|
||||||
@@ -191,13 +212,14 @@ def test_up_queries():
|
|||||||
try:
|
try:
|
||||||
ups = q.list_ups()
|
ups = q.list_ups()
|
||||||
assert ups["sort"] == "avg_views"
|
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]["up_mid"] == 1
|
||||||
assert ups["items"][0]["video_count"] == 2
|
assert ups["items"][0]["video_count"] == 2
|
||||||
assert ups["items"][0]["total_views"] == 6400
|
assert ups["items"][0]["total_views"] == 6400
|
||||||
assert ups["items"][0]["avg_views"] == 3200.0
|
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]["up_face"] == "https://i0.hdslb.com/bfs/face/a.jpg"
|
||||||
assert ups["items"][0]["follower_latest"] == 12345
|
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)
|
one = q.list_ups(min_videos=2)
|
||||||
assert one["total"] == 1
|
assert one["total"] == 1
|
||||||
@@ -228,8 +250,8 @@ def test_up_queries():
|
|||||||
# 大小写不敏感
|
# 大小写不敏感
|
||||||
lower = q.list_ups(q="up b")
|
lower = q.list_ups(q="up b")
|
||||||
assert lower["total"] == 1 and lower["items"][0]["up_mid"] == 2
|
assert lower["total"] == 1 and lower["items"][0]["up_mid"] == 2
|
||||||
# 命中多个 UP
|
# 命中多个 UP(含 UP C)
|
||||||
assert q.list_ups(q="UP")["total"] == 2
|
assert q.list_ups(q="UP")["total"] == 3
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -479,7 +501,7 @@ def test_year_filter_and_published_sort():
|
|||||||
assert added["total"] == 4 # 含 removed
|
assert added["total"] == 4 # 含 removed
|
||||||
|
|
||||||
ups = q.list_ups(sort="efficiency", year=2026)
|
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)
|
ups_none = q.list_ups(sort="views", year=2025)
|
||||||
assert ups_none["total"] == 0
|
assert ups_none["total"] == 0
|
||||||
finally:
|
finally:
|
||||||
@@ -506,9 +528,9 @@ def test_about_payload():
|
|||||||
"growth_rate_prior": 1000,
|
"growth_rate_prior": 1000,
|
||||||
}
|
}
|
||||||
assert about["heat_weights"]["view"] == 0.30
|
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_covered"] == 3
|
||||||
assert about["followers"]["videos_missing"] == 0
|
assert about["followers"]["videos_missing"] == 1
|
||||||
assert about["folders"][0]["media_id"] == 3700023351
|
assert about["folders"][0]["media_id"] == 3700023351
|
||||||
assert about["preview"] is False
|
assert about["preview"] is False
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
Reference in new issue
Block a user