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,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>`;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { api } from "../api.js";
|
||||
import { emptyState, sectionHeader } from "../components.js";
|
||||
import { lineChart } from "../charts.js";
|
||||
import {
|
||||
escapeAttr, escapeHtml, formatCount, formatDate, formatDelta,
|
||||
} from "../format.js";
|
||||
import { replaceQuery } from "../router.js";
|
||||
|
||||
const RANGES = [["7", "7 天"], ["30", "30 天"], ["90", "90 天"], ["3650", "全部"]];
|
||||
|
||||
export async function renderFans(root, { query }) {
|
||||
const days = query.get("days") || "90";
|
||||
const mid = query.get("mid");
|
||||
const fans = await api("/api/fans", { days, mid });
|
||||
const options = fans.options || [];
|
||||
|
||||
root.innerHTML = `<section class="page fans-page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>粉丝</h1>
|
||||
<p class="page-sub">${escapeHtml(fans.name || "粉丝追踪")}</p>
|
||||
</div>
|
||||
${options.length > 1 ? `<select class="select" id="fan-mid">
|
||||
${options.map((item) => `<option value="${item.mid}" ${String(fans.mid) === String(item.mid) ? "selected" : ""}>${escapeHtml(item.name)}</option>`).join("")}
|
||||
</select>` : ""}
|
||||
</header>
|
||||
|
||||
${!fans.available ? emptyState("暂无粉丝数据", "每日快照完成后会开始记录粉丝数。")
|
||||
: `
|
||||
<div class="hero-number">
|
||||
<span>当前粉丝</span>
|
||||
<strong>${formatCount(fans.follower)}</strong>
|
||||
<small>${escapeHtml(fans.stat_date || "—")} · 首次记录 ${escapeHtml(fans.first_date || "—")}</small>
|
||||
</div>
|
||||
|
||||
<div class="mini-stats">
|
||||
<div><span>日增</span><strong class="${Number(fans.delta_1d) >= 0 ? "up" : "down"}">${fans.delta_1d === null ? "—" : formatDelta(fans.delta_1d)}</strong></div>
|
||||
<div><span>7 日净增</span><strong class="${Number(fans.delta_7d) >= 0 ? "up" : "down"}">${fans.delta_7d === null ? "—" : formatDelta(fans.delta_7d)}</strong></div>
|
||||
<div><span>累计增长</span><strong class="${Number(fans.delta_cum) >= 0 ? "up" : "down"}">${formatDelta(fans.delta_cum)}</strong></div>
|
||||
<div><span>关注数</span><strong>${formatCount(fans.following)}</strong></div>
|
||||
</div>
|
||||
|
||||
<section class="panel chart-panel">
|
||||
${sectionHeader("粉丝曲线")}
|
||||
<div class="chip-row scroll-x">
|
||||
${RANGES.map(([value, label]) => `<button type="button"
|
||||
class="chip${days === value ? " active" : ""}" data-days="${value}">${label}</button>`).join("")}
|
||||
</div>
|
||||
<div class="chart" id="fans-chart"></div>
|
||||
<p class="section-note">只绘制粉丝数,不叠加 reaction 视频数据;缺失日断开,不插值。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("最近记录")}
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>日期</th><th>粉丝</th><th>关注</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
${(fans.points || []).slice().reverse().map((point) => `
|
||||
<tr class="${point.ok ? "" : "failed-row"}">
|
||||
<td>${escapeHtml(point.stat_date)}</td>
|
||||
<td>${point.ok ? formatCount(point.follower) : "—"}</td>
|
||||
<td>${point.ok ? formatCount(point.following) : "—"}</td>
|
||||
<td>${point.ok ? "成功" : "失败"}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>`}
|
||||
</section>`;
|
||||
|
||||
root.querySelectorAll("[data-days]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({ days: button.dataset.days });
|
||||
}));
|
||||
root.querySelector("#fan-mid")?.addEventListener("change", (event) => {
|
||||
replaceQuery({ mid: event.target.value });
|
||||
});
|
||||
const chartElement = root.querySelector("#fans-chart");
|
||||
if (chartElement) {
|
||||
lineChart(chartElement, {
|
||||
dates: fans.dates || [],
|
||||
values: fans.series || [],
|
||||
name: "粉丝",
|
||||
color: "#9b7bff",
|
||||
scale: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { api } from "../api.js";
|
||||
import {
|
||||
emptyState, cover, sectionHeader, videoCard,
|
||||
} from "../components.js";
|
||||
import {
|
||||
escapeHtml, formatCount, formatDate, formatDelta, formatDateTime,
|
||||
formatPercent, formatScore,
|
||||
} from "../format.js";
|
||||
|
||||
function statChip(label, value, tone = "") {
|
||||
return `<div class="live-chip ${tone}">
|
||||
<span>${escapeHtml(label)}</span><strong>${value}</strong>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function heatStrip(items) {
|
||||
if (!items.length) return emptyState("暂无热度数据", "完成一次快照后这里会出现榜单。");
|
||||
return `<div class="heat-strip">
|
||||
${items.map((item, index) => `
|
||||
<a class="heat-card" href="#/video/${item.aid}">
|
||||
<span class="heat-rank">${index + 1}</span>
|
||||
${cover(item, "heat-thumb")}
|
||||
<strong class="heat-title">${escapeHtml(item.title)}</strong>
|
||||
<span class="heat-meta">${escapeHtml(item.up_name || "未知 UP")} · ${formatCount(item.metrics?.view)} 播放</span>
|
||||
<span class="heat-score">热度 ${formatScore(item.heat)} · 前 ${Math.max(0, 100 - (item.heat_percentile || 0)).toFixed(0)}%</span>
|
||||
</a>`).join("")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function capacity(folders) {
|
||||
if (!folders.length) return "";
|
||||
return `<section class="panel capacity-panel">
|
||||
${sectionHeader("年度收藏夹")}
|
||||
${folders.map((folder) => `
|
||||
<div class="capacity-row">
|
||||
<div class="capacity-label">
|
||||
<span>${escapeHtml(folder.title || String(folder.year))}</span>
|
||||
<strong>${formatCount(folder.media_count)} / ${formatCount(folder.capacity)}</strong>
|
||||
</div>
|
||||
<div class="capacity-track"><span style="width:${Math.min(100, folder.ratio * 100)}%"
|
||||
class="${folder.warning ? "warning" : ""}"></span></div>
|
||||
${folder.warning ? `<p class="warning-text">接近容量上限,请准备新建年度收藏夹。</p>` : ""}
|
||||
</div>`).join("")}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export async function renderHome(root) {
|
||||
const [overview, added, published, heat, growth] = await Promise.all([
|
||||
api("/api/overview"),
|
||||
api("/api/videos", { sort: "added", within_days: 7, limit: 5 }),
|
||||
api("/api/videos", { sort: "published", within_days: 14, limit: 5 }),
|
||||
api("/api/videos", { sort: "heat", limit: 5 }),
|
||||
api("/api/videos", { sort: "growth", growth_days: 7, limit: 3 }),
|
||||
]);
|
||||
|
||||
const pending = overview.counts.candidates_pending || 0;
|
||||
const fans = overview.fans;
|
||||
const firstDay = overview.is_first_day;
|
||||
const addedLabel = firstDay
|
||||
? formatCount(overview.initial_import_count)
|
||||
: formatCount(overview.added_today);
|
||||
const health = overview.snapshot_health || { total: 0, ok: 0, failed: 0 };
|
||||
const addedNote = firstDay
|
||||
? `<p class="section-note">首次导入共 ${formatCount(overview.initial_import_count)} 条,
|
||||
按发布时间倒序展示。后续每日增量从下一次快照开始。</p>`
|
||||
: `<p class="section-note">按入库时间排序,展示最近 7 天新收录的视频。</p>`;
|
||||
|
||||
root.innerHTML = `<section class="page home-page">
|
||||
<header class="page-head home-head">
|
||||
<div>
|
||||
<h1>最新动态</h1>
|
||||
<p class="page-sub">数据 ${escapeHtml(overview.data_date || "—")} ·
|
||||
采集 ${health.ok}/${health.total}${health.failed ? ` · 失败 ${health.failed}` : ""}</p>
|
||||
</div>
|
||||
<button class="icon-button" id="refresh-home" type="button" title="刷新">刷新</button>
|
||||
</header>
|
||||
|
||||
<div class="live-strip">
|
||||
${statChip(firstDay ? "首次导入" : "今日入库", escapeHtml(addedLabel), "accent")}
|
||||
${statChip("播放增量", overview.view_delta_available ? formatDelta(overview.view_delta) : "—")}
|
||||
${statChip("粉丝日增", fans?.delta_1d === null || fans?.delta_1d === undefined ? "—" : formatDelta(fans.delta_1d))}
|
||||
${statChip("待确认候选", formatCount(pending), pending ? "warning" : "")}
|
||||
</div>
|
||||
|
||||
${pending ? `<a class="notice-row" href="#/candidates">
|
||||
<strong>${formatCount(pending)} 条候选待确认</strong>
|
||||
<span>请在 Telegram 中处理 →</span>
|
||||
</a>` : ""}
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>最新入库</h2>
|
||||
<a class="text-link" href="#/rank?scope=video&sort=added&within_days=7">查看全部</a>
|
||||
</div>
|
||||
${addedNote}
|
||||
<div class="video-list">
|
||||
${added.items.length ? added.items.map((item) => videoCard(item)).join("")
|
||||
: emptyState("最近 7 天没有新入库视频", "收藏夹同步或搜索确认后会出现在这里。")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>最近发布</h2>
|
||||
<a class="text-link" href="#/rank?scope=video&sort=published&within_days=14">查看全部</a>
|
||||
</div>
|
||||
<p class="section-note">按视频发布时间排序,与入库时间无关。</p>
|
||||
<div class="video-list">
|
||||
${published.items.length ? published.items.map((item) => videoCard(item)).join("")
|
||||
: emptyState("最近 14 天没有新发布视频", "搜索发现的新视频会逐步补充。")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>增长动态</h2>
|
||||
<a class="text-link" href="#/rank?scope=video&sort=growth&growth_days=7">增长榜</a>
|
||||
</div>
|
||||
${growth.available && growth.items.length
|
||||
? `<div class="video-list">${growth.items.map((item, index) => videoCard(item, { rank: index + 1 })).join("")}</div>`
|
||||
: emptyState("需要至少 2 天快照", growth.reason || "当前只有 1 个数据点,明天开始显示播放增量。")}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>热度速览</h2>
|
||||
<a class="text-link" href="#/rank?scope=video&sort=heat">完整榜单</a>
|
||||
</div>
|
||||
${heatStrip(heat.items)}
|
||||
</section>
|
||||
|
||||
${capacity(overview.folders || [])}
|
||||
|
||||
<section class="panel status-panel">
|
||||
${sectionHeader("采集状态")}
|
||||
<div class="status-grid">
|
||||
<div><span>快照完成</span><strong>${overview.snapshot_completed_today ? "是" : "否"}</strong></div>
|
||||
<div><span>数据日期</span><strong>${escapeHtml(overview.data_date || "—")}</strong></div>
|
||||
<div><span>粉丝</span><strong>${formatCount(fans?.follower)}</strong></div>
|
||||
<div><span>7 日粉丝</span><strong>${fans?.delta_7d === null || fans?.delta_7d === undefined ? "—" : formatDelta(fans.delta_7d)}</strong></div>
|
||||
<div><span>最近快照</span><strong>${escapeHtml(formatDateTime(overview.tasks?.snapshot?.finished_at))}</strong></div>
|
||||
<div><span>最近发现</span><strong>${escapeHtml(formatDateTime(overview.tasks?.discovery?.finished_at))}</strong></div>
|
||||
</div>
|
||||
<a class="text-link" href="#/system">查看系统状态 →</a>
|
||||
</section>
|
||||
</section>`;
|
||||
|
||||
root.querySelector("#refresh-home")?.addEventListener("click", () => window.location.reload());
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { api, apiUrl } from "../api.js";
|
||||
import {
|
||||
chip, emptyState, pager, sectionHeader, upCard, videoCard,
|
||||
} from "../components.js";
|
||||
import { escapeAttr, escapeHtml, formatCount } from "../format.js";
|
||||
import { replaceQuery } from "../router.js";
|
||||
|
||||
const VIDEO_SORTS = [
|
||||
["heat", "综合热度"], ["view", "播放"], ["like", "点赞"], ["coin", "投币"],
|
||||
["favorite", "收藏"], ["share", "转发"], ["reply", "评论"], ["danmaku", "弹幕"],
|
||||
["interaction", "互动率"], ["growth", "增长"], ["growth_rate", "增速"],
|
||||
];
|
||||
const UP_SORTS = [
|
||||
["views", "总播放"], ["count", "视频数"], ["avg_views", "平均播放"],
|
||||
["interaction", "总互动"], ["avg_interaction", "平均互动"], ["latest", "最近发布"],
|
||||
];
|
||||
const METRIC_PRIMARY = new Set(["view", "like", "coin", "favorite", "share", "reply", "danmaku"]);
|
||||
|
||||
function controls({ scope, sort, within, growthDays, q, folder, minVideos, folders }) {
|
||||
const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS;
|
||||
return `
|
||||
<div class="segmented" role="tablist">
|
||||
<button type="button" class="${scope === "video" ? "active" : ""}" data-scope="video">视频</button>
|
||||
<button type="button" class="${scope === "up" ? "active" : ""}" data-scope="up">UP</button>
|
||||
</div>
|
||||
<div class="chip-row scroll-x">
|
||||
${sorts.map(([value, label]) => chip(label, sort === value, "sort", value)).join("")}
|
||||
</div>
|
||||
<form class="filter-bar" id="rank-filter">
|
||||
<input class="search-input" type="search" name="q" value="${escapeAttr(q || "")}"
|
||||
placeholder="${scope === "up" ? "搜索 UP 名" : "搜索标题或 UP"}" autocomplete="off">
|
||||
<button class="button" type="submit">搜索</button>
|
||||
</form>
|
||||
<div class="filter-row">
|
||||
<div class="chip-row scroll-x">
|
||||
${[["all", "全部发布时间"], ["7", "近 7 天"], ["30", "近 30 天"], ["90", "近 90 天"]]
|
||||
.map(([value, label]) => chip(label, String(within || "all") === value, "within", value)).join("")}
|
||||
</div>
|
||||
${scope === "video" && folders.length ? `<select class="select" id="folder-filter">
|
||||
<option value="">全部收藏夹</option>
|
||||
${folders.map((item) => `<option value="${item.media_id}"
|
||||
${String(folder) === String(item.media_id) ? "selected" : ""}>${escapeHtml(item.title || item.year)}</option>`).join("")}
|
||||
</select>` : ""}
|
||||
${scope === "up" ? `<select class="select" id="min-videos">
|
||||
<option value="1" ${String(minVideos) === "1" ? "selected" : ""}>全部 UP</option>
|
||||
<option value="2" ${String(minVideos) === "2" ? "selected" : ""}>≥ 2 条视频</option>
|
||||
<option value="5" ${String(minVideos) === "5" ? "selected" : ""}>≥ 5 条视频</option>
|
||||
</select>` : ""}
|
||||
</div>
|
||||
${scope === "video" && (sort === "growth" || sort === "growth_rate") ? `
|
||||
<div class="chip-row growth-days">
|
||||
<span class="chip-label">增长区间</span>
|
||||
${[[1, "1 天"], [7, "7 天"], [30, "30 天"]]
|
||||
.map(([value, label]) => chip(label, Number(growthDays) === value, "growth-days", value)).join("")}
|
||||
</div>` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
function listHtml(data, scope, sort, startRank) {
|
||||
if (!data.items.length) {
|
||||
return emptyState(
|
||||
data.available === false ? "增长榜暂不可用" : "没有匹配的视频",
|
||||
data.available === false
|
||||
? (data.reason || "需要至少两天的快照。")
|
||||
: "换个筛选条件或清空搜索关键词试试。",
|
||||
);
|
||||
}
|
||||
if (scope === "up") {
|
||||
return `<div class="rank-list">${data.items.map((item, index) => upCard(item, { rank: startRank + index })).join("")}</div>`;
|
||||
}
|
||||
const primary = METRIC_PRIMARY.has(sort) ? sort : "view";
|
||||
return `<div class="rank-list">${data.items.map((item, index) =>
|
||||
videoCard(item, { rank: startRank + index, primary })).join("")}</div>`;
|
||||
}
|
||||
|
||||
export async function renderRankings(root, { query }) {
|
||||
const scope = query.get("scope") === "up" ? "up" : "video";
|
||||
const defaultSort = scope === "up" ? "views" : "heat";
|
||||
const sort = query.get("sort") || defaultSort;
|
||||
const within = query.get("within_days") || "all";
|
||||
const growthDays = query.get("growth_days") || "7";
|
||||
const q = query.get("q") || "";
|
||||
const folder = query.get("folder") || "";
|
||||
const minVideos = query.get("min_videos") || "1";
|
||||
const page = Math.max(1, Number(query.get("page") || 1));
|
||||
|
||||
const overview = await api("/api/overview");
|
||||
const folders = overview.folders || [];
|
||||
|
||||
const params = {
|
||||
sort,
|
||||
page,
|
||||
page_size: 20,
|
||||
within_days: within === "all" ? null : within,
|
||||
growth_days: growthDays,
|
||||
q,
|
||||
};
|
||||
let data;
|
||||
if (scope === "up") {
|
||||
data = await api("/api/rankings", {
|
||||
...params, scope: "up", min_videos: minVideos,
|
||||
});
|
||||
} else {
|
||||
data = await api("/api/videos", { ...params, folder: folder || null });
|
||||
}
|
||||
|
||||
const startRank = (data.page - 1) * data.page_size + 1;
|
||||
const exportParams = {
|
||||
...params, folder: folder || null, scope,
|
||||
min_videos: scope === "up" ? minVideos : null,
|
||||
};
|
||||
delete exportParams.page;
|
||||
delete exportParams.page_size;
|
||||
|
||||
root.innerHTML = `<section class="page rankings-page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>榜单</h1>
|
||||
<p class="page-sub">共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}</p>
|
||||
</div>
|
||||
${scope === "video"
|
||||
? `<a class="button ghost" href="${escapeAttr(apiUrl("/api/export.csv", exportParams))}">导出 CSV</a>`
|
||||
: ""}
|
||||
</header>
|
||||
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
|
||||
${data.available === false ? `<div class="notice-row warning">
|
||||
<strong>增长数据尚不可用</strong><span>${escapeHtml(data.reason || "")}</span>
|
||||
</div>` : ""}
|
||||
${listHtml(data, scope, sort, startRank)}
|
||||
${pager(data.page, data.pages)}
|
||||
</section>`;
|
||||
|
||||
root.querySelectorAll("[data-scope]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({
|
||||
scope: button.dataset.scope,
|
||||
sort: button.dataset.scope === "up" ? "views" : "heat",
|
||||
page: null,
|
||||
});
|
||||
}));
|
||||
root.querySelectorAll("[data-sort]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({ sort: button.dataset.sort, page: null });
|
||||
}));
|
||||
root.querySelectorAll("[data-within]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({ within_days: button.dataset.within === "all" ? null : button.dataset.within, page: null });
|
||||
}));
|
||||
root.querySelectorAll("[data-growth-days]").forEach((button) => button.addEventListener("click", () => {
|
||||
replaceQuery({ growth_days: button.dataset.growthDays, page: null });
|
||||
}));
|
||||
root.querySelector("#folder-filter")?.addEventListener("change", (event) => {
|
||||
replaceQuery({ folder: event.target.value || null, page: null });
|
||||
});
|
||||
root.querySelector("#min-videos")?.addEventListener("change", (event) => {
|
||||
replaceQuery({ min_videos: event.target.value, page: null });
|
||||
});
|
||||
root.querySelector("#rank-filter")?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const value = new FormData(event.target).get("q") || "";
|
||||
replaceQuery({ q: String(value).trim() || null, page: null });
|
||||
});
|
||||
root.querySelectorAll("[data-page]").forEach((button) => button.addEventListener("click", () => {
|
||||
if (button.disabled) return;
|
||||
replaceQuery({ page: button.dataset.page });
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { api } from "../api.js";
|
||||
import { emptyState, sectionHeader } from "../components.js";
|
||||
import {
|
||||
escapeHtml, formatCount, formatDateTime, formatNumber,
|
||||
} from "../format.js";
|
||||
|
||||
function taskRows(tasks) {
|
||||
const entries = Object.entries(tasks || {});
|
||||
if (!entries.length) return emptyState("暂无任务记录", "运行一次同步、快照或发现后会写入最近结果。");
|
||||
return `<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>任务</th><th>结果</th><th>完成时间</th><th>摘要 / 错误</th></tr></thead>
|
||||
<tbody>
|
||||
${entries.map(([name, record]) => {
|
||||
const summary = record.summary && typeof record.summary === "object"
|
||||
? Object.entries(record.summary)
|
||||
.filter(([key]) => ["added", "removed", "candidates", "notified", "ok", "failed", "total", "completed", "backup_error"].includes(key))
|
||||
.map(([key, value]) => `${key}=${value}`).join(" ")
|
||||
: "";
|
||||
return `<tr>
|
||||
<td>${escapeHtml(name)}</td>
|
||||
<td>${record.ok ? "成功" : `<span class="danger">失败</span>`}</td>
|
||||
<td>${escapeHtml(formatDateTime(record.finished_at))}</td>
|
||||
<td class="summary-cell">${escapeHtml(summary || record.error || "—")}</td>
|
||||
</tr>`;
|
||||
}).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export async function renderSystem(root) {
|
||||
const system = await api("/api/system");
|
||||
const health = system.snapshot_health || { total: 0, ok: 0, failed: 0 };
|
||||
const counts = system.counts || {};
|
||||
const next = system.next_runs || {};
|
||||
|
||||
root.innerHTML = `<section class="page system-page">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>系统状态</h1>
|
||||
<p class="page-sub">数据 ${escapeHtml(system.data_date || "—")} · 快照 ${formatCount(system.snapshot_days)} 天</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mini-stats">
|
||||
<div><span>采集成功</span><strong>${formatCount(health.ok)}</strong></div>
|
||||
<div><span>采集失败</span><strong class="${health.failed ? "danger" : ""}">${formatCount(health.failed)}</strong></div>
|
||||
<div><span>在库</span><strong>${formatCount(counts.active)}</strong></div>
|
||||
<div><span>已取消收藏</span><strong>${formatCount(counts.unfaved)}</strong></div>
|
||||
<div><span>已失效</span><strong>${formatCount(counts.removed)}</strong></div>
|
||||
<div><span>已移出库</span><strong>${formatCount(counts.dropped)}</strong></div>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("最近任务")}
|
||||
${taskRows(system.tasks)}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("预计下次运行")}
|
||||
<div class="status-grid">
|
||||
<div><span>收藏夹同步</span><strong>${escapeHtml(formatDateTime(next.folder_sync))}</strong></div>
|
||||
<div><span>搜索发现</span><strong>${escapeHtml(formatDateTime(next.discover))}</strong></div>
|
||||
<div><span>每日快照</span><strong>${escapeHtml(formatDateTime(next.snapshot))}</strong></div>
|
||||
<div><span>调度参数</span><strong>${system.schedule.discover_minutes} / ${system.schedule.folder_sync_minutes} 分钟</strong></div>
|
||||
</div>
|
||||
<p class="section-note">预计时间按配置和最近任务完成时间推算;实际以常驻进程为准。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("收藏夹容量")}
|
||||
${(system.folders || []).map((folder) => `
|
||||
<div class="capacity-row">
|
||||
<div class="capacity-label">
|
||||
<span>${escapeHtml(folder.title || String(folder.year))}</span>
|
||||
<strong>${formatCount(folder.media_count)} / ${formatCount(folder.capacity)}</strong>
|
||||
</div>
|
||||
<div class="capacity-track"><span style="width:${Math.min(100, folder.ratio * 100)}%"
|
||||
class="${folder.warning ? "warning" : ""}"></span></div>
|
||||
<p class="section-note">最近同步 ${escapeHtml(formatDateTime(folder.last_ids_at))}</p>
|
||||
</div>`).join("") || emptyState("未配置收藏夹")}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("授权与备份")}
|
||||
<div class="status-grid">
|
||||
<div><span>登录态</span><strong>${system.auth.ok === "1" ? "正常" : "异常"}</strong></div>
|
||||
<div><span>登录信息</span><strong>${escapeHtml(system.auth.message || "—")}</strong></div>
|
||||
<div><span>最近检查</span><strong>${escapeHtml(formatDateTime(system.auth.last_check))}</strong></div>
|
||||
<div><span>最近备份</span><strong>${system.backup.ok === "1" ? "成功" : "未完成"}</strong></div>
|
||||
<div><span>备份错误</span><strong class="${system.backup.error ? "danger" : ""}">${escapeHtml(system.backup.error || "—")}</strong></div>
|
||||
<div><span>Telegram</span><strong>${system.telegram_enabled ? "已启用" : "未启用"}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${(system.errors || []).length ? `<section class="panel">
|
||||
${sectionHeader("最近错误")}
|
||||
${system.errors.map((error) => `<div class="error-row">
|
||||
<strong>${escapeHtml(error.task)}</strong>
|
||||
<span>${escapeHtml(error.error || "未知错误")}</span>
|
||||
</div>`).join("")}
|
||||
</section>` : ""}
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("UP 黑名单")}
|
||||
<p class="section-note">只影响未来搜索发现;已入库视频继续追踪。Web 只读,移除请使用 CLI 或数据库。</p>
|
||||
${(system.blacklist || []).length ? `<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>mid</th><th>昵称</th><th>原因</th><th>在库视频</th></tr></thead>
|
||||
<tbody>
|
||||
${system.blacklist.map((item) => `<tr>
|
||||
<td>${item.mid}</td>
|
||||
<td>${escapeHtml(item.name || "—")}</td>
|
||||
<td>${escapeHtml(item.reason || "—")}</td>
|
||||
<td>${formatNumber(item.video_count || 0)}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>` : emptyState("黑名单为空")}
|
||||
</section>
|
||||
</section>`;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { api } from "../api.js";
|
||||
import {
|
||||
badge, emptyState, sectionHeader, videoCard,
|
||||
} from "../components.js";
|
||||
import { barChart, lineChart } from "../charts.js";
|
||||
import {
|
||||
escapeAttr, escapeHtml, formatCount, formatDate, formatPercent,
|
||||
} from "../format.js";
|
||||
import { replaceQuery } from "../router.js";
|
||||
|
||||
const SORTS = [
|
||||
["view", "播放"], ["latest", "最新发布"], ["interaction", "互动率"],
|
||||
];
|
||||
|
||||
function sortedVideos(videos, sort) {
|
||||
const items = [...videos];
|
||||
if (sort === "latest") {
|
||||
items.sort((a, b) => b.pubdate - a.pubdate);
|
||||
} else if (sort === "interaction") {
|
||||
items.sort((a, b) => (b.interaction_rate || 0) - (a.interaction_rate || 0));
|
||||
} else {
|
||||
items.sort((a, b) => (b.metrics?.view || 0) - (a.metrics?.view || 0));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function renderUp(root, { params, query }) {
|
||||
const mid = Number(params[0]);
|
||||
const sort = query.get("sort") || "view";
|
||||
const up = await api(`/api/ups/${mid}`);
|
||||
const videos = sortedVideos(up.videos || [], sort);
|
||||
const topForBar = [...(up.videos || [])]
|
||||
.sort((a, b) => (b.metrics?.view || 0) - (a.metrics?.view || 0))
|
||||
.slice(0, 12);
|
||||
|
||||
root.innerHTML = `<section class="page up-page">
|
||||
<header class="page-head">
|
||||
<a class="back-link" href="#/rank?scope=up&sort=views">← 返回 UP 榜</a>
|
||||
<a class="button ghost" href="${escapeAttr(up.space_url)}" target="_blank" rel="noreferrer">B 站空间</a>
|
||||
</header>
|
||||
|
||||
<div class="detail-hero compact-hero">
|
||||
<div class="detail-hero-body">
|
||||
<h1>${escapeHtml(up.up_name || `UP ${mid}`)}</h1>
|
||||
<div class="detail-meta">
|
||||
<span>mid ${up.up_mid}</span>
|
||||
${up.blacklisted ? badge("黑名单 · 仅影响发现", "warning") : ""}
|
||||
${(up.folders || []).map((title) => badge(title)).join("")}
|
||||
</div>
|
||||
<p class="section-note">首次发布 ${escapeHtml(formatDate(up.first_pubdate))} · 最近发布 ${escapeHtml(formatDate(up.latest_pubdate))}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mini-stats up-stats">
|
||||
<div><span>视频数</span><strong>${formatCount(up.video_count)}</strong></div>
|
||||
<div><span>总播放</span><strong>${formatCount(up.total_views)}</strong></div>
|
||||
<div><span>平均播放</span><strong>${formatCount(up.avg_views)}</strong></div>
|
||||
<div><span>总互动</span><strong>${formatCount(up.total_interactions)}</strong></div>
|
||||
<div><span>平均互动率</span><strong>${formatPercent(up.avg_interaction)}</strong></div>
|
||||
</div>
|
||||
|
||||
<section class="panel chart-panel">
|
||||
${sectionHeader("该 UP 视频总播放趋势")}
|
||||
${up.series?.dates?.length ? `<div class="chart" id="up-trend"></div>` : emptyState("暂无趋势", "该 UP 还没有成功快照。")}
|
||||
<p class="section-note">按该 UP 全部视频的每日播放合计绘制;缺失日期断开。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel chart-panel">
|
||||
${sectionHeader("单视频播放对比")}
|
||||
${topForBar.length ? `<div class="chart chart-tall" id="up-bars"></div>` : emptyState("暂无视频数据")}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>视频列表</h2>
|
||||
<select class="select" id="up-sort">
|
||||
${SORTS.map(([value, label]) => `<option value="${value}" ${sort === value ? "selected" : ""}>${label}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="video-list">
|
||||
${videos.length ? videos.map((item) => videoCard(item)).join("") : emptyState("没有视频")}
|
||||
</div>
|
||||
</section>
|
||||
</section>`;
|
||||
|
||||
const trendElement = root.querySelector("#up-trend");
|
||||
if (trendElement) {
|
||||
lineChart(trendElement, {
|
||||
dates: up.series.dates,
|
||||
values: up.series.views,
|
||||
name: "总播放",
|
||||
color: "#fb7299",
|
||||
scale: true,
|
||||
});
|
||||
}
|
||||
const barsElement = root.querySelector("#up-bars");
|
||||
if (barsElement) {
|
||||
barChart(barsElement, {
|
||||
labels: topForBar.map((item) => (item.title || `#${item.aid}`).slice(0, 12)),
|
||||
values: topForBar.map((item) => item.metrics?.view || 0),
|
||||
name: "播放",
|
||||
color: "#00aeec",
|
||||
});
|
||||
}
|
||||
root.querySelector("#up-sort")?.addEventListener("change", (event) => {
|
||||
replaceQuery({ sort: event.target.value });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { api } from "../api.js";
|
||||
import {
|
||||
badge, cover, emptyState, metricChips, metricGrid, sectionHeader, videoCard,
|
||||
} from "../components.js";
|
||||
import { lineChart, emptyChart } from "../charts.js";
|
||||
import {
|
||||
escapeAttr, escapeHtml, formatCount, formatDate, formatDelta,
|
||||
formatDuration, 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 ? formatCount(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="#/rank?scope=video&sort=heat">← 返回榜单</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.folder_title ? badge(detail.folder_title) : ""}
|
||||
${badge(statusLabel(detail.status), `status-${detail.status}`)}
|
||||
${samples < 2 ? badge("样本不足", "warning") : ""}
|
||||
</div>
|
||||
${detail.blacklisted ? `<p class="section-note warning-text">该 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>${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 });
|
||||
}));
|
||||
|
||||
const chartElement = root.querySelector("#video-trend");
|
||||
if (chartElement) {
|
||||
lineChart(chartElement, {
|
||||
dates: trend.dates || [],
|
||||
values: trend.series?.[metric] || [],
|
||||
name: metricLabel(metric),
|
||||
color: metric === "view" ? "#fb7299" : "#00aeec",
|
||||
scale: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user