154 lines
6.1 KiB
JavaScript
154 lines
6.1 KiB
JavaScript
import {
|
||
coverUrl, escapeAttr, escapeHtml, formatCount, formatDate,
|
||
formatDelta, formatDuration, formatPercent, 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) {
|
||
if (!rank) return "";
|
||
const top = rank <= 3 ? ` rank-${rank}` : "";
|
||
return `<span class="rank-badge${top}">${rank}</span>`;
|
||
}
|
||
|
||
export function videoCard(item, {
|
||
rank = null, primary = "view", primaryLabel = "", primaryText = null,
|
||
showStatus = false,
|
||
} = {}) {
|
||
const metrics = item.metrics || {};
|
||
const hasMetric = Object.prototype.hasOwnProperty.call(metrics, primary);
|
||
const primaryValue = primaryText !== null
|
||
? primaryText
|
||
: (hasMetric ? metrics[primary] : item[primary]);
|
||
const label = primaryLabel || metricLabel(primary);
|
||
const growth = item.growth;
|
||
const delta = growth?.available ? growth.delta : null;
|
||
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>` : "";
|
||
return `
|
||
<a class="video-card${rank ? "" : " no-rank"}" href="/video/${item.aid}" data-aid="${item.aid}">
|
||
${rankBadge(rank)}
|
||
${cover(item, "video-thumb")}
|
||
<div class="video-card-body">
|
||
<h3 class="video-title">${title}</h3>
|
||
<div class="video-meta">
|
||
<span>${upLink}</span>
|
||
<span>${escapeHtml(formatDate(item.pubdate))}</span>
|
||
<span>${escapeHtml(formatDuration(item.duration))}</span>
|
||
${status}
|
||
</div>
|
||
<div class="video-stats">
|
||
<span class="primary-stat">
|
||
<b>${escapeHtml(primaryValue ?? "—")}</b><small>${escapeHtml(label)}</small>
|
||
</span>
|
||
${delta !== null ? `<span class="delta ${delta >= 0 ? "up" : "down"}">${formatDelta(delta)}</span>` : ""}
|
||
<span class="secondary-stat">互动 ${formatPercent(item.interaction_rate)}</span>
|
||
</div>
|
||
</div>
|
||
<span class="card-arrow" aria-hidden="true">›</span>
|
||
</a>`;
|
||
}
|
||
|
||
export function upCard(item, {
|
||
rank = null, primary = "efficiency", primaryLabel = "播放/小时", primaryText = null,
|
||
} = {}) {
|
||
const best = item.best_video || {};
|
||
const displayCover = cover(
|
||
{ title: best.title || item.up_name, cover: best.cover },
|
||
"video-thumb up-thumb",
|
||
);
|
||
const value = primaryText !== null ? primaryText : item[primary];
|
||
const hours = item.content_hours;
|
||
return `
|
||
<a class="up-card" href="/up/${item.up_mid}">
|
||
${rankBadge(rank)}
|
||
${displayCover}
|
||
<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>
|
||
</div>
|
||
<div class="video-stats">
|
||
<span class="primary-stat"><b>${escapeHtml(value ?? "—")}</b><small>${escapeHtml(primaryLabel)}</small></span>
|
||
<span class="secondary-stat">总播放 ${formatCount(item.total_views)}</span>
|
||
</div>
|
||
</div>
|
||
<span class="card-arrow" aria-hidden="true">›</span>
|
||
</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)}</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 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>`;
|
||
}
|