feat(web): refresh palette, simplify ranking toolbar and drop export

This commit is contained in:
kairee committed 2026-11-01 00:37:20 +08:00
1 parent 6a053bdfe8
commit c2ef998720
16 files changed
+141 -206

No files matched your search

+6 -15
View File
@@ -523,14 +523,13 @@ class DashboardQueries:
q: str | None = None, q: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
export: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
if sort not in VIDEO_SORTS: if sort not in VIDEO_SORTS:
raise ValueError(f"不支持的排序: {sort}") raise ValueError(f"不支持的排序: {sort}")
if growth_days < 1 or growth_days > 365: if growth_days < 1 or growth_days > 365:
raise ValueError("growth_days 必须在 1~365 之间") raise ValueError("growth_days 必须在 1~365 之间")
page = max(1, int(page)) page = max(1, int(page))
page_size = max(1, min(int(page_size), 200 if not export else 10_000)) page_size = max(1, min(int(page_size), 200))
def run(conn: sqlite3.Connection) -> dict[str, Any]: def run(conn: sqlite3.Connection) -> dict[str, Any]:
latest = self._latest_date(conn) or self._today() latest = self._latest_date(conn) or self._today()
@@ -607,9 +606,8 @@ class DashboardQueries:
growth_available = any( growth_available = any(
item.get("growth", {}).get("available") for item in items item.get("growth", {}).get("available") for item in items
) )
if not export: start = (page - 1) * page_size
start = (page - 1) * page_size items = items[start:start + page_size]
items = items[start:start + page_size]
available = True available = True
reason = None reason = None
if sort in {"growth", "growth_rate"} and not growth_available: if sort in {"growth", "growth_rate"} and not growth_available:
@@ -732,12 +730,11 @@ class DashboardQueries:
min_videos: int = 1, min_videos: int = 1,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
export: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
if sort not in UP_SORTS: if sort not in UP_SORTS:
raise ValueError(f"不支持的 UP 排序: {sort}") raise ValueError(f"不支持的 UP 排序: {sort}")
page = max(1, int(page)) page = max(1, int(page))
page_size = max(1, min(int(page_size), 200 if not export else 10_000)) page_size = max(1, min(int(page_size), 200))
min_videos = max(1, int(min_videos)) min_videos = max(1, int(min_videos))
def run(conn: sqlite3.Connection) -> dict[str, Any]: def run(conn: sqlite3.Connection) -> dict[str, Any]:
@@ -813,9 +810,8 @@ class DashboardQueries:
key = sort_map[sort] key = sort_map[sort]
items.sort(key=lambda item: (key(item), item["up_mid"]), reverse=True) items.sort(key=lambda item: (key(item), item["up_mid"]), reverse=True)
total = len(items) total = len(items)
if not export: start = (page - 1) * page_size
start = (page - 1) * page_size items = items[start:start + page_size]
items = items[start:start + page_size]
return { return {
"sort": sort, "sort": sort,
"within_days": within_days, "within_days": within_days,
@@ -1115,8 +1111,3 @@ class DashboardQueries:
"telegram_enabled": bool(cfg.telegram.enabled), "telegram_enabled": bool(cfg.telegram.enabled),
} }
return self.db.read(run) return self.db.read(run)
def export_videos(self, **filters) -> dict[str, Any]:
filters.pop("page", None)
filters.pop("page_size", None)
return self.list_videos(**filters, export=True)
+10 -79
View File
@@ -7,16 +7,12 @@
from __future__ import annotations from __future__ import annotations
import csv
import hashlib import hashlib
import hmac import hmac
import io
import json import json
import mimetypes import mimetypes
import os
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date
from http import HTTPStatus from http import HTTPStatus
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -27,7 +23,6 @@ from urllib.parse import parse_qs, unquote, urlsplit
from ..analytics import is_loopback from ..analytics import is_loopback
from ..config import ConfigStore from ..config import ConfigStore
from ..database import Database from ..database import Database
from ..timeutil import shanghai_date
from .queries import DashboardQueries from .queries import DashboardQueries
STATIC_ROOT = Path(__file__).resolve().parent / "static" STATIC_ROOT = Path(__file__).resolve().parent / "static"
@@ -105,7 +100,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
self._method_not_allowed() self._method_not_allowed()
def _method_not_allowed(self) -> None: def _method_not_allowed(self) -> None:
self._send_error(HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "Web 看板只读") self._send_error(HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "不支持该操作")
# ------------------------------------------------------------ dispatch # ------------------------------------------------------------ dispatch
@@ -140,7 +135,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
self._send_error( self._send_error(
HTTPStatus.METHOD_NOT_ALLOWED, HTTPStatus.METHOD_NOT_ALLOWED,
"method_not_allowed", "method_not_allowed",
"Web 看板只读", "不支持该操作",
) )
return return
self._api(path, query, method) self._api(path, query, method)
@@ -220,15 +215,15 @@ class DashboardHandler(BaseHTTPRequestHandler):
<style> <style>
:root{color-scheme:light dark}body{margin:0;min-height:100vh;display:grid; :root{color-scheme:light dark}body{margin:0;min-height:100vh;display:grid;
place-items:center;font-family:system-ui,-apple-system,"Segoe UI",sans-serif; place-items:center;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;
background:#f6f7f9;color:#1c1f23}main{width:min(92vw,360px);padding:28px; background:#f7f2ea;color:#2a201a}main{width:min(92vw,360px);padding:28px;
border-radius:16px;background:#fff;box-shadow:0 12px 40px rgba(0,0,0,.08)} border-radius:16px;background:#fffdf9;box-shadow:0 12px 40px rgba(42,32,26,.12)}
h1{font-size:20px;margin:0 0 8px}p{color:#6b7280;font-size:14px;margin:0 0 18px} h1{font-size:20px;margin:0 0 8px}p{color:#7b6a58;font-size:14px;margin:0 0 18px}
input{width:100%;box-sizing:border-box;padding:12px 14px;border:1px solid #d9dee6; input{width:100%;box-sizing:border-box;padding:12px 14px;border:1px solid #e6d8c4;
border-radius:10px;font-size:16px}button{width:100%;margin-top:12px;padding:12px; border-radius:10px;font-size:16px}button{width:100%;margin-top:12px;padding:12px;
border:0;border-radius:10px;background:#fb7299;color:#fff;font-size:16px; border:0;border-radius:10px;background:#a9702f;color:#fff;font-size:16px;
font-weight:600;cursor:pointer}@media(prefers-color-scheme:dark){body{background:#111315; font-weight:600;cursor:pointer}@media(prefers-color-scheme:dark){body{background:#1b1411;
color:#f3f4f6}main{background:#1b1e22}p{color:#9ca3af}input{background:#111315; color:#f3e9dc}main{background:#221a15}p{color:#c9b6a0}input{background:#1b1411;
border-color:#333a42;color:#f3f4f6}} border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
</style></head><body><main> </style></head><body><main>
<h1>Reaction 看板</h1><p>输入配置中的 web.auth_token 以继续。</p> <h1>Reaction 看板</h1><p>输入配置中的 web.auth_token 以继续。</p>
<form method="post" action="/login"> <form method="post" action="/login">
@@ -378,63 +373,8 @@ border-color:#333a42;color:#f3f4f6}}
if path == "/api/system": if path == "/api/system":
self._send_json(HTTPStatus.OK, queries.system()) self._send_json(HTTPStatus.OK, queries.system())
return return
if path == "/api/export.csv":
self._export_csv(query)
return
raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "接口不存在") raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "接口不存在")
def _export_csv(self, query: Mapping[str, list[str]]) -> None:
data = self.context.queries.export_videos(
sort=_param(query, "sort", "heat"),
within_days=_optional_int(query, "within_days"),
growth_days=_int_param(query, "growth_days", 7, 1, 365),
folder_id=_optional_int(query, "folder"),
q=_optional_str(query, "q"),
page=1,
page_size=10_000,
)
output = io.StringIO()
writer = csv.writer(output, lineterminator="\n")
writer.writerow([
"排名", "aid", "bvid", "标题", "UP", "发布时间", "时长(秒)",
"播放", "点赞", "投币", "收藏", "转发", "评论", "弹幕",
"互动率", "综合热度", "状态", "收藏夹", "快照日期",
])
for index, item in enumerate(data["items"], start=1):
metrics = item.get("metrics") or {}
writer.writerow([
index,
item["aid"],
item.get("bvid") or "",
item.get("title") or "",
item.get("up_name") or "",
_format_date(item.get("pubdate")),
item.get("duration") or "",
metrics.get("view", ""),
metrics.get("like", ""),
metrics.get("coin", ""),
metrics.get("favorite", ""),
metrics.get("share", ""),
metrics.get("reply", ""),
metrics.get("danmaku", ""),
"" if item.get("interaction_rate") is None else f"{item['interaction_rate']:.6f}",
"" if item.get("heat") is None else item["heat"],
item.get("status") or "",
item.get("folder_title") or "",
item.get("snapshot_date") or "",
])
payload = ("\ufeff" + output.getvalue()).encode("utf-8")
filename = f"reaction-board-{shanghai_date(self.context.clock())}.csv"
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/csv; charset=utf-8")
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
self._security_headers()
self.end_headers()
if self.command != "HEAD":
self.wfile.write(payload)
# ------------------------------------------------------------ static # ------------------------------------------------------------ static
def _static(self, path: str, method: str) -> None: def _static(self, path: str, method: str) -> None:
@@ -588,15 +528,6 @@ def _range_days(query: Mapping[str, list[str]]) -> int | None:
return days return days
def _format_date(timestamp: int | float | None) -> str:
if not timestamp:
return ""
try:
return shanghai_date(float(timestamp))
except (OverflowError, OSError, ValueError):
return ""
def _preview_marker(db: Database) -> bool: def _preview_marker(db: Database) -> bool:
def run(conn): def run(conn):
row = conn.execute("SELECT v FROM kv WHERE k = 'preview:seeded'").fetchone() row = conn.execute("SELECT v FROM kv WHERE k = 'preview:seeded'").fetchone()
-4
View File
@@ -44,10 +44,6 @@ export async function api(path, params) {
return payload.data; return payload.data;
} }
export function apiUrl(path, params) {
return buildUrl(path, params).toString();
}
export async function health() { export async function health() {
const response = await fetch("/api/health", { credentials: "same-origin" }); const response = await fetch("/api/health", { credentials: "same-origin" });
if (!response.ok) throw new ApiError("无法连接看板服务", response.status); if (!response.ok) throw new ApiError("无法连接看板服务", response.status);
+2
View File
@@ -90,11 +90,13 @@ window.addEventListener("error", (event) => {
async function boot() { async function boot() {
renderNav(); renderNav();
const banner = document.querySelector("#preview-banner"); const banner = document.querySelector("#preview-banner");
const hint = document.querySelector("#topbar-hint");
try { try {
const payload = await health(); const payload = await health();
const data = payload?.data || payload; const data = payload?.data || payload;
if (data?.preview) { if (data?.preview) {
banner.hidden = false; banner.hidden = false;
if (hint) hint.hidden = false;
} }
} catch { } catch {
toast("无法连接看板服务", "error"); toast("无法连接看板服务", "error");
+21 -20
View File
@@ -6,15 +6,21 @@ let resizeObserver = null;
function colors() { function colors() {
const styles = getComputedStyle(document.documentElement); const styles = getComputedStyle(document.documentElement);
return { return {
text: styles.getPropertyValue("--text").trim() || "#1c1f23", text: styles.getPropertyValue("--text").trim() || "#2a201a",
muted: styles.getPropertyValue("--muted").trim() || "#6b7280", muted: styles.getPropertyValue("--muted").trim() || "#7b6a58",
grid: styles.getPropertyValue("--grid").trim() || "#e8ebf0", grid: styles.getPropertyValue("--grid").trim() || "#eadfce",
accent: "#fb7299", accent: styles.getPropertyValue("--accent").trim() || "#a9702f",
blue: "#00aeec", blue: styles.getPropertyValue("--blue").trim() || "#5f7f96",
surface: styles.getPropertyValue("--surface").trim() || "#ffffff", success: styles.getPropertyValue("--success").trim() || "#5f7a4e",
danger: styles.getPropertyValue("--danger").trim() || "#b4553f",
surface: styles.getPropertyValue("--surface").trim() || "#fffdf9",
}; };
} }
export function themeColor(name) {
return colors()[name] || colors().accent;
}
function ensureEcharts() { function ensureEcharts() {
if (!window.echarts) { if (!window.echarts) {
throw new Error("ECharts 未加载,请检查 /static/vendor/echarts.min.js"); throw new Error("ECharts 未加载,请检查 /static/vendor/echarts.min.js");
@@ -54,16 +60,10 @@ export function disposeAll() {
resizeObserver = null; resizeObserver = null;
} }
const palette = { export function lineChart(element, { dates, values, name = "数值", color, scale = false }) {
accent: "#fb7299",
blue: "#00aeec",
purple: "#9b7bff",
green: "#22c55e",
};
export function lineChart(element, { dates, values, name = "数值", color = palette.accent, scale = false }) {
if (!element) return null; if (!element) return null;
const theme = colors(); const theme = colors();
const lineColor = color || theme.accent;
const option = { const option = {
animationDuration: 300, animationDuration: 300,
backgroundColor: "transparent", backgroundColor: "transparent",
@@ -114,14 +114,14 @@ export function lineChart(element, { dates, values, name = "数值", color = pal
symbol: "circle", symbol: "circle",
symbolSize: 5, symbolSize: 5,
showSymbol: values.length <= 45, showSymbol: values.length <= 45,
lineStyle: { width: 2.2, color }, lineStyle: { width: 2.2, color: lineColor },
itemStyle: { color }, itemStyle: { color: lineColor },
areaStyle: { areaStyle: {
color: { color: {
type: "linear", x: 0, y: 0, x2: 0, y2: 1, type: "linear", x: 0, y: 0, x2: 0, y2: 1,
colorStops: [ colorStops: [
{ offset: 0, color: `${color}55` }, { offset: 0, color: `${lineColor}55` },
{ offset: 1, color: `${color}00` }, { offset: 1, color: `${lineColor}00` },
], ],
}, },
}, },
@@ -130,9 +130,10 @@ export function lineChart(element, { dates, values, name = "数值", color = pal
return mount(element, option); return mount(element, option);
} }
export function barChart(element, { labels, values, name = "数值", color = palette.blue }) { export function barChart(element, { labels, values, name = "数值", color }) {
if (!element) return null; if (!element) return null;
const theme = colors(); const theme = colors();
const barColor = color || theme.blue;
const option = { const option = {
animationDuration: 300, animationDuration: 300,
backgroundColor: "transparent", backgroundColor: "transparent",
@@ -176,7 +177,7 @@ export function barChart(element, { labels, values, name = "数值", color = pal
type: "bar", type: "bar",
data: values, data: values,
barMaxWidth: 26, barMaxWidth: 26,
itemStyle: { color, borderRadius: [4, 4, 0, 0] }, itemStyle: { color: barColor, borderRadius: [4, 4, 0, 0] },
}], }],
}; };
return mount(element, option); return mount(element, option);
+1 -1
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"> <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"> <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fb7299"/><stop offset="1" stop-color="#ff9fbc"/> <stop offset="0" stop-color="#e9b97c"/><stop offset="1" stop-color="#c98e52"/>
</linearGradient></defs> </linearGradient></defs>
<rect width="64" height="64" rx="18" fill="url(#g)"/> <rect width="64" height="64" rx="18" fill="url(#g)"/>
<text x="32" y="42" font-size="34" font-weight="700" text-anchor="middle" <text x="32" y="42" font-size="34" font-weight="700" text-anchor="middle"

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 433 B

+4 -2
View File
@@ -4,6 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f7f2ea" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1b1411" media="(prefers-color-scheme: dark)">
<title>咻咻满 Reaction 看板</title> <title>咻咻满 Reaction 看板</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"> <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">
@@ -18,9 +20,9 @@
<small id="topbar-title">最新动态</small> <small id="topbar-title">最新动态</small>
</span> </span>
</a> </a>
<span class="topbar-hint">只读</span> <span class="topbar-hint" id="topbar-hint" hidden>预览</span>
</header> </header>
<div class="preview-banner" id="preview-banner" hidden>预览数据 · 只读 · 不影响真实数据库</div> <div class="preview-banner" id="preview-banner" hidden>预览数据 · 不影响真实数据库</div>
<main class="main-shell"> <main class="main-shell">
<div id="view"></div> <div id="view"></div>
</main> </main>
+72 -47
View File
@@ -1,43 +1,57 @@
:root { :root {
color-scheme: light dark; color-scheme: light dark;
--bg: #f5f6f8; --bg: #f7f2ea;
--surface: #ffffff; --bg-deep: #efe7db;
--surface-2: #eef1f5; --surface: #fffdf9;
--surface-3: #e5e9ef; --surface-2: #f2ebe0;
--text: #1b1e23; --surface-3: #e9ddca;
--muted: #6b7280; --text: #2a201a;
--border: #e3e7ee; --muted: #7b6a58;
--grid: #e8ebf0; --faint: #a3927f;
--accent: #fb7299; --border: #e6d8c4;
--accent-strong: #e75c84; --grid: #eadfce;
--accent-soft: #ffe8ef; --accent: #a9702f;
--blue: #00aeec; --accent-strong: #8a5417;
--blue-soft: #e3f7fe; --accent-soft: #f3e2c5;
--warning: #d97706; --accent-contrast: #ffffff;
--warning-soft: #fff4df; --blue: #5f7f96;
--danger: #dc2626; --blue-soft: #e3edf3;
--success: #16a34a; --pink: #c25a52;
--warning: #b9793a;
--warning-soft: #f7e6c8;
--danger: #b4553f;
--success: #5f7a4e;
--radius: 16px; --radius: 16px;
--radius-small: 10px; --radius-small: 10px;
--shadow: 0 8px 30px rgba(20, 28, 45, 0.06); --shadow: 0 10px 30px rgba(42, 32, 26, 0.08);
--nav-width: 220px; --nav-width: 220px;
--nav-height: 64px; --nav-height: 64px;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #0e1014; --bg: #1b1411;
--surface: #171a20; --bg-deep: #140f0c;
--surface-2: #20252d; --surface: #221a15;
--surface-3: #2a313b; --surface-2: #2a201a;
--text: #edf0f4; --surface-3: #342820;
--muted: #9aa3af; --text: #f3e9dc;
--border: #2a3038; --muted: #c9b6a0;
--grid: #2a3038; --faint: #9c8976;
--accent-soft: #3a2029; --border: #3a2c22;
--blue-soft: #102f3a; --grid: #3a2c22;
--warning-soft: #3a2a12; --accent: #e9b97c;
--shadow: 0 12px 34px rgba(0, 0, 0, 0.28); --accent-strong: #f6d9ae;
--accent-soft: #e9b97c29;
--accent-contrast: #2a1a10;
--blue: #a8bccc;
--blue-soft: #a8bccc1f;
--pink: #e3958c;
--warning: #e9b97c;
--warning-soft: #e9b97c1f;
--danger: #e3958c;
--success: #a7b98f;
--shadow: 0 14px 38px rgba(0, 0, 0, 0.35);
} }
} }
@@ -92,11 +106,11 @@ h1, h2, h3, p { margin: 0; }
height: 34px; height: 34px;
flex: 0 0 auto; flex: 0 0 auto;
border-radius: 11px; border-radius: 11px;
background: linear-gradient(135deg, var(--accent), #ff9fbc); background: linear-gradient(135deg, var(--accent-strong), var(--accent));
color: #fff; color: var(--accent-contrast);
font-weight: 800; font-weight: 800;
font-size: 17px; font-size: 17px;
box-shadow: 0 6px 16px rgba(251, 114, 153, 0.3); box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 35%, transparent);
} }
.brand-text { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; } .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 strong { font-size: 15px; letter-spacing: 0.2px; }
@@ -118,11 +132,11 @@ h1, h2, h3, p { margin: 0; }
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #5c3b00; color: var(--accent-contrast);
background: #ffd479; background: var(--accent);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.preview-banner { color: #ffe7b0; background: #6b4a00; } .preview-banner { color: var(--accent-contrast); background: var(--accent); }
} }
.main-shell { .main-shell {
@@ -161,7 +175,7 @@ h1, h2, h3, p { margin: 0; }
border: 0; border: 0;
border-radius: 10px; border-radius: 10px;
background: var(--accent); background: var(--accent);
color: #fff; color: var(--accent-contrast);
font-size: 13px; font-size: 13px;
font-weight: 650; font-weight: 650;
cursor: pointer; cursor: pointer;
@@ -306,9 +320,9 @@ h1, h2, h3, p { margin: 0; }
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
} }
.rank-badge.rank-1 { background: #ffd166; color: #5b3a00; } .rank-badge.rank-1 { background: var(--accent); color: var(--accent-contrast); }
.rank-badge.rank-2 { background: #dbe3ec; color: #39414d; } .rank-badge.rank-2 { background: var(--accent-soft); color: var(--accent-strong); }
.rank-badge.rank-3 { background: #f2c7a5; color: #5c3218; } .rank-badge.rank-3 { background: var(--surface-3); color: var(--muted); }
.video-thumb, .hero-cover, .heat-thumb, .candidate-thumb { .video-thumb, .hero-cover, .heat-thumb, .candidate-thumb {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -469,7 +483,7 @@ h1, h2, h3, p { margin: 0; }
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
.segmented button.active { background: var(--accent); color: #fff; } .segmented button.active { background: var(--accent); color: var(--accent-contrast); }
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } .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 { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 2px; scrollbar-width: none; }
@@ -489,7 +503,17 @@ h1, h2, h3, p { margin: 0; }
.chip-label { color: var(--muted); font-size: 11px; padding-right: 2px; } .chip-label { color: var(--muted); font-size: 11px; padding-right: 2px; }
.range-row { margin-top: 8px; } .range-row { margin-top: 8px; }
.filter-bar { display: flex; gap: 7px; } .rank-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.rank-search { display: flex; flex: 1; min-width: 0; gap: 6px; }
.rank-search .search-input { flex: 1; min-width: 0; }
@media (max-width: 619px) {
.rank-search { flex-basis: 100%; }
}
.search-input, .select { .search-input, .select {
min-height: 38px; min-height: 38px;
padding: 8px 11px; padding: 8px 11px;
@@ -613,8 +637,8 @@ h1, h2, h3, p { margin: 0; }
.hero-number { .hero-number {
padding: 18px; padding: 18px;
border-radius: var(--radius); border-radius: var(--radius);
background: linear-gradient(135deg, #2b2140, #4a2c4f 55%, #a33d5f); background: linear-gradient(135deg, var(--accent-strong), var(--accent) 65%, var(--bg-deep));
color: #fff; color: var(--accent-contrast);
} }
.hero-number span { display: block; font-size: 12px; opacity: 0.72; } .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 strong { display: block; margin: 4px 0 2px; font-size: 34px; letter-spacing: -1px; }
@@ -680,8 +704,9 @@ h1, h2, h3, p { margin: 0; }
max-width: min(88vw, 420px); max-width: min(88vw, 420px);
padding: 9px 14px; padding: 9px 14px;
border-radius: 999px; border-radius: 999px;
background: rgba(20, 24, 32, 0.92); background: var(--bg-deep);
color: #fff; color: var(--text);
border: 1px solid var(--border);
font-size: 12.5px; font-size: 12.5px;
opacity: 0; opacity: 0;
transform: translate(-50%, 10px); transform: translate(-50%, 10px);
@@ -689,7 +714,7 @@ h1, h2, h3, p { margin: 0; }
transition: opacity 0.18s, transform 0.18s; transition: opacity 0.18s, transform 0.18s;
} }
.toast.show { opacity: 1; transform: translate(-50%, 0); } .toast.show { opacity: 1; transform: translate(-50%, 0); }
.toast.error { background: rgba(160, 30, 45, 0.95); } .toast.error { background: var(--danger); color: #fff7f2; border-color: transparent; }
@media (min-width: 620px) { @media (min-width: 620px) {
.live-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); } .live-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@@ -49,10 +49,10 @@ export async function renderCandidates(root) {
</div> </div>
</header> </header>
<div class="notice-row"> ${data.items.length ? `<div class="notice-row">
<strong>Web 看板只读</strong> <strong>候选处理</strong>
<span>${data.telegram_enabled ? "请在 Telegram 消息中处理候选。" : "Telegram 未启用,请使用 CLI 或数据库处理候选。"}</span> <span>${data.telegram_enabled ? "请在 Telegram 消息中收藏、忽略或拉黑。" : "Telegram 未启用,请使用 CLI 或数据库处理候选。"}</span>
</div> </div>` : ""}
<section class="panel"> <section class="panel">
${sectionHeader("待确认")} ${sectionHeader("待确认")}
+2 -2
View File
@@ -1,6 +1,6 @@
import { api } from "../api.js"; import { api } from "../api.js";
import { centerActiveChips, emptyState, sectionHeader } from "../components.js"; import { centerActiveChips, emptyState, sectionHeader } from "../components.js";
import { lineChart } from "../charts.js"; import { lineChart, themeColor } from "../charts.js";
import { import {
escapeAttr, escapeHtml, formatCount, formatDate, formatDelta, escapeAttr, escapeHtml, formatCount, formatDate, formatDelta,
} from "../format.js"; } from "../format.js";
@@ -82,7 +82,7 @@ export async function renderFans(root, { query }) {
dates: fans.dates || [], dates: fans.dates || [],
values: fans.series || [], values: fans.series || [],
name: "粉丝", name: "粉丝",
color: "#9b7bff", color: themeColor("accent"),
scale: true, scale: true,
}); });
} }
+12 -19
View File
@@ -1,4 +1,4 @@
import { api, apiUrl } from "../api.js"; import { api } from "../api.js";
import { import {
centerActiveChips, chip, emptyState, pager, sectionHeader, upCard, videoCard, centerActiveChips, chip, emptyState, pager, sectionHeader, upCard, videoCard,
} from "../components.js"; } from "../components.js";
@@ -8,7 +8,7 @@ import { replaceQuery } from "../router.js";
const VIDEO_SORTS = [ const VIDEO_SORTS = [
["heat", "综合热度"], ["view", "播放"], ["like", "点赞"], ["coin", "投币"], ["heat", "综合热度"], ["view", "播放"], ["like", "点赞"], ["coin", "投币"],
["favorite", "收藏"], ["share", "转发"], ["reply", "评论"], ["danmaku", "弹幕"], ["favorite", "收藏"], ["share", "转发"], ["reply", "评论"], ["danmaku", "弹幕"],
["interaction", "互动率"], ["growth", "增长"], ["growth_rate", "增速"], ["interaction", "互动率"], ["growth", "播放增量"], ["growth_rate", "播放增速"],
]; ];
const UP_SORTS = [ const UP_SORTS = [
["avg_views", "平均播放"], ["avg_interaction", "平均互动"], ["avg_views", "平均播放"], ["avg_interaction", "平均互动"],
@@ -20,18 +20,20 @@ const METRIC_PRIMARY = new Set(["view", "like", "coin", "favorite", "share", "re
function controls({ scope, sort, within, growthDays, q, folder, minVideos, folders }) { function controls({ scope, sort, within, growthDays, q, folder, minVideos, folders }) {
const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS; const sorts = scope === "up" ? UP_SORTS : VIDEO_SORTS;
return ` return `
<div class="segmented" role="tablist"> <div class="rank-toolbar">
<button type="button" class="${scope === "video" ? "active" : ""}" data-scope="video">视频</button> <div class="segmented" role="tablist">
<button type="button" class="${scope === "up" ? "active" : ""}" data-scope="up">UP</button> <button type="button" class="${scope === "video" ? "active" : ""}" data-scope="video">视频</button>
<button type="button" class="${scope === "up" ? "active" : ""}" data-scope="up">UP</button>
</div>
<form class="rank-search" 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> </div>
<div class="chip-row scroll-x"> <div class="chip-row scroll-x">
${sorts.map(([value, label]) => chip(label, sort === value, "sort", value)).join("")} ${sorts.map(([value, label]) => chip(label, sort === value, "sort", value)).join("")}
</div> </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="filter-row">
<div class="chip-row scroll-x"> <div class="chip-row scroll-x">
${[["all", "全部发布时间"], ["7", "近 7 天"], ["30", "近 30 天"], ["90", "近 90 天"]] ${[["all", "全部发布时间"], ["7", "近 7 天"], ["30", "近 30 天"], ["90", "近 90 天"]]
@@ -106,12 +108,6 @@ export async function renderRankings(root, { query }) {
} }
const startRank = (data.page - 1) * data.page_size + 1; 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"> root.innerHTML = `<section class="page rankings-page">
<header class="page-head"> <header class="page-head">
@@ -119,9 +115,6 @@ export async function renderRankings(root, { query }) {
<h1>榜单</h1> <h1>榜单</h1>
<p class="page-sub">共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}</p> <p class="page-sub">共 ${formatCount(data.total)} ${scope === "up" ? "个 UP" : "条视频"}</p>
</div> </div>
${scope === "video"
? `<a class="button ghost" href="${escapeAttr(apiUrl("/api/export.csv", exportParams))}">导出 CSV</a>`
: ""}
</header> </header>
${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })} ${controls({ scope, sort, within, growthDays, q, folder, minVideos, folders })}
${data.available === false ? `<div class="notice-row warning"> ${data.available === false ? `<div class="notice-row warning">
+1 -1
View File
@@ -104,7 +104,7 @@ export async function renderSystem(root) {
<section class="panel"> <section class="panel">
${sectionHeader("UP 黑名单")} ${sectionHeader("UP 黑名单")}
<p class="section-note">只影响未来搜索发现;已入库视频继续追踪。Web 只读,移除请使用 CLI 或数据库。</p> <p class="section-note">只影响未来搜索发现;已入库视频继续追踪。移除请使用 CLI 或数据库。</p>
${(system.blacklist || []).length ? `<div class="table-wrap"> ${(system.blacklist || []).length ? `<div class="table-wrap">
<table class="data-table"> <table class="data-table">
<thead><tr><th>mid</th><th>昵称</th><th>原因</th><th>在库视频</th></tr></thead> <thead><tr><th>mid</th><th>昵称</th><th>原因</th><th>在库视频</th></tr></thead>
+3 -3
View File
@@ -2,7 +2,7 @@ import { api } from "../api.js";
import { import {
badge, emptyState, sectionHeader, videoCard, badge, emptyState, sectionHeader, videoCard,
} from "../components.js"; } from "../components.js";
import { barChart, lineChart } from "../charts.js"; import { barChart, lineChart, themeColor } from "../charts.js";
import { import {
escapeAttr, escapeHtml, formatCount, formatDate, formatPercent, escapeAttr, escapeHtml, formatCount, formatDate, formatPercent,
} from "../format.js"; } from "../format.js";
@@ -89,7 +89,7 @@ export async function renderUp(root, { params, query }) {
dates: up.series.dates, dates: up.series.dates,
values: up.series.views, values: up.series.views,
name: "总播放", name: "总播放",
color: "#fb7299", color: themeColor("accent"),
scale: true, scale: true,
}); });
} }
@@ -99,7 +99,7 @@ export async function renderUp(root, { params, query }) {
labels: topForBar.map((item) => (item.title || `#${item.aid}`).slice(0, 12)), labels: topForBar.map((item) => (item.title || `#${item.aid}`).slice(0, 12)),
values: topForBar.map((item) => item.metrics?.view || 0), values: topForBar.map((item) => item.metrics?.view || 0),
name: "播放", name: "播放",
color: "#00aeec", color: themeColor("blue"),
}); });
} }
root.querySelector("#up-sort")?.addEventListener("change", (event) => { root.querySelector("#up-sort")?.addEventListener("change", (event) => {
+2 -2
View File
@@ -3,7 +3,7 @@ import {
badge, centerActiveChips, cover, emptyState, metricChips, metricGrid, badge, centerActiveChips, cover, emptyState, metricChips, metricGrid,
sectionHeader, videoCard, sectionHeader, videoCard,
} from "../components.js"; } from "../components.js";
import { lineChart, emptyChart } from "../charts.js"; import { lineChart, themeColor } from "../charts.js";
import { import {
escapeAttr, escapeHtml, formatCount, formatDate, formatDelta, escapeAttr, escapeHtml, formatCount, formatDate, formatDelta,
formatDuration, formatPercent, formatScore, metricLabel, statusLabel, formatDuration, formatPercent, formatScore, metricLabel, statusLabel,
@@ -119,7 +119,7 @@ export async function renderVideo(root, { params, query }) {
dates: trend.dates || [], dates: trend.dates || [],
values: trend.series?.[metric] || [], values: trend.series?.[metric] || [],
name: metricLabel(metric), name: metricLabel(metric),
color: metric === "view" ? "#fb7299" : "#00aeec", color: metric === "view" ? themeColor("accent") : themeColor("blue"),
scale: true, scale: true,
}); });
} }
-4
View File
@@ -239,10 +239,6 @@ def test_fans_candidates_system_and_export():
assert system["next_runs"]["snapshot"] > 0 assert system["next_runs"]["snapshot"] > 0
assert system["next_runs"]["folder_sync"] > FIXED_NOW assert system["next_runs"]["folder_sync"] > FIXED_NOW
assert system["next_runs"]["discover"] > FIXED_NOW assert system["next_runs"]["discover"] > FIXED_NOW
exported = q.export_videos(sort="view")
assert exported["total"] == 3
assert all("metrics" in item for item in exported["items"])
finally: finally:
db.close() db.close()
+1 -3
View File
@@ -119,9 +119,7 @@ def test_readonly_routes_and_static():
assert status == 404 assert status == 404
status, _, payload = running.request("/api/export.csv?sort=view") status, _, payload = running.request("/api/export.csv?sort=view")
assert status == 200 assert status == 404
assert payload.startswith(b"\xef\xbb\xbf")
assert "咻咻满".encode("utf-8") in payload
status, headers, _ = running.request("/api/overview") status, headers, _ = running.request("/api/overview")
assert headers["X-Content-Type-Options"] == "nosniff" assert headers["X-Content-Type-Options"] == "nosniff"