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.
207 lines
8.6 KiB
JavaScript
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>`;
|
|
}
|