Files
re-xxm/reaction_board/web/static/views/rankings.js
T
kairee 39a6537f67 fix(growth): rank the rate board by a smoothed score
Show the real percentage on the card, but sort with delta / (baseline + prior) so a small baseline cannot dominate. Same-day publishes stay off the rate board.
2026-11-02 22:06:41 +08:00

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
? "当日发布不进增速榜。更早发布的新入库按入库时播放量起算,需要当天至少两次采样。"
: "";
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");
}