fix(web): fix UP card layout and fan delta semantics
This commit is contained in:
1 parent
7fcd1db987
commit
f9a5478800
7 files changed
+51
-16
No files matched your search
@@ -474,11 +474,21 @@ class DashboardQueries:
|
|||||||
return None
|
return None
|
||||||
current = ok_rows[-1]
|
current = ok_rows[-1]
|
||||||
previous = ok_rows[-2] if len(ok_rows) >= 2 else None
|
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
|
baseline = None
|
||||||
if latest:
|
if latest:
|
||||||
cutoff = _date_minus(latest, 7)
|
cutoff = _date_minus(latest, 7)
|
||||||
candidates = [row for row in ok_rows if str(row["stat_date"]) <= cutoff]
|
by_date = {str(row["stat_date"]): row for row in ok_rows}
|
||||||
baseline = candidates[-1] if candidates else None
|
baseline = by_date.get(cutoff)
|
||||||
first = ok_rows[0]
|
first = ok_rows[0]
|
||||||
return {
|
return {
|
||||||
"mid": mid,
|
"mid": mid,
|
||||||
@@ -716,7 +726,7 @@ class DashboardQueries:
|
|||||||
def list_ups(
|
def list_ups(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
sort: str = "views",
|
sort: str = "avg_views",
|
||||||
within_days: int | None = None,
|
within_days: int | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
min_videos: int = 1,
|
min_videos: int = 1,
|
||||||
@@ -944,9 +954,19 @@ class DashboardQueries:
|
|||||||
ok_rows = [row for row in rows if int(row["ok"]) == 1]
|
ok_rows = [row for row in rows if int(row["ok"]) == 1]
|
||||||
current = ok_rows[-1] if ok_rows else rows[-1]
|
current = ok_rows[-1] if ok_rows else rows[-1]
|
||||||
previous = ok_rows[-2] if len(ok_rows) >= 2 else None
|
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)
|
cutoff = _date_minus(latest, 7)
|
||||||
baselines = [row for row in ok_rows if str(row["stat_date"]) <= cutoff]
|
by_date = {str(row["stat_date"]): row for row in ok_rows}
|
||||||
baseline = baselines[-1] if baselines else None
|
baseline = by_date.get(cutoff)
|
||||||
first = ok_rows[0] if ok_rows else rows[0]
|
first = ok_rows[0] if ok_rows else rows[0]
|
||||||
return {
|
return {
|
||||||
"mid": selected,
|
"mid": selected,
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ border-color:#333a42;color:#f3f4f6}}
|
|||||||
scope = _param(query, "scope", "video")
|
scope = _param(query, "scope", "video")
|
||||||
if scope == "up":
|
if scope == "up":
|
||||||
self._send_json(HTTPStatus.OK, queries.list_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"),
|
within_days=_optional_int(query, "within_days"),
|
||||||
q=_optional_str(query, "q"),
|
q=_optional_str(query, "q"),
|
||||||
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
|
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
|
||||||
@@ -344,7 +344,7 @@ border-color:#333a42;color:#f3f4f6}}
|
|||||||
return
|
return
|
||||||
if path == "/api/ups":
|
if path == "/api/ups":
|
||||||
self._send_json(HTTPStatus.OK, queries.list_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"),
|
within_days=_optional_int(query, "within_days"),
|
||||||
q=_optional_str(query, "q"),
|
q=_optional_str(query, "q"),
|
||||||
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
|
min_videos=_int_param(query, "min_videos", 1, 1, 10000),
|
||||||
|
|||||||
@@ -698,7 +698,7 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
.detail-hero { flex-direction: row; align-items: flex-start; }
|
.detail-hero { flex-direction: row; align-items: flex-start; }
|
||||||
.hero-cover { width: 300px; flex: 0 0 300px; }
|
.hero-cover { width: 300px; flex: 0 0 300px; }
|
||||||
.video-thumb { width: 112px; }
|
.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; }
|
.video-card.no-rank, .up-card.no-rank { grid-template-columns: 112px minmax(0, 1fr) 14px; }
|
||||||
.secondary-stat.optional { display: inline; }
|
.secondary-stat.optional { display: inline; }
|
||||||
}
|
}
|
||||||
@@ -753,7 +753,7 @@ h1, h2, h3, p { margin: 0; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 380px) {
|
@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-card.no-rank, .up-card.no-rank { grid-template-columns: 82px minmax(0, 1fr) 10px; }
|
||||||
.video-thumb { width: 82px; }
|
.video-thumb { width: 82px; }
|
||||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ const VIDEO_SORTS = [
|
|||||||
["interaction", "互动率"], ["growth", "增长"], ["growth_rate", "增速"],
|
["interaction", "互动率"], ["growth", "增长"], ["growth_rate", "增速"],
|
||||||
];
|
];
|
||||||
const UP_SORTS = [
|
const UP_SORTS = [
|
||||||
["views", "总播放"], ["count", "视频数"], ["avg_views", "平均播放"],
|
["avg_views", "平均播放"], ["avg_interaction", "平均互动"],
|
||||||
["interaction", "总互动"], ["avg_interaction", "平均互动"], ["latest", "最近发布"],
|
["interaction", "总互动"], ["latest", "最近发布"],
|
||||||
|
["count", "视频数"], ["views", "总播放"],
|
||||||
];
|
];
|
||||||
const METRIC_PRIMARY = new Set(["view", "like", "coin", "favorite", "share", "reply", "danmaku"]);
|
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 }) {
|
export async function renderRankings(root, { query }) {
|
||||||
const scope = query.get("scope") === "up" ? "up" : "video";
|
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 sort = query.get("sort") || defaultSort;
|
||||||
const within = query.get("within_days") || "all";
|
const within = query.get("within_days") || "all";
|
||||||
const growthDays = query.get("growth_days") || "7";
|
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", () => {
|
root.querySelectorAll("[data-scope]").forEach((button) => button.addEventListener("click", () => {
|
||||||
replaceQuery({
|
replaceQuery({
|
||||||
scope: button.dataset.scope,
|
scope: button.dataset.scope,
|
||||||
sort: button.dataset.scope === "up" ? "views" : "heat",
|
sort: button.dataset.scope === "up" ? "avg_views" : "heat",
|
||||||
page: null,
|
page: null,
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function renderUp(root, { params, query }) {
|
|||||||
|
|
||||||
root.innerHTML = `<section class="page up-page">
|
root.innerHTML = `<section class="page up-page">
|
||||||
<header class="page-head">
|
<header class="page-head">
|
||||||
<a class="back-link" href="#/rank?scope=up&sort=views">← 返回 UP 榜</a>
|
<a class="back-link" href="#/rank?scope=up&sort=avg_views">← 返回 UP 榜</a>
|
||||||
<a class="button ghost" href="${escapeAttr(up.space_url)}" target="_blank" rel="noreferrer">B 站空间</a>
|
<a class="button ghost" href="${escapeAttr(up.space_url)}" target="_blank" rel="noreferrer">B 站空间</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -65,10 +65,10 @@ export async function renderUp(root, { params, query }) {
|
|||||||
<p class="section-note">按该 UP 全部视频的每日播放合计绘制;缺失日期断开。</p>
|
<p class="section-note">按该 UP 全部视频的每日播放合计绘制;缺失日期断开。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel chart-panel">
|
${videos.length > 1 ? `<section class="panel chart-panel">
|
||||||
${sectionHeader("单视频播放对比")}
|
${sectionHeader("单视频播放对比")}
|
||||||
${topForBar.length ? `<div class="chart chart-tall" id="up-bars"></div>` : emptyState("暂无视频数据")}
|
${topForBar.length ? `<div class="chart chart-tall" id="up-bars"></div>` : emptyState("暂无视频数据")}
|
||||||
</section>
|
</section>` : ""}
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ def test_up_queries():
|
|||||||
db, q = setup(tmp)
|
db, q = setup(tmp)
|
||||||
try:
|
try:
|
||||||
ups = q.list_ups()
|
ups = q.list_ups()
|
||||||
|
assert ups["sort"] == "avg_views"
|
||||||
assert ups["total"] == 2
|
assert ups["total"] == 2
|
||||||
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
|
||||||
@@ -212,6 +213,15 @@ def test_fans_candidates_system_and_export():
|
|||||||
wide = q.fans(days=30)
|
wide = q.fans(days=30)
|
||||||
assert wide["dates"] == list(DATES)
|
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()
|
candidates = q.candidates()
|
||||||
assert candidates["total"] == 2
|
assert candidates["total"] == 2
|
||||||
assert candidates["pending"] == 1
|
assert candidates["pending"] == 1
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ def test_readonly_routes_and_static():
|
|||||||
items = json.loads(payload)["data"]["items"]
|
items = json.loads(payload)["data"]["items"]
|
||||||
assert items and items[0]["aid"] == 102
|
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")
|
status, headers, payload = running.request("/api/videos", method="HEAD")
|
||||||
assert status == 200 and payload == b""
|
assert status == 200 and payload == b""
|
||||||
assert "Content-Length" in headers
|
assert "Content-Length" in headers
|
||||||
|
|||||||
Reference in new issue
Block a user