feat(web): split video and up rankings and add about page

This commit is contained in:
kairee committed 2026-11-01 11:33:15 +08:00
1 parent 5a656d42a8
commit c0b0286395
9 files changed
+298 -98

No files matched your search

+134 -72
View File
@@ -1,21 +1,32 @@
import { api } from "../api.js";
import {
centerActiveChips, chip, emptyState, pager, sectionHeader, upCard, videoCard,
centerActiveChips, chip, emptyState, pager, upCard, videoCard,
} from "../components.js";
import { escapeAttr, escapeHtml, formatCount } from "../format.js";
import { escapeAttr, escapeHtml, formatCount, formatDate, formatNumber, formatPercent } from "../format.js";
import { replaceQuery } from "../router.js";
const VIDEO_SORTS = [
["heat", "综合热度"], ["view", "播放"], ["like", "点赞"], ["coin", "投币"],
["favorite", "收藏"], ["share", "转发"], ["reply", "评论"], ["danmaku", "弹幕"],
["interaction", "互动率"], ["growth", "播放增量"], ["growth_rate", "播放增速"],
["efficiency", "内容效率"],
["heat", "综合热度"],
["view", "播放"],
["coin", "投币"],
["favorite", "收藏"],
["reply", "评论"],
["interaction", "互动率"],
["growth", "播放增量"],
["growth_rate", "播放增速"],
];
const UP_SORTS = [
["avg_views", "平均播放"], ["avg_interaction", "平均互动"],
["interaction", "总互动"], ["latest", "最近发布"],
["count", "视频数"], ["views", "总播放"],
["efficiency", "内容效率"],
["interaction_efficiency", "互动效率"],
["duration", "内容量"],
["views", "总播放"],
["count", "视频数"],
["avg_views", "平均播放"],
["interaction", "总互动"],
["latest", "最近发布"],
];
const METRIC_PRIMARY = new Set(["view", "like", "coin", "favorite", "share", "reply", "danmaku"]);
const VIDEO_METRIC_PRIMARY = new Set(["view", "coin", "favorite", "reply"]);
const OVERVIEW_TTL = 60_000;
let overviewCache = { at: 0, promise: null };
@@ -36,10 +47,6 @@ function controls({ scope, sort, within, growthDays, q, folder, minVideos, folde
const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS;
return `
<div class="rank-toolbar">
<div class="segmented" role="tablist">
<button type="button" class="${scope === "video" ? "active" : ""}" data-scope="video">视频</button>
<button type="button" class="${scope === "up" ? "active" : ""}" data-scope="up">UP</button>
</div>
<form class="rank-search" id="rank-filter">
<input class="search-input" type="search" name="q" value="${escapeAttr(q || "")}"
placeholder="${scope === "up" ? "搜索 UP 名" : "搜索标题或 UP"}" autocomplete="off">
@@ -74,75 +81,73 @@ function controls({ scope, sort, within, growthDays, q, folder, minVideos, folde
`;
}
function videoPrimary(item, sort) {
if (sort === "efficiency") {
return { primary: "efficiency", label: "播放/小时", text: formatCount(item.efficiency) };
}
if (sort === "interaction_efficiency") {
return { primary: "interaction_efficiency", label: "互动/小时", text: formatCount(item.interaction_efficiency) };
}
if (sort === "interaction") {
return { primary: "view", label: "互动率", text: formatPercent(item.interaction_rate) };
}
if (VIDEO_METRIC_PRIMARY.has(sort)) {
return { primary: sort, label: "", text: null };
}
return { primary: "view", label: "播放", text: null };
}
function upPrimary(item, sort) {
if (sort === "efficiency") {
return { label: "播放/小时", text: formatCount(item.efficiency) };
}
if (sort === "interaction_efficiency") {
return { label: "互动/小时", text: formatCount(item.interaction_efficiency) };
}
if (sort === "duration") {
return { label: "内容小时", text: item.content_hours === null ? "—" : `${formatNumber(item.content_hours)} 小时` };
}
if (sort === "views") return { label: "总播放", text: formatCount(item.total_views) };
if (sort === "count") return { label: "视频数", text: formatNumber(item.video_count) };
if (sort === "avg_views") return { label: "平均播放", text: formatCount(item.avg_views) };
if (sort === "avg_interaction") return { label: "平均互动", text: formatPercent(item.avg_interaction) };
if (sort === "interaction") return { label: "总互动", text: formatCount(item.total_interactions) };
if (sort === "latest") return { label: "最近发布", text: formatDate(item.latest_pubdate) };
return { label: "播放/小时", text: formatCount(item.efficiency) };
}
function listHtml(data, scope, sort, startRank) {
if (!data.items.length) {
return emptyState(
data.available === false ? "增长榜暂不可用" : "没有匹配的视频",
data.available === false
? (data.reason || "需要至少两天的快照。")
: "换个筛选条件或清空搜索关键词试试。",
data.available === false ? "当前榜单暂不可用" : (scope === "up" ? "没有匹配的 UP" : "没有匹配的视频"),
data.reason || "换个筛选条件或清空搜索关键词试试。",
);
}
if (scope === "up") {
return `<div class="rank-list">${data.items.map((item, index) => upCard(item, { rank: startRank + index })).join("")}</div>`;
return `<div class="rank-list">${data.items.map((item, index) => {
const primary = upPrimary(item, sort);
return upCard(item, {
rank: startRank + index, primaryLabel: primary.label, primaryText: primary.text,
});
}).join("")}</div>`;
}
const primary = METRIC_PRIMARY.has(sort) ? sort : "view";
return `<div class="rank-list">${data.items.map((item, index) =>
videoCard(item, { rank: startRank + index, primary })).join("")}</div>`;
return `<div class="rank-list">${data.items.map((item, index) => {
const primary = videoPrimary(item, sort);
return videoCard(item, {
rank: startRank + index, primary: primary.primary,
primaryLabel: primary.label, primaryText: primary.text,
});
}).join("")}</div>`;
}
export async function renderRankings(root, { query }) {
const scope = query.get("scope") === "up" ? "up" : "video";
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";
const q = query.get("q") || "";
const folder = query.get("folder") || "";
const minVideos = query.get("min_videos") || "1";
const page = Math.max(1, Number(query.get("page") || 1));
function durationNote(data) {
const filter = data.duration_filter;
if (!filter) return "";
return `<div class="section-note">内容效率与互动效率仅统计时长 ≥ ${filter.min_duration_seconds} 秒、
发布满 ${filter.maturity_days} 天的视频;时长缺失的视频不参与排序。</div>`;
}
const overview = await getOverview();
const folders = overview.folders || [];
const params = {
sort,
page,
page_size: 20,
within_days: within === "all" ? null : within,
growth_days: growthDays,
q,
};
let data;
if (scope === "up") {
data = await api("/api/rankings", {
...params, scope: "up", min_videos: minVideos,
});
} else {
data = await api("/api/videos", { ...params, folder: folder || null });
}
const startRank = (data.page - 1) * data.page_size + 1;
root.innerHTML = `<section class="page rankings-page">
<header class="page-head">
<h1>榜单 <span class="page-sub">共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}</span></h1>
</header>
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
${data.available === false ? `<div class="notice-row warning">
<strong>增长数据尚不可用</strong><span>${escapeHtml(data.reason || "")}</span>
</div>` : ""}
${listHtml(data, scope, sort, startRank)}
${pager(data.page, data.pages)}
</section>`;
root.querySelectorAll("[data-scope]").forEach((button) => button.addEventListener("click", () => {
replaceQuery({
scope: button.dataset.scope,
sort: button.dataset.scope === "up" ? "avg_views" : "heat",
page: null,
});
}));
function bindControls(root, scope) {
root.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => {
replaceQuery({ sort: button.dataset.sort, page: null });
}));
@@ -169,3 +174,60 @@ export async function renderRankings(root, { query }) {
}));
centerActiveChips(root);
}
async function renderScope(root, { query }, scope) {
const defaultSort = "efficiency";
const sort = query.get("sort") || defaultSort;
const within = query.get("within_days") || "all";
const growthDays = query.get("growth_days") || "7";
const q = query.get("q") || "";
const folder = query.get("folder") || "";
const minVideos = query.get("min_videos") || "1";
const page = Math.max(1, Number(query.get("page") || 1));
const overview = await getOverview();
const folders = overview.folders || [];
const params = {
sort,
page,
page_size: 20,
within_days: within === "all" ? null : within,
growth_days: growthDays,
q,
};
let data;
if (scope === "up") {
data = await api("/api/ups", { ...params, min_videos: minVideos });
} else {
data = await api("/api/videos", { ...params, folder: folder || null });
}
const startRank = (data.page - 1) * data.page_size + 1;
const title = scope === "up" ? "UP主" : "视频";
const unit = scope === "up" ? "个 UP" : "条视频";
root.innerHTML = `<section class="page rankings-page">
<header class="page-head">
<h1>${title} <span class="page-sub">共 ${formatCount(data.total)} ${unit}</span></h1>
</header>
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
${durationNote(data)}
${data.available === false ? `<div class="notice-row warning">
<strong>${sort.startsWith("growth") ? "增长数据尚不可用" : "榜单筛选后为空"}</strong>
<span>${escapeHtml(data.reason || "")}</span>
</div>` : ""}
${listHtml(data, scope, sort, startRank)}
${pager(data.page, data.pages)}
</section>`;
bindControls(root, scope);
}
export function renderVideos(root, context) {
return renderScope(root, context, "video");
}
export function renderUps(root, context) {
return renderScope(root, context, "up");
}