Files
re-xxm/reaction_board/web/static/components.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

207 lines
8.6 KiB
JavaScript

import {
coverUrl, escapeAttr, escapeHtml, formatCount, formatDate,
formatDelta, formatDuration, formatPercent, formatSignedPercent, metricLabel, statusLabel,
} from "./format.js";
export function cover(item, className = "thumb") {
const url = coverUrl(item.cover);
const title = escapeAttr(item.title || "视频封面");
if (!url) {
return `<div class="${className} cover-placeholder" aria-hidden="true">无封面</div>`;
}
return `<div class="${className}"><img src="${escapeAttr(url)}" alt="${title}"
loading="lazy" referrerpolicy="no-referrer"></div>`;
}
export function rankBadge(rank, className = "rank-badge") {
if (!rank) return "";
const top = rank <= 3 ? ` rank-${rank}` : "";
return `<span class="${className}${top}">${rank}</span>`;
}
export function avatar(item, className = "avatar") {
const url = coverUrl(item?.up_face || item?.avatar);
const name = escapeHtml(item?.up_name || "UP");
if (!url) {
const initial = String(item?.up_name || "U").trim().slice(0, 1).toUpperCase() || "U";
return `<div class="${className} avatar-placeholder" aria-hidden="true">${escapeHtml(initial)}</div>`;
}
return `<div class="${className}"><img src="${escapeAttr(url)}" alt="${name}"
loading="lazy" referrerpolicy="no-referrer"></div>`;
}
function growthBaselineTag(growth) {
if (growth?.baseline === "zero") return "<small>今日</small>";
if (growth?.baseline === "open") return "<small>入库后</small>";
return "";
}
export function videoCard(item, {
rank = null, primary = "view", primaryLabel = "", primaryText = null,
showStatus = false, growthField = "delta",
} = {}) {
const metrics = item.metrics || {};
const hasMetric = Object.prototype.hasOwnProperty.call(metrics, primary);
const raw = primaryText !== null
? primaryText
: (hasMetric ? metrics[primary] : item[primary]);
// 指标排序直接返回原始计数,统一走 formatCount,避免同一列混排 “36375” 与 “1.1万”。
const primaryValue = typeof raw === "number" ? formatCount(raw) : raw;
const label = primaryLabel || metricLabel(primary);
const growth = item.growth;
const showRate = growthField === "rate";
const rate = showRate && growth?.available ? growth.rate : null;
const delta = !showRate && growth?.available ? growth.delta : null;
const change = showRate
? (rate === null || rate === undefined ? null : formatSignedPercent(rate))
: (delta === null || delta === undefined ? null : formatDelta(delta));
const changeUp = showRate ? Number(rate) >= 0 : Number(delta) >= 0;
const title = escapeHtml(item.title || "(无标题)");
const up = escapeHtml(item.up_name || "未知 UP");
const upMid = item.up_mid;
// 卡片本身已经是 <a>,这里不能再嵌套链接,否则 HTML 解析会截断卡片。
const upLink = upMid ? `<span class="up-name" data-up-mid="${upMid}">${up}</span>` : up;
const status = showStatus && item.status
? `<span class="badge status-${escapeAttr(item.status)}">${escapeHtml(statusLabel(item.status))}</span>` : "";
const duration = formatDuration(item.duration);
const durationBadge = duration === "—"
? ""
: `<span class="cover-duration">${escapeHtml(duration)}</span>`;
return `
<a class="video-card" href="/video/${item.aid}" data-aid="${item.aid}">
${rankBadge(rank)}
<div class="video-cover">
${cover(item, "video-thumb")}
${durationBadge}
</div>
<div class="video-card-body">
<h3 class="video-title">${title}</h3>
<div class="video-byline">
<div class="video-up">${upLink}</div>
<span class="video-date">${escapeHtml(formatDate(item.pubdate))}</span>
</div>
<div class="video-stats">
${status}
<span class="primary-stat">
<b>${escapeHtml(primaryValue ?? "—")}</b><small>${escapeHtml(label)}</small>
</span>
${change !== null ? `<span class="delta ${changeUp ? "up" : "down"}">${change}${growthBaselineTag(growth)}</span>` : ""}
<span class="secondary-stat">互动 ${formatPercent(item.interaction_rate)}</span>
</div>
</div>
</a>`;
}
export function upCard(item, {
rank = null, primary = "efficiency", primaryLabel = "播放/小时", primaryText = null,
secondaryLabel = "总播放", secondaryText = null,
} = {}) {
const value = primaryText !== null ? primaryText : item[primary];
const secondary = secondaryText !== null ? secondaryText : formatCount(item.total_views);
const hours = item.content_hours;
const follower = item.follower_latest;
const followerText = follower === null || follower === undefined
? "粉丝 —"
: `粉丝 ${formatCount(follower)}`;
return `
<a class="up-card" href="/up/${item.up_mid}">
${rankBadge(rank)}
<div class="up-cover">
${avatar(item, "up-avatar")}
</div>
<div class="up-card-body">
<h3 class="video-title">${escapeHtml(item.up_name || `UP ${item.up_mid}`)}</h3>
<div class="video-meta">
<span>${formatNumber(item.video_count)} 条视频</span>
<span>${hours === null || hours === undefined ? "时长未知" : `${formatNumber(hours)} 小时`}</span>
<span>${followerText}</span>
</div>
<div class="video-stats">
<span class="primary-stat"><b>${escapeHtml(value ?? "—")}</b><small>${escapeHtml(primaryLabel)}</small></span>
<span class="secondary-stat">${escapeHtml(secondaryLabel)} ${escapeHtml(secondary)}</span>
</div>
</div>
</a>`;
}
export function metricGrid(item) {
const metrics = item.metrics || {};
const growth = item.growth;
return `<div class="metric-grid">
${["view", "like", "coin", "favorite", "share", "reply", "danmaku"].map((metric) => {
const extra = metric === "view" && growth?.available
? `<span class="metric-delta ${growth.delta >= 0 ? "up" : "down"}">${formatDelta(growth.delta)}${growthBaselineTag(growth)}</span>`
: "";
return `<div class="metric-cell">
<span class="metric-label">${escapeHtml(metricLabel(metric))}</span>
<strong>${formatCount(metrics[metric])}</strong>${extra}
</div>`;
}).join("")}
</div>`;
}
export function mediaCard(item, { rank = null, highlight = "" } = {}) {
const duration = formatDuration(item.duration);
const durationBadge = duration === "—"
? ""
: `<span class="cover-duration">${escapeHtml(duration)}</span>`;
return `<a class="media-card" href="/video/${item.aid}" data-aid="${item.aid}">
${rankBadge(rank)}
<div class="media-cover">
${cover(item, "media-thumb")}
${durationBadge}
</div>
<span class="media-title">${escapeHtml(item.title || "(无标题)")}</span>
<span class="media-byline">
<span class="media-up">${escapeHtml(item.up_name || "未知 UP")}</span>
<span class="media-date">${escapeHtml(formatDate(item.pubdate))}</span>
</span>
${highlight ? `<span class="media-highlight">${highlight}</span>` : ""}
</a>`;
}
export function sectionHeader(title, extra = "") {
return `<div class="section-head"><h2>${escapeHtml(title)}</h2>${extra}</div>`;
}
export function emptyState(title, message = "", action = "") {
return `<div class="empty-state"><strong>${escapeHtml(title)}</strong>
${message ? `<p>${escapeHtml(message)}</p>` : ""}${action}</div>`;
}
export function badge(text, kind = "subtle") {
return `<span class="badge ${kind}">${escapeHtml(text)}</span>`;
}
export function chip(label, active, data, value = "") {
return `<button type="button" class="chip${active ? " active" : ""}" data-${data}="${escapeAttr(value)}">${escapeHtml(label)}</button>`;
}
export function formatNumber(value) {
return value === null || value === undefined ? "—" : Number(value).toLocaleString("zh-CN");
}
export function metricChips(metrics, active, dataName = "metric") {
return metrics.map((metric) => chip(
metricLabel(metric), metric === active, dataName, metric,
)).join("");
}
export function centerActiveChips(root) {
root.querySelectorAll(".chip-row.scroll-x").forEach((row) => {
const active = row.querySelector(".chip.active");
if (!active) return;
const target = active.offsetLeft - row.clientWidth / 2 + active.clientWidth / 2;
row.scrollLeft = Math.max(0, target);
});
}
export function pager(page, pages) {
if (!pages || pages <= 1) return "";
return `<div class="pager">
<button type="button" class="button ghost" data-page="${page - 1}" ${page <= 1 ? "disabled" : ""}>上一页</button>
<span>第 ${page} / ${pages} 页</span>
<button type="button" class="button ghost" data-page="${page + 1}" ${page >= pages ? "disabled" : ""}>下一页</button>
</div>`;
}