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

@@ -0,0 +1,74 @@
import { api } from "../api.js";
import { cover, emptyState, sectionHeader } from "../components.js";
import {
escapeAttr, escapeHtml, formatCount, formatDate, formatDuration,
} from "../format.js";
function candidateCard(item) {
const score = Number(item.score || 0);
return `<article class="candidate-card">
${cover(item, "candidate-thumb")}
<div class="candidate-body">
<div class="candidate-top">
<span class="score-badge ${score >= 4 ? "high" : score >= 2 ? "mid" : "low"}">score ${score}</span>
${item.notified_at ? `<span class="badge subtle">已通知</span>` : `<span class="badge warning">未通知</span>`}
<span class="badge subtle">剩余 ${item.days_left} 天</span>
</div>
<h3 class="video-title">${escapeHtml(item.title || "(无标题)")}</h3>
<div class="video-meta">
<span>${escapeHtml(item.up_name || "未知 UP")}</span>
<span>发布 ${escapeHtml(formatDate(item.pubdate))}</span>
<span>${escapeHtml(formatDuration(item.duration))}</span>
<span>${escapeHtml(item.channel || "")}</span>
</div>
<div class="video-stats">
<span class="secondary-stat">播放 ${formatCount(item.play)}</span>
<span class="secondary-stat">点赞 ${formatCount(item.like)}</span>
<span class="secondary-stat">收藏 ${formatCount(item.favorite)}</span>
<span class="secondary-stat">弹幕 ${formatCount(item.danmaku)}</span>
<span class="secondary-stat">评论 ${formatCount(item.review)}</span>
</div>
<div class="candidate-actions">
${item.url ? `<a class="button ghost" href="${escapeAttr(item.url)}" target="_blank" rel="noreferrer">打开 B 站</a>` : ""}
<span class="readonly-note">请在 Telegram 中收藏 / 忽略 / 拉黑</span>
</div>
</div>
</article>`;
}
export async function renderCandidates(root) {
const data = await api("/api/candidates");
const pending = data.items.filter((item) => item.decision === null);
const decided = data.items.filter((item) => item.decision !== null);
root.innerHTML = `<section class="page candidates-page">
<header class="page-head">
<div>
<h1>候选池</h1>
<p class="page-sub">${formatCount(data.pending)} 条待确认 · ${formatCount(data.total)} 条总计 · 保留 ${data.ttl_days} 天</p>
</div>
</header>
<div class="notice-row">
<strong>Web 看板只读</strong>
<span>${data.telegram_enabled ? "请在 Telegram 消息中处理候选。" : "Telegram 未启用,请使用 CLI 或数据库处理候选。"}</span>
</div>
<section class="panel">
${sectionHeader("待确认")}
${pending.length ? `<div class="candidate-list">${pending.map(candidateCard).join("")}</div>`
: emptyState("没有待确认候选", "搜索发现达到阈值的视频会出现在这里,同时发送 Telegram 通知。")}
</section>
${decided.length ? `<details class="panel details-panel">
<summary>已处理候选(${decided.length})</summary>
<div class="candidate-list">
${decided.map((item) => `<div class="decided-row">
<span class="badge subtle">${escapeHtml(item.decision)}</span>
<a href="${escapeAttr(item.url || "#")}" target="_blank" rel="noreferrer">${escapeHtml(item.title)}</a>
<span>${escapeHtml(item.up_name || "")}</span>
</div>`).join("")}
</div>
</details>` : ""}
</section>`;
}