feat(web): add mobile-first read-only dashboard frontend
This commit is contained in:
1 parent
257fbe009b
commit
cbba405271
18 files changed
+2628
-2
No files matched your search
@@ -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>`;
|
||||
}
|
||||
Reference in new issue
Block a user