132 lines
6.4 KiB
JavaScript
132 lines
6.4 KiB
JavaScript
import { api } from "../api.js";
|
|
import {
|
|
badge, centerActiveChips, cover, emptyState, metricChips, metricGrid,
|
|
sectionHeader, videoCard,
|
|
} from "../components.js";
|
|
import { lineChart, themeColor } from "../charts.js";
|
|
import {
|
|
escapeAttr, escapeHtml, formatCount, formatDate, formatDelta,
|
|
formatDuration, formatNumber, formatPercent, formatScore,
|
|
metricLabel, statusLabel,
|
|
} from "../format.js";
|
|
import { replaceQuery } from "../router.js";
|
|
|
|
const METRICS = ["view", "like", "coin", "favorite", "share", "reply", "danmaku"];
|
|
const RANGES = [["7", "7 天"], ["30", "30 天"], ["90", "90 天"], ["all", "全部"]];
|
|
|
|
function snapshotTable(points) {
|
|
if (!points.length) return emptyState("暂无快照", "该视频还没有成功采集的数据。");
|
|
return `<div class="table-wrap">
|
|
<table class="data-table">
|
|
<thead><tr><th>日期</th>${METRICS.map((metric) => `<th>${metricLabel(metric)}</th>`).join("")}<th>状态</th></tr></thead>
|
|
<tbody>
|
|
${points.slice().reverse().map((point) => `
|
|
<tr class="${point.ok ? "" : "failed-row"}">
|
|
<td>${escapeHtml(point.stat_date)}</td>
|
|
${METRICS.map((metric) => `<td>${point.ok ? formatNumber(point[metric]) : "—"}</td>`).join("")}
|
|
<td>${point.ok ? "成功" : `<span class="danger">失败</span>`}</td>
|
|
</tr>`).join("")}
|
|
</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
export async function renderVideo(root, { params, query }) {
|
|
const aid = Number(params[0]);
|
|
const metric = METRICS.includes(query.get("metric")) ? query.get("metric") : "view";
|
|
const range = query.get("range") || "30";
|
|
const trendDays = range === "all" ? "all" : range;
|
|
const [detail, trend] = await Promise.all([
|
|
api(`/api/videos/${aid}`),
|
|
api(`/api/videos/${aid}/trend`, { range: trendDays }),
|
|
]);
|
|
const up = detail.up_mid ? await api(`/api/ups/${detail.up_mid}`) : null;
|
|
const samples = detail.snapshot_count || 0;
|
|
const others = up?.videos?.filter((item) => item.aid !== aid).slice(0, 5) || [];
|
|
|
|
root.innerHTML = `<section class="page video-page">
|
|
<header class="page-head">
|
|
<a class="back-link" href="/videos">← 返回视频榜</a>
|
|
<div class="head-actions">
|
|
${detail.url ? `<a class="button ghost" href="${escapeAttr(detail.url)}" target="_blank" rel="noreferrer">B 站观看</a>` : ""}
|
|
</div>
|
|
</header>
|
|
|
|
<div class="detail-hero">
|
|
${cover(detail, "hero-cover")}
|
|
<div class="detail-hero-body">
|
|
<h1>${escapeHtml(detail.title)}</h1>
|
|
<div class="detail-meta">
|
|
${detail.up_mid ? `<a class="inline-link" href="/up/${detail.up_mid}">${escapeHtml(detail.up_name || "未知 UP")}</a>` : escapeHtml(detail.up_name || "未知 UP")}
|
|
<span>${escapeHtml(formatDate(detail.pubdate))}</span>
|
|
<span>${escapeHtml(formatDuration(detail.duration))}</span>
|
|
${detail.pages ? `<span>${formatNumber(detail.pages)} P</span>` : ""}
|
|
${badge(statusLabel(detail.status), `status-${detail.status}`)}
|
|
${samples < 2 ? badge("样本不足", "warning") : ""}
|
|
</div>
|
|
${detail.blacklisted ? `<p class="section-note warning-text">该 UP 在黑名单中;黑名单只影响未来发现,不影响当前视频追踪。</p>` : ""}
|
|
${detail.up_follower_backfilled ? `<p class="section-note">UP 粉丝数为回填值,不是真实的入库时点。</p>` : ""}
|
|
</div>
|
|
</div>
|
|
|
|
${metricGrid(detail)}
|
|
|
|
<div class="mini-stats">
|
|
<div><span>互动率</span><strong>${formatPercent(detail.interaction_rate)}</strong></div>
|
|
<div><span>综合热度</span><strong>${formatScore(detail.heat)}</strong></div>
|
|
<div><span>全库百分位</span><strong>${detail.heat_percentile === null || detail.heat_percentile === undefined ? "—" : `前 ${Math.max(0, 100 - detail.heat_percentile).toFixed(0)}%`}</strong></div>
|
|
<div><span>播放日增</span><strong>${detail.growth?.available ? formatDelta(detail.growth.delta) : "—"}</strong></div>
|
|
<div><span>内容效率(原始)</span><strong>${detail.efficiency == null ? "—" : `${formatCount(detail.efficiency)}/小时`}</strong></div>
|
|
<div><span>互动效率(原始)</span><strong>${detail.interaction_efficiency == null ? "—" : `${formatCount(detail.interaction_efficiency)}/小时`}</strong></div>
|
|
<div><span>UP 粉丝(入库时)</span><strong>${formatCount(detail.up_follower)}</strong></div>
|
|
<div><span>快照数</span><strong>${samples}</strong></div>
|
|
<div><span>最近快照</span><strong>${escapeHtml(detail.snapshot_date || "—")}</strong></div>
|
|
</div>
|
|
|
|
<section class="panel chart-panel">
|
|
${sectionHeader("七项趋势")}
|
|
<div class="chip-row scroll-x">${metricChips(METRICS, metric)}</div>
|
|
<div class="chip-row scroll-x range-row">
|
|
${RANGES.map(([value, label]) => `<button type="button"
|
|
class="chip${range === value ? " active" : ""}" data-range="${value}">${label}</button>`).join("")}
|
|
</div>
|
|
${trend.available ? `<div class="chart" id="video-trend"></div>` : emptyState("暂无趋势数据", "该视频还没有成功快照。")}
|
|
${!trend.available ? "" : `<p class="section-note">缺失日期会断开,不插值;失败快照按缺失处理。纵轴按数据范围缩放,精确值见下方快照记录。</p>`}
|
|
</section>
|
|
|
|
<section class="panel">
|
|
${sectionHeader("快照记录")}
|
|
${snapshotTable(trend.points || [])}
|
|
</section>
|
|
|
|
${up ? `<section class="panel">
|
|
<div class="section-head">
|
|
<h2>该 UP 的其他视频</h2>
|
|
<a class="text-link" href="/up/${detail.up_mid}">查看 UP 页 →</a>
|
|
</div>
|
|
<div class="video-list">
|
|
${others.length ? others.map((item) => videoCard(item)).join("") : emptyState("没有其他视频")}
|
|
</div>
|
|
</section>` : ""}
|
|
</section>`;
|
|
|
|
root.querySelectorAll("[data-metric]").forEach((button) => button.addEventListener("click", () => {
|
|
replaceQuery({ metric: button.dataset.metric });
|
|
}));
|
|
root.querySelectorAll("[data-range]").forEach((button) => button.addEventListener("click", () => {
|
|
replaceQuery({ range: button.dataset.range });
|
|
}));
|
|
centerActiveChips(root);
|
|
|
|
const chartElement = root.querySelector("#video-trend");
|
|
if (chartElement) {
|
|
lineChart(chartElement, {
|
|
dates: trend.dates || [],
|
|
values: trend.series?.[metric] || [],
|
|
name: metricLabel(metric),
|
|
color: metric === "view" ? themeColor("accent") : themeColor("blue"),
|
|
scale: true,
|
|
});
|
|
}
|
|
}
|