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

+56
View File
@@ -0,0 +1,56 @@
export class ApiError extends Error {
constructor(message, status = 0, code = "error") {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
}
}
function buildUrl(path, params) {
const url = new URL(path, window.location.origin);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined || value === "") continue;
url.searchParams.set(key, String(value));
}
}
return url;
}
export async function api(path, params) {
const response = await fetch(buildUrl(path, params), {
headers: { Accept: "application/json" },
credentials: "same-origin",
});
if (response.status === 401) {
window.location.href = "/login";
throw new ApiError("登录已失效,请重新登录", 401, "unauthorized");
}
let payload;
try {
payload = await response.json();
} catch {
throw new ApiError(`接口返回异常(HTTP ${response.status})`, response.status);
}
if (!response.ok || !payload.ok) {
const error = payload.error || {};
throw new ApiError(
error.message || `请求失败(HTTP ${response.status})`,
response.status,
error.code || "error",
);
}
return payload.data;
}
export function apiUrl(path, params) {
return buildUrl(path, params).toString();
}
export async function health() {
const response = await fetch("/api/health", { credentials: "same-origin" });
if (!response.ok) throw new ApiError("无法连接看板服务", response.status);
const payload = await response.json();
return payload?.data ?? payload;
}
+120
View File
@@ -0,0 +1,120 @@
import { health } from "./api.js";
import { disposeAll } from "./charts.js";
import { escapeHtml } from "./format.js";
import { onRoute, startRouter } from "./router.js";
import { renderCandidates } from "./views/candidates.js";
import { renderFans } from "./views/fans.js";
import { renderHome } from "./views/home.js";
import { renderRankings } from "./views/rankings.js";
import { renderSystem } from "./views/system.js";
import { renderUp } from "./views/up.js";
import { renderVideo } from "./views/video.js";
const NAV_ITEMS = [
{ path: "/home", label: "首页", icon: "home" },
{ path: "/rank", label: "榜单", icon: "rank" },
{ path: "/fans", label: "粉丝", icon: "fans" },
{ path: "/candidates", label: "候选", icon: "candidate" },
{ path: "/system", label: "系统", icon: "system" },
];
const ROUTES = [
{ pattern: /^\/home$/, view: renderHome, title: "最新动态" },
{ pattern: /^\/rank$/, view: renderRankings, title: "榜单" },
{ pattern: /^\/video\/(\d+)$/, view: renderVideo, title: "视频详情" },
{ pattern: /^\/up\/(\d+)$/, view: renderUp, title: "UP 详情" },
{ pattern: /^\/fans$/, view: renderFans, title: "粉丝" },
{ pattern: /^\/candidates$/, view: renderCandidates, title: "候选池" },
{ pattern: /^\/system$/, view: renderSystem, title: "系统状态" },
];
function icon(name) {
const paths = {
home: '<path d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z"/>',
rank: '<path d="M5 20V10m7 10V4m7 16v-7"/>',
fans: '<path d="M16 20v-1a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v1"/><circle cx="9.5" cy="7" r="3"/><path d="M17 11a3 3 0 1 0 0-6m1 15v-1a4 4 0 0 0-3-3.8"/>',
candidate: '<path d="M9 11l2 2 4-4"/><rect x="4" y="4" width="16" height="16" rx="3"/>',
system: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
};
return `<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ""}</svg>`;
}
function renderNav() {
const nav = document.querySelector("#bottom-nav");
nav.innerHTML = NAV_ITEMS.map((item) => `
<a class="nav-item" href="#${item.path}" data-nav="${item.path}">
${icon(item.icon)}<span class="nav-label">${item.label}</span>
</a>`).join("");
}
function activeNav(path) {
let active = "/home";
if (path.startsWith("/rank") || path.startsWith("/video/") || path.startsWith("/up/")) active = "/rank";
else if (path.startsWith("/fans")) active = "/fans";
else if (path.startsWith("/candidates")) active = "/candidates";
else if (path.startsWith("/system")) active = "/system";
document.querySelectorAll("[data-nav]").forEach((item) => {
item.classList.toggle("active", item.dataset.nav === active);
});
}
function setTitle(route, path) {
const title = route?.title || "看板";
document.title = `${title} · 咻咻满 Reaction`;
const element = document.querySelector("#topbar-title");
if (element) element.textContent = title;
activeNav(path);
}
function toast(message, kind = "info") {
const element = document.querySelector("#toast");
if (!element) return;
element.textContent = message;
element.className = `toast show ${kind}`;
window.clearTimeout(toast.timer);
toast.timer = window.setTimeout(() => {
element.className = "toast";
}, 2600);
}
window.addEventListener("error", (event) => {
const target = event.target;
if (target instanceof HTMLImageElement) {
target.classList.add("image-error");
const wrapper = target.closest(".video-thumb, .hero-cover, .heat-thumb, .candidate-thumb");
wrapper?.classList.add("cover-placeholder");
}
}, true);
async function boot() {
renderNav();
const banner = document.querySelector("#preview-banner");
try {
const payload = await health();
const data = payload?.data || payload;
if (data?.preview) {
banner.hidden = false;
}
} catch {
toast("无法连接看板服务", "error");
}
onRoute(({ path, route }) => {
disposeAll();
setTitle(route, path);
});
startRouter({
root: document.querySelector("#app"),
outlet: document.querySelector("#view"),
routes: ROUTES,
});
}
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
if (reason?.name === "ApiError") {
toast(reason.message, "error");
}
});
boot();
+188
View File
@@ -0,0 +1,188 @@
import { formatAxis, formatCount, formatDay } from "./format.js";
const charts = new Map();
let resizeObserver = null;
function colors() {
const styles = getComputedStyle(document.documentElement);
return {
text: styles.getPropertyValue("--text").trim() || "#1c1f23",
muted: styles.getPropertyValue("--muted").trim() || "#6b7280",
grid: styles.getPropertyValue("--grid").trim() || "#e8ebf0",
accent: "#fb7299",
blue: "#00aeec",
surface: styles.getPropertyValue("--surface").trim() || "#ffffff",
};
}
function ensureEcharts() {
if (!window.echarts) {
throw new Error("ECharts 未加载,请检查 /static/vendor/echarts.min.js");
}
return window.echarts;
}
function mount(element, option) {
const echarts = ensureEcharts();
let chart = charts.get(element);
if (!chart) {
chart = echarts.init(element, null, { renderer: "canvas" });
charts.set(element, chart);
}
chart.setOption(option, true);
if (!resizeObserver && typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
charts.get(entry.target)?.resize();
}
});
}
resizeObserver?.observe(element);
return chart;
}
export function disposeAll() {
for (const chart of charts.values()) {
try {
chart.dispose();
} catch {
// 页面切换时忽略已销毁实例
}
}
charts.clear();
resizeObserver?.disconnect();
resizeObserver = null;
}
const palette = {
accent: "#fb7299",
blue: "#00aeec",
purple: "#9b7bff",
green: "#22c55e",
};
export function lineChart(element, { dates, values, name = "数值", color = palette.accent, scale = false }) {
if (!element) return null;
const theme = colors();
const option = {
animationDuration: 300,
backgroundColor: "transparent",
grid: { left: 8, right: 12, top: 22, bottom: 8, containLabel: true },
tooltip: {
trigger: "axis",
backgroundColor: theme.surface,
borderColor: theme.grid,
textStyle: { color: theme.text, fontSize: 12 },
formatter(params) {
const point = params[0];
if (!point) return "";
const value = point.value;
return `<strong>${point.axisValue}</strong><br>${name}:${
value === null || value === undefined ? "缺失" : formatCount(value)
}`;
},
},
xAxis: {
type: "category",
data: dates,
boundaryGap: false,
axisLine: { lineStyle: { color: theme.grid } },
axisTick: { show: false },
axisLabel: {
color: theme.muted,
fontSize: 11,
formatter: (value) => formatDay(value),
hideOverlap: true,
},
},
yAxis: {
type: "value",
scale,
splitLine: { lineStyle: { color: theme.grid, type: "dashed" } },
axisLabel: {
color: theme.muted,
fontSize: 11,
formatter: (value) => formatAxis(value),
},
},
series: [{
name,
type: "line",
data: values,
smooth: false,
connectNulls: false,
symbol: "circle",
symbolSize: 5,
showSymbol: values.length <= 45,
lineStyle: { width: 2.2, color },
itemStyle: { color },
areaStyle: {
color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: `${color}55` },
{ offset: 1, color: `${color}00` },
],
},
},
}],
};
return mount(element, option);
}
export function barChart(element, { labels, values, name = "数值", color = palette.blue }) {
if (!element) return null;
const theme = colors();
const option = {
animationDuration: 300,
backgroundColor: "transparent",
grid: { left: 8, right: 12, top: 22, bottom: 8, containLabel: true },
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
backgroundColor: theme.surface,
borderColor: theme.grid,
textStyle: { color: theme.text, fontSize: 12 },
formatter(params) {
const point = params[0];
if (!point) return "";
return `<strong>${point.name}</strong><br>${name}:${formatCount(point.value)}`;
},
},
xAxis: {
type: "category",
data: labels,
axisLine: { lineStyle: { color: theme.grid } },
axisTick: { show: false },
axisLabel: {
color: theme.muted,
fontSize: 10,
interval: 0,
rotate: labels.length > 8 ? 32 : 0,
formatter: (value) => String(value).slice(0, 10),
},
},
yAxis: {
type: "value",
splitLine: { lineStyle: { color: theme.grid, type: "dashed" } },
axisLabel: {
color: theme.muted,
fontSize: 11,
formatter: (value) => formatAxis(value),
},
},
series: [{
name,
type: "bar",
data: values,
barMaxWidth: 26,
itemStyle: { color, borderRadius: [4, 4, 0, 0] },
}],
};
return mount(element, option);
}
export function emptyChart(element, message = "暂无数据") {
if (!element) return;
element.innerHTML = `<div class="chart-empty">${message}</div>`;
}
+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>`;
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fb7299"/><stop offset="1" stop-color="#ff9fbc"/>
</linearGradient></defs>
<rect width="64" height="64" rx="18" fill="url(#g)"/>
<text x="32" y="42" font-size="34" font-weight="700" text-anchor="middle"
fill="#fff" font-family="system-ui, sans-serif">咻</text>
</svg>

After

Width:  |  Height:  |  Size: 433 B

+137
View File
@@ -0,0 +1,137 @@
const COUNT_UNITS = [
{ value: 1e8, suffix: "亿" },
{ value: 1e4, suffix: "万" },
];
export function formatCount(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "—";
const number = Number(value);
for (const unit of COUNT_UNITS) {
if (Math.abs(number) >= unit.value) {
const scaled = number / unit.value;
return `${trimZero(scaled.toFixed(Math.abs(scaled) >= 1000 ? 0 : 1))}${unit.suffix}`;
}
}
return Math.round(number).toLocaleString("zh-CN");
}
export function formatAxis(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "";
const number = Number(value);
if (Math.abs(number) >= 1e8) return `${trimZero((number / 1e8).toFixed(2))}亿`;
if (Math.abs(number) >= 1e4) {
const scaled = number / 1e4;
return `${trimZero(scaled.toFixed(Math.abs(scaled) >= 100 ? 1 : 2))}万`;
}
return Math.round(number).toLocaleString("zh-CN");
}
export function formatNumber(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "—";
return Number(value).toLocaleString("zh-CN");
}
export function formatDelta(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "—";
const number = Number(value);
const sign = number > 0 ? "+" : "";
return `${sign}${formatCount(number)}`;
}
export function formatScore(value, digits = 2) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "—";
return Number(value).toFixed(digits).replace(/\.?0+$/, "");
}
export function formatPercent(value, digits = 2) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "—";
return `${(Number(value) * 100).toFixed(digits)}%`;
}
export function formatDate(timestamp) {
if (!timestamp) return "—";
const date = new Date(Number(timestamp) * 1000);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric", month: "2-digit", day: "2-digit",
}).format(date);
}
export function formatDateTime(timestamp) {
if (!timestamp) return "—";
const date = new Date(Number(timestamp) * 1000);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
}).format(date);
}
export function formatDuration(seconds) {
if (seconds === null || seconds === undefined || Number(seconds) <= 0) return "—";
let total = Math.round(Number(seconds));
const hours = Math.floor(total / 3600);
total -= hours * 3600;
const minutes = Math.floor(total / 60);
const rest = total - minutes * 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
}
return `${minutes}:${String(rest).padStart(2, "0")}`;
}
export function formatDay(value) {
if (!value) return "—";
return String(value).slice(5);
}
export function trimZero(text) {
return String(text).replace(/\.0$/, "").replace(/(\.\d)0$/, "$1");
}
export function escapeHtml(value) {
if (value === null || value === undefined) return "";
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}
export function coverUrl(value) {
if (!value) return "";
return value.startsWith("//") ? `https:${value}` : value;
}
export function metricLabel(metric) {
return ({
view: "播放",
like: "点赞",
coin: "投币",
favorite: "收藏",
share: "转发",
reply: "评论",
danmaku: "弹幕",
})[metric] || metric;
}
export function statusLabel(status) {
return ({
active: "在库",
unfaved: "已取消收藏",
removed: "已失效",
dropped: "已移出库",
})[status] || status || "—";
}
export function debounce(fn, wait = 280) {
let timer = null;
return (...args) => {
window.clearTimeout(timer);
timer = window.setTimeout(() => fn(...args), wait);
};
}
+22 -2
View File
@@ -2,12 +2,32 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>咻咻满 Reaction 看板</title> <title>咻咻满 Reaction 看板</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<div id="app">正在加载…</div> <div class="app" id="app">
<header class="topbar">
<a class="brand" href="#/home">
<span class="brand-mark">咻</span>
<span class="brand-text">
<strong>Reaction 看板</strong>
<small id="topbar-title">最新动态</small>
</span>
</a>
<span class="topbar-hint">只读</span>
</header>
<div class="preview-banner" id="preview-banner" hidden>预览数据 · 只读 · 不影响真实数据库</div>
<main class="main-shell">
<div id="view"></div>
</main>
<nav class="bottom-nav" id="bottom-nav" aria-label="主导航"></nav>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="/static/vendor/echarts.min.js"></script>
<script type="module" src="/static/app.js"></script> <script type="module" src="/static/app.js"></script>
</body> </body>
</html> </html>
+106
View File
@@ -0,0 +1,106 @@
import { escapeHtml } from "./format.js";
const listeners = new Set();
let rootElement = null;
let routeElement = null;
let current = null;
let routeTable = [];
export function parseLocation() {
const raw = window.location.hash.replace(/^#/, "") || "/home";
const [pathPart, queryPart = ""] = raw.split("?");
const path = pathPart.startsWith("/") ? pathPart : `/${pathPart}`;
return { path, query: new URLSearchParams(queryPart) };
}
export function navigate(path, query) {
const params = new URLSearchParams();
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined || value === "") continue;
params.set(key, String(value));
}
}
const text = params.toString();
window.location.hash = `${path}${text ? `?${text}` : ""}`;
}
export function replaceQuery(changes) {
const { path, query } = parseLocation();
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === undefined || value === "") query.delete(key);
else query.set(key, String(value));
}
const text = query.toString();
window.location.hash = `${path}${text ? `?${text}` : ""}`;
}
export function onRoute(listener) {
listeners.add(listener);
}
export function startRouter({ root, outlet, routes }) {
rootElement = root;
routeElement = outlet;
routeTable = routes;
window.addEventListener("hashchange", render);
render();
}
export function currentRoute() {
return current;
}
function matchRoute(path, routes) {
for (const route of routes) {
const match = route.pattern.exec(path);
if (match) return { route, match };
}
return null;
}
async function render() {
const { path, query } = parseLocation();
if (!path || path === "/") {
navigate("/home");
return;
}
const matched = matchRoute(path, routeTable);
if (!matched) {
routeElement.innerHTML = `
<section class="page">
<div class="empty-state"><strong>页面不存在</strong>
<p>${escapeHtml(path)}</p><a class="button" href="#/home">回到首页</a></div>
</section>`;
return;
}
current = { path, query, route: matched.route };
for (const listener of listeners) {
try {
listener(current);
} catch {
// 导航状态更新不应阻断页面渲染
}
}
window.scrollTo({ top: 0, behavior: "instant" });
routeElement.innerHTML = `<div class="page-loading"><div class="skeleton skeleton-title"></div>
<div class="skeleton skeleton-card"></div><div class="skeleton skeleton-card"></div></div>`;
try {
await matched.route.view(routeElement, {
params: matched.match.slice(1),
query,
path,
});
} catch (error) {
routeElement.innerHTML = `
<section class="page">
<div class="empty-state error-state">
<strong>加载失败</strong>
<p>${escapeHtml(error.message || "未知错误")}</p>
<button class="button" id="retry-button" type="button">重试</button>
</div>
</section>`;
routeElement.querySelector("#retry-button")?.addEventListener("click", render);
}
rootElement?.classList.remove("is-loading");
}
+757
View File
@@ -0,0 +1,757 @@
:root {
color-scheme: light dark;
--bg: #f5f6f8;
--surface: #ffffff;
--surface-2: #eef1f5;
--surface-3: #e5e9ef;
--text: #1b1e23;
--muted: #6b7280;
--border: #e3e7ee;
--grid: #e8ebf0;
--accent: #fb7299;
--accent-strong: #e75c84;
--accent-soft: #ffe8ef;
--blue: #00aeec;
--blue-soft: #e3f7fe;
--warning: #d97706;
--warning-soft: #fff4df;
--danger: #dc2626;
--success: #16a34a;
--radius: 16px;
--radius-small: 10px;
--shadow: 0 8px 30px rgba(20, 28, 45, 0.06);
--nav-width: 220px;
--nav-height: 64px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1014;
--surface: #171a20;
--surface-2: #20252d;
--surface-3: #2a313b;
--text: #edf0f4;
--muted: #9aa3af;
--border: #2a3038;
--grid: #2a3038;
--accent-soft: #3a2029;
--blue-soft: #102f3a;
--warning-soft: #3a2a12;
--shadow: 0 12px 34px rgba(0, 0, 0, 0.28);
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; color: inherit; }
img { max-width: 100%; }
h1, h2, h3, p { margin: 0; }
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.app { min-height: 100vh; }
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 60px;
padding: 0 16px;
background: color-mix(in srgb, var(--surface) 88%, transparent);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(14px);
}
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.brand-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
flex: 0 0 auto;
border-radius: 11px;
background: linear-gradient(135deg, var(--accent), #ff9fbc);
color: #fff;
font-weight: 800;
font-size: 17px;
box-shadow: 0 6px 16px rgba(251, 114, 153, 0.3);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; }
.brand-text strong { font-size: 15px; letter-spacing: 0.2px; }
.brand-text small { color: var(--muted); font-size: 11px; white-space: nowrap; }
.topbar-hint {
flex: 0 0 auto;
padding: 4px 9px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--muted);
font-size: 11px;
}
.preview-banner {
position: sticky;
top: 60px;
z-index: 19;
padding: 8px 14px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: #5c3b00;
background: #ffd479;
}
@media (prefers-color-scheme: dark) {
.preview-banner { color: #ffe7b0; background: #6b4a00; }
}
.main-shell {
padding: 12px 12px calc(var(--nav-height) + 28px + env(safe-area-inset-bottom));
}
.page {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 1120px;
margin: 0 auto;
}
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
padding: 4px 2px;
}
.page-head h1 { font-size: 24px; letter-spacing: -0.4px; }
.page-sub { margin-top: 4px; color: var(--muted); font-size: 12.5px; }
.home-head { align-items: center; }
.back-link { color: var(--muted); font-size: 13px; }
.back-link:hover { color: var(--accent); }
.head-actions { display: flex; gap: 8px; align-items: center; }
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 38px;
padding: 8px 14px;
border: 0;
border-radius: 10px;
background: var(--accent);
color: #fff;
font-size: 13px;
font-weight: 650;
cursor: pointer;
white-space: nowrap;
}
.button:hover { background: var(--accent-strong); }
.button.ghost {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.button.ghost:hover { border-color: var(--accent); color: var(--accent); }
.button:disabled { opacity: 0.45; cursor: not-allowed; }
.icon-button {
min-height: 36px;
padding: 7px 12px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--muted);
font-size: 12px;
cursor: pointer;
}
.icon-button:hover { color: var(--accent); border-color: var(--accent); }
.text-link { color: var(--accent); font-size: 12.5px; font-weight: 600; }
.inline-link { color: var(--blue); }
.inline-link:hover, .text-link:hover { text-decoration: underline; }
.bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
display: flex;
align-items: stretch;
height: calc(var(--nav-height) + env(safe-area-inset-bottom));
padding: 5px 6px env(safe-area-inset-bottom);
background: color-mix(in srgb, var(--surface) 92%, transparent);
border-top: 1px solid var(--border);
backdrop-filter: blur(16px);
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
border-radius: 10px;
color: var(--muted);
font-size: 10.5px;
}
.nav-item.active { color: var(--accent); background: var(--accent-soft); }
.nav-icon { width: 21px; height: 21px; }
.nav-label { line-height: 1; }
.panel {
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow);
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.section-head h2 { font-size: 16px; letter-spacing: -0.2px; }
.section-note { color: var(--muted); font-size: 12px; margin: 4px 0 10px; }
.warning-text { color: var(--warning); }
.danger { color: var(--danger); }
.up { color: var(--success); }
.down { color: var(--danger); }
.live-strip {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.live-chip {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 13px;
background: var(--surface);
}
.live-chip span { display: block; color: var(--muted); font-size: 11px; }
.live-chip strong { display: block; margin-top: 2px; font-size: 17px; letter-spacing: -0.2px; }
.live-chip.accent { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); background: var(--accent-soft); }
.live-chip.warning { border-color: color-mix(in srgb, var(--warning) 45%, var(--border)); background: var(--warning-soft); }
.notice-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 14px;
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
border-radius: 13px;
background: var(--accent-soft);
font-size: 13px;
}
.notice-row.warning {
border-color: color-mix(in srgb, var(--warning) 50%, var(--border));
background: var(--warning-soft);
}
.notice-row span { color: var(--muted); font-size: 12px; }
.video-list { display: flex; flex-direction: column; gap: 8px; }
.video-card, .up-card {
position: relative;
display: grid;
grid-template-columns: 26px 96px minmax(0, 1fr) 12px;
align-items: start;
gap: 9px;
padding: 9px;
border: 1px solid var(--border);
border-radius: 13px;
background: var(--surface);
transition: border-color 0.15s, transform 0.15s;
}
.video-card:hover, .up-card:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); transform: translateY(-1px); }
.video-card.no-rank, .up-card.no-rank { grid-template-columns: 96px minmax(0, 1fr) 12px; }
.video-card.no-rank .video-thumb, .up-card.no-rank .video-thumb { grid-column: 1; }
.video-card.no-rank .video-card-body, .up-card.no-rank .up-card-body { grid-column: 2; }
.video-card.no-rank .card-arrow, .up-card.no-rank .card-arrow { grid-column: 3; }
.rank-badge {
grid-column: 1;
grid-row: 1;
align-self: start;
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 8px;
background: var(--surface-2);
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.rank-badge.rank-1 { background: #ffd166; color: #5b3a00; }
.rank-badge.rank-2 { background: #dbe3ec; color: #39414d; }
.rank-badge.rank-3 { background: #f2c7a5; color: #5c3218; }
.video-thumb, .hero-cover, .heat-thumb, .candidate-thumb {
position: relative;
overflow: hidden;
border-radius: 9px;
background: var(--surface-2);
}
.video-thumb {
grid-column: 2;
grid-row: 1;
width: 96px;
aspect-ratio: 16 / 9;
}
.video-thumb img, .hero-cover img, .heat-thumb img, .candidate-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cover-placeholder { display: grid; place-items: center; color: var(--muted); font-size: 10px; }
.cover-placeholder img.image-error { opacity: 0; }
.video-card-body { grid-column: 3; grid-row: 1; min-width: 0; }
.up-card-body { grid-column: 3; grid-row: 1; min-width: 0; }
.video-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 14px;
line-height: 1.35;
font-weight: 650;
letter-spacing: -0.1px;
}
.video-meta {
display: flex;
flex-wrap: wrap;
gap: 3px 9px;
margin-top: 4px;
color: var(--muted);
font-size: 11px;
}
.video-stats {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 9px;
margin-top: 5px;
color: var(--muted);
font-size: 11px;
}
.primary-stat { display: inline-flex; align-items: baseline; gap: 3px; color: var(--text); }
.primary-stat b { font-size: 15px; letter-spacing: -0.3px; }
.primary-stat small { color: var(--muted); font-size: 10px; }
.secondary-stat { white-space: nowrap; }
.secondary-stat.optional { display: none; }
.delta { font-weight: 650; font-size: 11px; }
.delta.up { color: var(--success); }
.delta.down { color: var(--danger); }
.card-arrow {
grid-column: 4;
grid-row: 1;
align-self: center;
color: var(--muted);
font-size: 18px;
}
.heat-strip {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 210px;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
scroll-snap-type: x proximity;
scrollbar-width: none;
}
.heat-strip::-webkit-scrollbar { display: none; }
.heat-card {
position: relative;
display: flex;
flex-direction: column;
gap: 5px;
padding: 9px;
border: 1px solid var(--border);
border-radius: 13px;
background: var(--surface);
scroll-snap-align: start;
}
.heat-rank {
position: absolute;
top: 14px;
left: 14px;
z-index: 1;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.62);
color: #fff;
font-size: 11px;
font-weight: 700;
}
.heat-thumb { width: 100%; aspect-ratio: 16 / 9; }
.heat-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 12.5px;
line-height: 1.35;
}
.heat-meta, .heat-score { color: var(--muted); font-size: 10.5px; }
.heat-score { color: var(--accent); }
.capacity-row { padding: 7px 0; }
.capacity-label { display: flex; justify-content: space-between; gap: 12px; font-size: 12.5px; }
.capacity-label strong { color: var(--muted); font-weight: 600; }
.capacity-track {
height: 7px;
margin-top: 6px;
overflow: hidden;
border-radius: 999px;
background: var(--surface-2);
}
.capacity-track span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--blue), var(--accent));
}
.capacity-track span.warning { background: linear-gradient(90deg, var(--warning), var(--danger)); }
.status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.status-grid > div {
padding: 8px 10px;
border-radius: 10px;
background: var(--surface-2);
}
.status-grid span { display: block; color: var(--muted); font-size: 10.5px; }
.status-grid strong { display: block; margin-top: 2px; font-size: 12.5px; font-weight: 650; }
.status-panel .text-link { display: inline-block; margin-top: 10px; }
.segmented {
display: inline-flex;
align-self: flex-start;
padding: 3px;
border: 1px solid var(--border);
border-radius: 11px;
background: var(--surface);
}
.segmented button {
min-width: 74px;
padding: 7px 16px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.segmented button.active { background: var(--accent); color: #fff; }
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.chip-row.scroll-x { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
.chip-row.scroll-x::-webkit-scrollbar { display: none; }
.chip {
flex: 0 0 auto;
padding: 6px 11px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--muted);
font-size: 12px;
white-space: nowrap;
cursor: pointer;
}
.chip.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); font-weight: 650; }
.chip-label { color: var(--muted); font-size: 11px; padding-right: 2px; }
.range-row { margin-top: 8px; }
.filter-bar { display: flex; gap: 7px; }
.search-input, .select {
min-height: 38px;
padding: 8px 11px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
font-size: 13px;
}
.search-input { flex: 1; min-width: 0; }
.select { max-width: 170px; }
.filter-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.filter-row > .chip-row { flex: 1; min-width: 200px; }
.growth-days { padding-top: 2px; }
.rank-list { display: flex; flex-direction: column; gap: 8px; }
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 0;
color: var(--muted);
font-size: 12px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 28px 16px;
border: 1px dashed var(--border);
border-radius: 13px;
text-align: center;
color: var(--muted);
}
.empty-state strong { color: var(--text); font-size: 14px; }
.empty-state p { font-size: 12px; max-width: 360px; }
.empty-state.error-state { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
.empty-state .button { margin-top: 8px; }
.detail-hero {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.hero-cover { width: 100%; aspect-ratio: 16 / 9; border-radius: 12px; }
.detail-hero-body h1 { font-size: 19px; line-height: 1.35; letter-spacing: -0.3px; }
.detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 8px; color: var(--muted); font-size: 12px; }
.compact-hero { padding: 18px; }
.badge {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 999px;
background: var(--surface-2);
color: var(--muted);
font-size: 10.5px;
font-weight: 600;
white-space: nowrap;
}
.badge.subtle { background: var(--surface-2); }
.badge.warning { background: var(--warning-soft); color: var(--warning); }
.badge.status-active { background: var(--blue-soft); color: var(--blue); }
.badge.status-unfaved { background: var(--warning-soft); color: var(--warning); }
.badge.status-removed, .badge.status-dropped { background: var(--surface-3); color: var(--muted); }
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 7px;
}
.metric-cell {
padding: 10px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.metric-label { display: block; color: var(--muted); font-size: 10.5px; }
.metric-cell strong { display: block; margin-top: 2px; font-size: 17px; letter-spacing: -0.3px; }
.metric-delta { display: inline-block; margin-top: 2px; font-size: 10.5px; font-weight: 650; }
.metric-delta.up { color: var(--success); }
.metric-delta.down { color: var(--danger); }
.mini-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
}
.mini-stats > div {
padding: 9px 10px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.mini-stats span { display: block; color: var(--muted); font-size: 10.5px; }
.mini-stats strong { display: block; margin-top: 2px; font-size: 14px; }
.up-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.chart-panel .chart { width: 100%; height: 240px; margin-top: 8px; }
.chart-panel .chart-tall { height: 300px; }
.chart-empty { display: grid; place-items: center; height: 100%; color: var(--muted); font-size: 12px; }
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 12px; }
.data-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 12px; }
.data-table th, .data-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
.data-table th { position: sticky; top: 0; background: var(--surface-2); color: var(--muted); font-size: 11px; font-weight: 600; }
.data-table tr:last-child td { border-bottom: 0; }
.data-table .failed-row td { color: var(--muted); background: color-mix(in srgb, var(--danger) 5%, transparent); }
.summary-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
.hero-number {
padding: 18px;
border-radius: var(--radius);
background: linear-gradient(135deg, #2b2140, #4a2c4f 55%, #a33d5f);
color: #fff;
}
.hero-number span { display: block; font-size: 12px; opacity: 0.72; }
.hero-number strong { display: block; margin: 4px 0 2px; font-size: 34px; letter-spacing: -1px; }
.hero-number small { opacity: 0.68; font-size: 11px; }
.candidate-list { display: flex; flex-direction: column; gap: 10px; }
.candidate-card {
display: grid;
grid-template-columns: 104px minmax(0, 1fr);
gap: 10px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 13px;
background: var(--surface);
}
.candidate-thumb { width: 104px; aspect-ratio: 16 / 9; }
.candidate-top { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 4px; }
.candidate-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
.readonly-note { color: var(--muted); font-size: 11px; }
.score-badge {
padding: 2px 7px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
background: var(--surface-2);
color: var(--muted);
}
.score-badge.high { background: var(--accent-soft); color: var(--accent-strong); }
.score-badge.mid { background: var(--blue-soft); color: var(--blue); }
.details-panel summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; }
.decided-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
.decided-row:last-child { border-bottom: 0; }
.decided-row a { flex: 1; min-width: 180px; }
.error-row { display: flex; flex-direction: column; gap: 2px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
.error-row strong { color: var(--danger); }
.error-row:last-child { border-bottom: 0; }
.page-loading { display: flex; flex-direction: column; gap: 10px; max-width: 1120px; margin: 0 auto; }
.skeleton {
position: relative;
overflow: hidden;
border-radius: 12px;
background: var(--surface-2);
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--surface) 55%, transparent), transparent);
animation: shimmer 1.4s infinite;
}
.skeleton-title { height: 52px; }
.skeleton-card { height: 104px; }
@keyframes shimmer { 100% { transform: translateX(100%); } }
.toast {
position: fixed;
left: 50%;
bottom: calc(var(--nav-height) + 22px + env(safe-area-inset-bottom));
z-index: 50;
max-width: min(88vw, 420px);
padding: 9px 14px;
border-radius: 999px;
background: rgba(20, 24, 32, 0.92);
color: #fff;
font-size: 12.5px;
opacity: 0;
transform: translate(-50%, 10px);
pointer-events: none;
transition: opacity 0.18s, transform 0.18s;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
.toast.error { background: rgba(160, 30, 45, 0.95); }
@media (min-width: 620px) {
.live-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.metric-grid { grid-template-columns: repeat(7, minmax(0, 1fr)); }
.up-stats { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.detail-hero { flex-direction: row; align-items: flex-start; }
.hero-cover { width: 300px; flex: 0 0 300px; }
.video-thumb { width: 112px; }
.video-card { grid-template-columns: 28px 112px minmax(0, 1fr) 14px; }
.video-card.no-rank, .up-card.no-rank { grid-template-columns: 112px minmax(0, 1fr) 14px; }
.secondary-stat.optional { display: inline; }
}
@media (min-width: 900px) {
.topbar {
position: fixed;
left: var(--nav-width);
right: 0;
height: 64px;
padding: 0 28px;
}
.preview-banner { top: 64px; margin-left: var(--nav-width); }
.main-shell {
margin-left: var(--nav-width);
padding: 82px 28px 40px;
}
.bottom-nav {
top: 0;
right: auto;
bottom: 0;
width: var(--nav-width);
height: 100vh;
flex-direction: column;
justify-content: flex-start;
gap: 4px;
padding: 82px 12px 20px;
border-top: 0;
border-right: 1px solid var(--border);
}
.nav-item {
flex: 0 0 auto;
flex-direction: row;
justify-content: flex-start;
gap: 10px;
padding: 11px 14px;
font-size: 13px;
border-radius: 11px;
}
.nav-item.active { background: var(--accent-soft); }
.nav-icon { width: 20px; height: 20px; }
.video-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.detail-hero .hero-cover { width: 360px; flex-basis: 360px; }
.toast { left: calc(50% + var(--nav-width) / 2); bottom: 28px; }
.page-head h1 { font-size: 28px; }
}
@media (min-width: 1280px) {
.main-shell { padding-left: 36px; padding-right: 36px; }
.video-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.metric-grid { gap: 9px; }
}
@media (max-width: 380px) {
.video-card { grid-template-columns: 22px 82px minmax(0, 1fr) 10px; gap: 7px; }
.video-card.no-rank, .up-card.no-rank { grid-template-columns: 82px minmax(0, 1fr) 10px; }
.video-thumb { width: 82px; }
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.candidate-card { grid-template-columns: 84px minmax(0, 1fr); }
.candidate-thumb { width: 84px; }
}
+222
View File
@@ -0,0 +1,222 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
========================================================================
Apache ECharts Subcomponents:
The Apache ECharts project contains subcomponents with separate copyright
notices and license terms. Your use of the source code for these
subcomponents is also subject to the terms and conditions of the following
licenses.
BSD 3-Clause (d3.js):
The following files embed [d3.js](https://github.com/d3/d3) BSD 3-Clause:
`/src/chart/treemap/treemapLayout.ts`,
`/src/chart/tree/layoutHelper.ts`,
`/src/chart/graph/forceHelper.ts`,
`/src/util/number.ts`
See `/licenses/LICENSE-d3` for details of the license.
+45
View File
@@ -0,0 +1,45 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).echarts={})}(this,(function(t){"use strict";
/*! *****************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&(t[n]=e[n])},e(t,n)};function n(t,n){if("function"!=typeof n&&null!==n)throw new TypeError("Class extends value "+String(n)+" is not a constructor or null");function i(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(i.prototype=n.prototype,new i)}var i=function(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1},r=new function(){this.browser=new i,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow="undefined"!=typeof window};"object"==typeof wx&&"function"==typeof wx.getSystemInfoSync?(r.wxa=!0,r.touchEventsSupported=!0):"undefined"==typeof document&&"undefined"!=typeof self?r.worker=!0:"undefined"==typeof navigator||0===navigator.userAgent.indexOf("Node.js")?(r.node=!0,r.svgSupported=!0):function(t,e){var n=e.browser,i=t.match(/Firefox\/([\d.]+)/),r=t.match(/MSIE\s([\d.]+)/)||t.match(/Trident\/.+?rv:(([\d.]+))/),o=t.match(/Edge?\/([\d.]+)/),a=/micromessenger/i.test(t);i&&(n.firefox=!0,n.version=i[1]);r&&(n.ie=!0,n.version=r[1]);o&&(n.edge=!0,n.version=o[1],n.newEdge=+o[1].split(".")[0]>18);a&&(n.weChat=!0);e.svgSupported="undefined"!=typeof SVGRect,e.touchEventsSupported="ontouchstart"in window&&!n.ie&&!n.edge,e.pointerEventsSupported="onpointerdown"in window&&(n.edge||n.ie&&+n.version>=11),e.domSupported="undefined"!=typeof document;var s=document.documentElement.style;e.transform3dSupported=(n.ie&&"transition"in s||n.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in s)&&!("OTransition"in s),e.transformSupported=e.transform3dSupported||n.ie&&+n.version>=9}(navigator.userAgent,r);var o="sans-serif",a="12px "+o;var s,l,u=function(t){var e={};if("undefined"==typeof JSON)return e;for(var n=0;n<t.length;n++){var i=String.fromCharCode(n+32),r=(t.charCodeAt(n)-20)/100;e[i]=r}return e}("007LLmW'55;N0500LLLLLLLLLL00NNNLzWW\\\\WQb\\0FWLg\\bWb\\WQ\\WrWWQ000CL5LLFLL0LL**F*gLLLL5F0LF\\FFF5.5N"),h={createCanvas:function(){return"undefined"!=typeof document&&document.createElement("canvas")},measureText:function(t,e){if(!s){var n=h.createCanvas();s=n&&n.getContext("2d")}if(s)return l!==e&&(l=s.font=e||a),s.measureText(t);t=t||"";var i=/((?:\d+)?\.?\d*)px/.exec(e=e||a),r=i&&+i[1]||12,o=0;if(e.indexOf("mono")>=0)o=r*t.length;else for(var c=0;c<t.length;c++){var p=u[t[c]];o+=null==p?r:p*r}return{width:o}},loadImage:function(t,e,n){var i=new Image;return i.onload=e,i.onerror=n,i.src=t,i}};function c(t){for(var e in h)t[e]&&(h[e]=t[e])}var p=V(["Function","RegExp","Date","Error","CanvasGradient","CanvasPattern","Image","Canvas"],(function(t,e){return t["[object "+e+"]"]=!0,t}),{}),d=V(["Int8","Uint8","Uint8Clamped","Int16","Uint16","Int32","Uint32","Float32","Float64"],(function(t,e){return t["[object "+e+"Array]"]=!0,t}),{}),f=Object.prototype.toString,g=Array.prototype,y=g.forEach,v=g.filter,m=g.slice,x=g.map,_=function(){}.constructor,b=_?_.prototype:null,w="__proto__",S=2311;function M(){return S++}function I(){for(var t=[],e=0;e<arguments.length;e++)t[e]=arguments[e];"undefined"!=typeof console&&console.error.apply(console,t)}function T(t){if(null==t||"object"!=typeof t)return t;var e=t,n=f.call(t);if("[object Array]"===n){if(!pt(t)){e=[];for(var i=0,r=t.length;i<r;i++)e[i]=T(t[i])}}else if(d[n]){if(!pt(t)){var o=t.constructor;if(o.from)e=o.from(t);else{e=new o(t.length);for(i=0,r=t.length;i<r;i++)e[i]=t[i]}}}else if(!p[n]&&!pt(t)&&!J(t))for(var a in e={},t)t.hasOwnProperty(a)&&a!==w&&(e[a]=T(t[a]));return e}function C(t,e,n){if(!q(e)||!q(t))return n?T(e):t;for(var i in e)if(e.hasOwnProperty(i)&&i!==w){var r=t[i],o=e[i];!q(o)||!q(r)||Y(o)||Y(r)||J(o)||J(r)||K(o)||K(r)||pt(o)||pt(r)?!n&&i in t||(t[i]=T(e[i])):C(r,o,n)}return t}function D(t,e){for(var n=t[0],i=1,r=t.length;i<r;i++)n=C(n,t[i],e);return n}function A(t,e){if(Object.assign)Object.assign(t,e);else for(var n in e)e.hasOwnProperty(n)&&n!==w&&(t[n]=e[n]);return t}function k(t,e,n){for(var i=G(e),r=0;r<i.length;r++){var o=i[r];(n?null!=e[o]:null==t[o])&&(t[o]=e[o])}return t}var L=h.createCanvas;function P(t,e){if(t){if(t.indexOf)return t.indexOf(e);for(var n=0,i=t.length;n<i;n++)if(t[n]===e)return n}return-1}function O(t,e){var n=t.prototype;function i(){}for(var r in i.prototype=e.prototype,t.prototype=new i,n)n.hasOwnProperty(r)&&(t.prototype[r]=n[r]);t.prototype.constructor=t,t.superClass=e}function R(t,e,n){if(t="prototype"in t?t.prototype:t,e="prototype"in e?e.prototype:e,Object.getOwnPropertyNames)for(var i=Object.getOwnPropertyNames(e),r=0;r<i.length;r++){var o=i[r];"constructor"!==o&&(n?null!=e[o]:null==t[o])&&(t[o]=e[o])}else k(t,e,n)}function N(t){retuLine truncated
/*!
* ZRender, a high performance 2d drawing library.
*
* Copyright (c) 2013, Baidu Inc.
* All rights reserved.
*
* LICENSE
* https://github.com/ecomfe/zrender/blob/master/LICENSE.txt
*/
var Fr={},Gr={};var Wr,Hr=function(){function t(t,e,n){var i=this;this._sleepAfterStill=10,this._stillFrameAccum=0,this._needsRefresh=!0,this._needsRefreshHover=!0,this._darkMode=!1,n=n||{},this.dom=e,this.id=t;var o=new rn,a=n.renderer||"canvas";Fr[a]||(a=G(Fr)[0]),n.useDirtyRect=null!=n.useDirtyRect&&n.useDirtyRect;var s=new Fr[a](e,o,n,t),l=n.ssr||s.ssrOnly;this.storage=o,this.painter=s;var u,h=r.node||r.worker||l?null:new or(s.getViewportRoot(),s.root),c=n.useCoarsePointer;(null==c||"auto"===c?r.touchEventsSupported:!!c)&&(u=rt(n.pointerSize,44)),this.handler=new Ye(o,s,h,s.root,u),this.animation=new Wi({stage:{update:l?null:function(){return i._flush(!0)}}}),l||this.animation.start()}return t.prototype.add=function(t){!this._disposed&&t&&(this.storage.addRoot(t),t.addSelfToZr(this),this.refresh())},t.prototype.remove=function(t){!this._disposed&&t&&(this.storage.delRoot(t),t.removeSelfFromZr(this),this.refresh())},t.prototype.configLayer=function(t,e){this._disposed||(this.painter.configLayer&&this.painter.configLayer(t,e),this.refresh())},t.prototype.setBackgroundColor=function(t){this._disposed||(this.painter.setBackgroundColor&&this.painter.setBackgroundColor(t),this.refresh(),this._backgroundColor=t,this._darkMode=function(t){if(!t)return!1;if("string"==typeof t)return oi(t,1)<.4;if(t.colorStops){for(var e=t.colorStops,n=0,i=e.length,r=0;r<i;r++)n+=oi(e[r].color,1);return(n/=i)<.4}return!1}(t))},t.prototype.getBackgroundColor=function(){return this._backgroundColor},t.prototype.setDarkMode=function(t){this._darkMode=t},t.prototype.isDarkMode=function(){return this._darkMode},t.prototype.refreshImmediately=function(t){this._disposed||(t||this.animation.update(!0),this._needsRefresh=!1,this.painter.refresh(),this._needsRefresh=!1)},t.prototype.refresh=function(){this._disposed||(this._needsRefresh=!0,this.animation.start())},t.prototype.flush=function(){this._disposed||this._flush(!1)},t.prototype._flush=function(t){var e,n=Bi();this._needsRefresh&&(e=!0,this.refreshImmediately(t)),this._needsRefreshHover&&(e=!0,this.refreshHoverImmediately());var i=Bi();e?(this._stillFrameAccum=0,this.trigger("rendered",{elapsedTime:i-n})):this._sleepAfterStill>0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},t.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},t.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},t.prototype.refreshHover=function(){this._needsRefreshHover=!0},t.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&"canvas"===this.painter.getType()&&this.painter.refreshHover())},t.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},t.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},t.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},t.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},t.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},t.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},t.prototype.on=function(t,e,n){return this._disposed||this.handler.on(t,e,n),this},t.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},t.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},t.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e<t.length;e++)t[e]instanceof Br&&t[e].removeSelfFromZr(this);this.storage.delAllRoots(),this.painter.clear()}},t.prototype.dispose=function(){var t;this._disposed||(this.animation.stop(),this.clear(),this.storage.dispose(),this.painter.dispose(),this.handler.dispose(),this.animation=this.storage=this.painter=this.handler=null,this._disposed=!0,t=this.id,delete Gr[t])},t}();function Yr(t,e){var n=new Hr(M(),t,e);return Gr[n.id]=n,n}function Xr(t,e){Fr[t]=e}function Ur(t){if("function"==typeof Wr)return Wr(t)}function Zr(t){Wr=t}var jr=Object.freeze({__proto__:null,init:Yr,dispose:function(t){t.dispose()},disposeAll:function(){for(var t in Gr)Gr.hasOwnProperty(t)&&Gr[t].dispose();Gr={}},getInstance:function(t){return Gr[t]},registerPainter:Xr,getElementSSRData:Ur,registerSSRDataGetter:Zr,version:"5.6.0"}),qr=1e-4;function Kr(t,e,n,i){var r=e[0],o=e[1],a=n[0],s=n[1],l=o-r,u=s-a;if(0===l)return 0===u?a:(a+s)/2;if(i)if(l>0){if(t<=r)return a;if(t>=o)return s}else{if(t>=r)return a;if(t<=o)return s}else{if(t===r)return a;if(t===o)return s}return(t-r)/l*u+a}function $r(t,e){switch(t){case"center":case"middle":t="50%";break;case"left":case"top":t="0%";break;case"right":case"bottom":t="100%"}return U(t)?(n=t,n.replace(/^\s+|\s+$/g,"")).match(/%$/)?parseFloat(t)/100*e:parseFloat(t):null==t?NaN:+t;var n}function Jr(t,e,n){return null==e&&(e=10),e=Math.min(Math.max(0,e),20),t=(+t).toFixed(e),n?t:+t}function Qr(t){return t.sort((funLine truncated
@@ -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>`;
}
+88
View File
@@ -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,
});
}
}
+149
View File
@@ -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());
}
+164
View File
@@ -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 });
}));
}
+123
View File
@@ -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>`;
}
+108
View File
@@ -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 });
});
}
+124
View File
@@ -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,
});
}
}