Drop nested home panels, overlay rank and duration on covers, and keep metrics fully visible by moving dates onto the author row.
195 lines
7.9 KiB
JavaScript
195 lines
7.9 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, 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>`;
|
|
}
|
|
|
|
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 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 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>` : "";
|
|
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>
|
|
${delta !== null ? `<span class="delta ${delta >= 0 ? "up" : "down"}">${formatDelta(delta)}</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)}</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>`;
|
|
}
|