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