Keep small-base videos on the delta board only, and raise the smoothing prior to 1000 so videos that just clear the floor do not dominate the ranking.
282 lines
11 KiB
JavaScript
282 lines
11 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", "综合热度"],
|
|
["published", "发布时间"],
|
|
["added", "入库时间"],
|
|
["view", "播放"],
|
|
["coin", "投币"],
|
|
["favorite", "收藏"],
|
|
["reply", "评论"],
|
|
["interaction", "互动率"],
|
|
["growth", "播放增量"],
|
|
["growth_rate", "播放增速"],
|
|
];
|
|
const UP_SORTS = [
|
|
["efficiency", "内容效率"],
|
|
["interaction_efficiency", "互动效率"],
|
|
["duration", "内容量"],
|
|
["views", "总播放"],
|
|
["count", "视频数"],
|
|
["avg_views", "平均播放"],
|
|
["interaction", "总互动"],
|
|
["latest", "最近发布"],
|
|
];
|
|
const TIME_RANGES = [
|
|
["all", "全部时间"],
|
|
["7", "近 7 天"],
|
|
["30", "近 30 天"],
|
|
["90", "近 90 天"],
|
|
["365", "近 1 年"],
|
|
];
|
|
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 sortOptions(scope, sort) {
|
|
const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS;
|
|
return sorts.map(([value, label]) =>
|
|
`<option value="${value}" ${value === sort ? "selected" : ""}>${escapeHtml(label)}</option>`).join("");
|
|
}
|
|
|
|
function timeOptions(within, year, years) {
|
|
const selected = year ? `year:${year}` : (within || "all");
|
|
const base = TIME_RANGES.map(([value, label]) =>
|
|
`<option value="${value}" ${selected === value ? "selected" : ""}>${escapeHtml(label)}</option>`).join("");
|
|
const yearOptions = (years || []).map((value) =>
|
|
`<option value="year:${value}" ${selected === `year:${value}` ? "selected" : ""}>${value} 年</option>`).join("");
|
|
return base + yearOptions;
|
|
}
|
|
|
|
function controls({ scope, sort, within, year, growthDays, q, years }) {
|
|
return `
|
|
<div class="rank-toolbar">
|
|
<label class="select-label">排序
|
|
<select class="select" id="rank-sort">${sortOptions(scope, sort)}</select>
|
|
</label>
|
|
<select class="select" id="rank-time" aria-label="时间范围" title="时间范围">
|
|
${timeOptions(within, year, years)}
|
|
</select>
|
|
<form class="rank-search" id="rank-filter">
|
|
<span class="search-box">
|
|
<input class="search-input" type="search" name="q" value="${escapeAttr(q || "")}"
|
|
placeholder="${scope === "up" ? "搜索 UP 名" : "搜索标题或 UP"}" autocomplete="off">
|
|
<button class="search-button" type="submit" aria-label="搜索" title="搜索">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
|
|
stroke-linecap="round" aria-hidden="true">
|
|
<circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.2-3.2"></path>
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
</form>
|
|
</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_score", label: "播放/小时", text: formatCount(item.efficiency_score) };
|
|
}
|
|
if (sort === "interaction_efficiency") {
|
|
return { primary: "interaction_efficiency_score", label: "互动/小时", text: formatCount(item.interaction_efficiency_score) };
|
|
}
|
|
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 upSecondary(item, sort) {
|
|
// “总播放”排序时主指标已是总播放,副指标改为平均播放,避免重复。
|
|
if (sort === "views") {
|
|
return { label: "平均播放", text: formatCount(item.avg_views) };
|
|
}
|
|
return { label: "总播放", text: formatCount(item.total_views) };
|
|
}
|
|
|
|
function listHtml(data, scope, sort, startRank) {
|
|
if (!data.items.length) {
|
|
const unavailable = data.available === false;
|
|
const hint = "换个筛选条件或清空搜索关键词试试。";
|
|
return emptyState(
|
|
unavailable ? "当前榜单暂不可用" : (scope === "up" ? "没有匹配的 UP" : "没有匹配的视频"),
|
|
unavailable ? hint : (data.reason || hint),
|
|
);
|
|
}
|
|
if (scope === "up") {
|
|
return `<div class="rank-list">${data.items.map((item, index) => {
|
|
const primary = upPrimary(item, sort);
|
|
const secondary = upSecondary(item, sort);
|
|
return upCard(item, {
|
|
rank: startRank + index,
|
|
primaryLabel: primary.label, primaryText: primary.text,
|
|
secondaryLabel: secondary.label, secondaryText: secondary.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,
|
|
growthField: sort === "growth_rate" ? "rate" : "delta",
|
|
});
|
|
}).join("")}</div>`;
|
|
}
|
|
|
|
function durationNote(data) {
|
|
const filter = data.duration_filter;
|
|
if (!filter) return "";
|
|
const shrink = filter.shrink_minutes
|
|
? `,按 ${filter.shrink_minutes} 分钟先验向全库均值收缩`
|
|
: "";
|
|
return `<div class="section-note">内容效率与互动效率:时长 ≥ ${filter.min_duration_seconds} 秒、发布满 ${filter.maturity_days} 天、播放 ≥ ${filter.min_view}${shrink};时长缺失不参与。</div>`;
|
|
}
|
|
|
|
function growthNote(sort, growthDays, prior) {
|
|
if (sort === "growth_rate") {
|
|
const strength = Number(prior);
|
|
const smooth = Number.isFinite(strength)
|
|
? `百分比是真实相对增量;排序按平滑增速(基线 + ${strength})。`
|
|
: "百分比是真实相对增量;排序按平滑增速。";
|
|
const cold = Number(growthDays) === 1
|
|
? "基线播放不足 1000、以及当日发布,不进增速榜。"
|
|
: "基线播放不足 1000 不进增速榜。";
|
|
return `<div class="section-note">${smooth}${cold}</div>`;
|
|
}
|
|
if (sort !== "growth" || Number(growthDays) !== 1) return "";
|
|
return `<div class="section-note">近 1 日:当日发布按 0 起算;更早发布的新入库按入库时播放量起算,需要当天至少两次采样。</div>`;
|
|
}
|
|
|
|
function bindControls(root) {
|
|
root.querySelector("#rank-sort")?.addEventListener("change", (event) => {
|
|
replaceQuery({ sort: event.target.value, page: null });
|
|
});
|
|
root.querySelector("#rank-time")?.addEventListener("change", (event) => {
|
|
const value = event.target.value;
|
|
if (value.startsWith("year:")) {
|
|
replaceQuery({ year: value.slice(5), within_days: null, page: null });
|
|
} else if (value === "all") {
|
|
replaceQuery({ year: null, within_days: null, page: null });
|
|
} else {
|
|
replaceQuery({ year: null, within_days: value, page: null });
|
|
}
|
|
});
|
|
root.querySelectorAll("[data-growth-days]").forEach((button) => button.addEventListener("click", () => {
|
|
replaceQuery({ growth_days: button.dataset.growthDays, 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 sort = query.get("sort") || "efficiency";
|
|
const within = query.get("within_days") || "";
|
|
const year = query.get("year") || "";
|
|
const growthDays = query.get("growth_days") || "7";
|
|
const q = query.get("q") || "";
|
|
const page = Math.max(1, Number(query.get("page") || 1));
|
|
|
|
const overview = await getOverview();
|
|
const years = overview.years || [];
|
|
|
|
const params = {
|
|
sort,
|
|
page,
|
|
page_size: 20,
|
|
within_days: within || null,
|
|
year: year || null,
|
|
growth_days: growthDays,
|
|
q,
|
|
};
|
|
let data;
|
|
if (scope === "up") {
|
|
data = await api("/api/ups", params);
|
|
} else {
|
|
data = await api("/api/videos", params);
|
|
}
|
|
|
|
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, year, growthDays, q, years })}
|
|
${durationNote(data)}
|
|
${growthNote(sort, growthDays, data.growth_rate_prior)}
|
|
${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);
|
|
}
|
|
|
|
export function renderVideos(root, context) {
|
|
return renderScope(root, context, "video");
|
|
}
|
|
|
|
export function renderUps(root, context) {
|
|
return renderScope(root, context, "up");
|
|
} |