feat(web): add mobile-first read-only dashboard frontend

This commit is contained in:
kairee committed 2026-10-31 23:46:55 +08:00
1 parent 257fbe009b
commit cbba405271
18 files changed
+2628 -2

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
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", showStatus = false } = {}) {
const metrics = item.metrics || {};
const primaryValue = metrics[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>${formatCount(primaryValue)}</b><small>${escapeHtml(metricLabel(primary))}</small>
</span>
${delta !== null ? `<span class="delta ${delta >= 0 ? "up" : "down"}">${formatDelta(delta)}</span>` : ""}
<span class="secondary-stat">赞 ${formatCount(metrics.like)}</span>
<span class="secondary-stat optional">币 ${formatCount(metrics.coin)}</span>
<span class="secondary-stat optional">藏 ${formatCount(metrics.favorite)}</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 } = {}) {
const best = item.best_video || {};
const displayCover = cover(
{ title: best.title || item.up_name, cover: best.cover },
"video-thumb up-thumb",
);
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>均播 ${formatCount(item.avg_views)}</span>
</div>
<div class="video-stats">
<span class="primary-stat"><b>${formatCount(item.total_views)}</b><small>总播放</small></span>
<span class="secondary-stat">总互动 ${formatCount(item.total_interactions)}</span>
<span class="secondary-stat">均互动 ${formatPercent(item.avg_interaction)}</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 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>`;
}