234 lines
9.4 KiB
JavaScript
234 lines
9.4 KiB
JavaScript
import { api } from "../api.js";
|
|
import {
|
|
centerActiveChips, chip, emptyState, pager, upCard, videoCard,
|
|
} from "../components.js";
|
|
import { escapeAttr, escapeHtml, formatCount, formatDate, formatNumber, formatPercent } from "../format.js";
|
|
import { replaceQuery } from "../router.js";
|
|
|
|
const VIDEO_SORTS = [
|
|
["efficiency", "内容效率"],
|
|
["heat", "综合热度"],
|
|
["view", "播放"],
|
|
["coin", "投币"],
|
|
["favorite", "收藏"],
|
|
["reply", "评论"],
|
|
["interaction", "互动率"],
|
|
["growth", "播放增量"],
|
|
["growth_rate", "播放增速"],
|
|
];
|
|
const UP_SORTS = [
|
|
["efficiency", "内容效率"],
|
|
["interaction_efficiency", "互动效率"],
|
|
["duration", "内容量"],
|
|
["views", "总播放"],
|
|
["count", "视频数"],
|
|
["avg_views", "平均播放"],
|
|
["interaction", "总互动"],
|
|
["latest", "最近发布"],
|
|
];
|
|
const VIDEO_METRIC_PRIMARY = new Set(["view", "coin", "favorite", "reply"]);
|
|
const OVERVIEW_TTL = 60_000;
|
|
let overviewCache = { at: 0, promise: null };
|
|
|
|
async function getOverview() {
|
|
const now = Date.now();
|
|
if (!overviewCache.promise || now - overviewCache.at > OVERVIEW_TTL) {
|
|
overviewCache = { at: now, promise: api("/api/overview") };
|
|
}
|
|
try {
|
|
return await overviewCache.promise;
|
|
} catch (error) {
|
|
overviewCache = { at: 0, promise: null };
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function controls({ scope, sort, within, growthDays, q, folder, minVideos, folders }) {
|
|
const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS;
|
|
return `
|
|
<div class="rank-toolbar">
|
|
<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">
|
|
<button class="button" type="submit">搜索</button>
|
|
</form>
|
|
</div>
|
|
<div class="chip-row scroll-x">
|
|
${sorts.map(([value, label]) => chip(label, sort === value, "sort", value)).join("")}
|
|
</div>
|
|
<div class="filter-row">
|
|
<div class="chip-row scroll-x">
|
|
${[["all", "全部发布时间"], ["7", "近 7 天"], ["30", "近 30 天"], ["90", "近 90 天"]]
|
|
.map(([value, label]) => chip(label, String(within || "all") === value, "within", value)).join("")}
|
|
</div>
|
|
${scope === "video" && folders.length ? `<select class="select" id="folder-filter">
|
|
<option value="">全部收藏夹</option>
|
|
${folders.map((item) => `<option value="${item.media_id}"
|
|
${String(folder) === String(item.media_id) ? "selected" : ""}>${escapeHtml(item.title || item.year)}</option>`).join("")}
|
|
</select>` : ""}
|
|
${scope === "up" ? `<select class="select" id="min-videos">
|
|
<option value="1" ${String(minVideos) === "1" ? "selected" : ""}>全部 UP</option>
|
|
<option value="2" ${String(minVideos) === "2" ? "selected" : ""}>≥ 2 条视频</option>
|
|
<option value="5" ${String(minVideos) === "5" ? "selected" : ""}>≥ 5 条视频</option>
|
|
</select>` : ""}
|
|
</div>
|
|
${scope === "video" && (sort === "growth" || sort === "growth_rate") ? `
|
|
<div class="chip-row growth-days">
|
|
<span class="chip-label">增长区间</span>
|
|
${[[1, "1 天"], [7, "7 天"], [30, "30 天"]]
|
|
.map(([value, label]) => chip(label, Number(growthDays) === value, "growth-days", value)).join("")}
|
|
</div>` : ""}
|
|
`;
|
|
}
|
|
|
|
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 ? "当前榜单暂不可用" : (scope === "up" ? "没有匹配的 UP" : "没有匹配的视频"),
|
|
data.reason || "换个筛选条件或清空搜索关键词试试。",
|
|
);
|
|
}
|
|
if (scope === "up") {
|
|
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>`;
|
|
}
|
|
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>`;
|
|
}
|
|
|
|
function durationNote(data) {
|
|
const filter = data.duration_filter;
|
|
if (!filter) return "";
|
|
return `<div class="section-note">内容效率与互动效率仅统计时长 ≥ ${filter.min_duration_seconds} 秒、
|
|
发布满 ${filter.maturity_days} 天的视频;时长缺失的视频不参与排序。</div>`;
|
|
}
|
|
|
|
function bindControls(root, scope) {
|
|
root.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => {
|
|
replaceQuery({ sort: button.dataset.sort, page: null });
|
|
}));
|
|
root.querySelectorAll("[data-within]").forEach((button) => button.addEventListener("click", () => {
|
|
replaceQuery({ within_days: button.dataset.within === "all" ? null : button.dataset.within, page: null });
|
|
}));
|
|
root.querySelectorAll("[data-growth-days]").forEach((button) => button.addEventListener("click", () => {
|
|
replaceQuery({ growth_days: button.dataset.growthDays, page: null });
|
|
}));
|
|
root.querySelector("#folder-filter")?.addEventListener("change", (event) => {
|
|
replaceQuery({ folder: event.target.value || null, page: null });
|
|
});
|
|
root.querySelector("#min-videos")?.addEventListener("change", (event) => {
|
|
replaceQuery({ min_videos: event.target.value, page: null });
|
|
});
|
|
root.querySelector("#rank-filter")?.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const value = new FormData(event.target).get("q") || "";
|
|
replaceQuery({ q: String(value).trim() || null, page: null });
|
|
});
|
|
root.querySelectorAll("[data-page]").forEach((button) => button.addEventListener("click", () => {
|
|
if (button.disabled) return;
|
|
replaceQuery({ page: button.dataset.page });
|
|
}));
|
|
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");
|
|
}
|