feat(web): refresh palette, simplify ranking toolbar and drop export
This commit is contained in:
1 parent
6a053bdfe8
commit
c2ef998720
16 files changed
+141
-206
No files matched your search
@@ -523,14 +523,13 @@ class DashboardQueries:
|
||||
q: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
export: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if sort not in VIDEO_SORTS:
|
||||
raise ValueError(f"不支持的排序: {sort}")
|
||||
if growth_days < 1 or growth_days > 365:
|
||||
raise ValueError("growth_days 必须在 1~365 之间")
|
||||
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]:
|
||||
latest = self._latest_date(conn) or self._today()
|
||||
@@ -607,9 +606,8 @@ class DashboardQueries:
|
||||
growth_available = any(
|
||||
item.get("growth", {}).get("available") for item in items
|
||||
)
|
||||
if not export:
|
||||
start = (page - 1) * page_size
|
||||
items = items[start:start + page_size]
|
||||
start = (page - 1) * page_size
|
||||
items = items[start:start + page_size]
|
||||
available = True
|
||||
reason = None
|
||||
if sort in {"growth", "growth_rate"} and not growth_available:
|
||||
@@ -732,12 +730,11 @@ class DashboardQueries:
|
||||
min_videos: int = 1,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
export: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if sort not in UP_SORTS:
|
||||
raise ValueError(f"不支持的 UP 排序: {sort}")
|
||||
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))
|
||||
|
||||
def run(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
@@ -813,9 +810,8 @@ class DashboardQueries:
|
||||
key = sort_map[sort]
|
||||
items.sort(key=lambda item: (key(item), item["up_mid"]), reverse=True)
|
||||
total = len(items)
|
||||
if not export:
|
||||
start = (page - 1) * page_size
|
||||
items = items[start:start + page_size]
|
||||
start = (page - 1) * page_size
|
||||
items = items[start:start + page_size]
|
||||
return {
|
||||
"sort": sort,
|
||||
"within_days": within_days,
|
||||
@@ -1115,8 +1111,3 @@ class DashboardQueries:
|
||||
"telegram_enabled": bool(cfg.telegram.enabled),
|
||||
}
|
||||
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)
|
||||
@@ -7,16 +7,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from http import HTTPStatus
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -27,7 +23,6 @@ from urllib.parse import parse_qs, unquote, urlsplit
|
||||
from ..analytics import is_loopback
|
||||
from ..config import ConfigStore
|
||||
from ..database import Database
|
||||
from ..timeutil import shanghai_date
|
||||
from .queries import DashboardQueries
|
||||
|
||||
STATIC_ROOT = Path(__file__).resolve().parent / "static"
|
||||
@@ -105,7 +100,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
||||
self._method_not_allowed()
|
||||
|
||||
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
|
||||
|
||||
@@ -140,7 +135,7 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
||||
self._send_error(
|
||||
HTTPStatus.METHOD_NOT_ALLOWED,
|
||||
"method_not_allowed",
|
||||
"Web 看板只读",
|
||||
"不支持该操作",
|
||||
)
|
||||
return
|
||||
self._api(path, query, method)
|
||||
@@ -220,15 +215,15 @@ class DashboardHandler(BaseHTTPRequestHandler):
|
||||
<style>
|
||||
: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;
|
||||
background:#f6f7f9;color:#1c1f23}main{width:min(92vw,360px);padding:28px;
|
||||
border-radius:16px;background:#fff;box-shadow:0 12px 40px rgba(0,0,0,.08)}
|
||||
h1{font-size:20px;margin:0 0 8px}p{color:#6b7280;font-size:14px;margin:0 0 18px}
|
||||
input{width:100%;box-sizing:border-box;padding:12px 14px;border:1px solid #d9dee6;
|
||||
background:#f7f2ea;color:#2a201a}main{width:min(92vw,360px);padding:28px;
|
||||
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:#7b6a58;font-size:14px;margin:0 0 18px}
|
||||
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:0;border-radius:10px;background:#fb7299;color:#fff;font-size:16px;
|
||||
font-weight:600;cursor:pointer}@media(prefers-color-scheme:dark){body{background:#111315;
|
||||
color:#f3f4f6}main{background:#1b1e22}p{color:#9ca3af}input{background:#111315;
|
||||
border-color:#333a42;color:#f3f4f6}}
|
||||
border:0;border-radius:10px;background:#a9702f;color:#fff;font-size:16px;
|
||||
font-weight:600;cursor:pointer}@media(prefers-color-scheme:dark){body{background:#1b1411;
|
||||
color:#f3e9dc}main{background:#221a15}p{color:#c9b6a0}input{background:#1b1411;
|
||||
border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
|
||||
</style></head><body><main>
|
||||
<h1>Reaction 看板</h1><p>输入配置中的 web.auth_token 以继续。</p>
|
||||
<form method="post" action="/login">
|
||||
@@ -378,63 +373,8 @@ border-color:#333a42;color:#f3f4f6}}
|
||||
if path == "/api/system":
|
||||
self._send_json(HTTPStatus.OK, queries.system())
|
||||
return
|
||||
if path == "/api/export.csv":
|
||||
self._export_csv(query)
|
||||
return
|
||||
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
|
||||
|
||||
def _static(self, path: str, method: str) -> None:
|
||||
@@ -588,15 +528,6 @@ def _range_days(query: Mapping[str, list[str]]) -> int | None:
|
||||
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 run(conn):
|
||||
row = conn.execute("SELECT v FROM kv WHERE k = 'preview:seeded'").fetchone()
|
||||
|
||||
@@ -44,10 +44,6 @@ export async function api(path, params) {
|
||||
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);
|
||||
|
||||
@@ -90,11 +90,13 @@ window.addEventListener("error", (event) => {
|
||||
async function boot() {
|
||||
renderNav();
|
||||
const banner = document.querySelector("#preview-banner");
|
||||
const hint = document.querySelector("#topbar-hint");
|
||||
try {
|
||||
const payload = await health();
|
||||
const data = payload?.data || payload;
|
||||
if (data?.preview) {
|
||||
banner.hidden = false;
|
||||
if (hint) hint.hidden = false;
|
||||
}
|
||||
} catch {
|
||||
toast("无法连接看板服务", "error");
|
||||
|
||||
@@ -6,15 +6,21 @@ 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",
|
||||
text: styles.getPropertyValue("--text").trim() || "#2a201a",
|
||||
muted: styles.getPropertyValue("--muted").trim() || "#7b6a58",
|
||||
grid: styles.getPropertyValue("--grid").trim() || "#eadfce",
|
||||
accent: styles.getPropertyValue("--accent").trim() || "#a9702f",
|
||||
blue: styles.getPropertyValue("--blue").trim() || "#5f7f96",
|
||||
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() {
|
||||
if (!window.echarts) {
|
||||
throw new Error("ECharts 未加载,请检查 /static/vendor/echarts.min.js");
|
||||
@@ -54,16 +60,10 @@ export function disposeAll() {
|
||||
resizeObserver = null;
|
||||
}
|
||||
|
||||
const palette = {
|
||||
accent: "#fb7299",
|
||||
blue: "#00aeec",
|
||||
purple: "#9b7bff",
|
||||
green: "#22c55e",
|
||||
};
|
||||
|
||||
export function lineChart(element, { dates, values, name = "数值", color = palette.accent, scale = false }) {
|
||||
export function lineChart(element, { dates, values, name = "数值", color, scale = false }) {
|
||||
if (!element) return null;
|
||||
const theme = colors();
|
||||
const lineColor = color || theme.accent;
|
||||
const option = {
|
||||
animationDuration: 300,
|
||||
backgroundColor: "transparent",
|
||||
@@ -114,14 +114,14 @@ export function lineChart(element, { dates, values, name = "数值", color = pal
|
||||
symbol: "circle",
|
||||
symbolSize: 5,
|
||||
showSymbol: values.length <= 45,
|
||||
lineStyle: { width: 2.2, color },
|
||||
itemStyle: { color },
|
||||
lineStyle: { width: 2.2, color: lineColor },
|
||||
itemStyle: { color: lineColor },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: "linear", x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: `${color}55` },
|
||||
{ offset: 1, color: `${color}00` },
|
||||
{ offset: 0, color: `${lineColor}55` },
|
||||
{ offset: 1, color: `${lineColor}00` },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -130,9 +130,10 @@ export function lineChart(element, { dates, values, name = "数值", color = pal
|
||||
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;
|
||||
const theme = colors();
|
||||
const barColor = color || theme.blue;
|
||||
const option = {
|
||||
animationDuration: 300,
|
||||
backgroundColor: "transparent",
|
||||
@@ -176,7 +177,7 @@ export function barChart(element, { labels, values, name = "数值", color = pal
|
||||
type: "bar",
|
||||
data: values,
|
||||
barMaxWidth: 26,
|
||||
itemStyle: { color, borderRadius: [4, 4, 0, 0] },
|
||||
itemStyle: { color: barColor, borderRadius: [4, 4, 0, 0] },
|
||||
}],
|
||||
};
|
||||
return mount(element, option);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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"/>
|
||||
<stop offset="0" stop-color="#e9b97c"/><stop offset="1" stop-color="#c98e52"/>
|
||||
</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"
|
||||
|
||||
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 433 B |
@@ -4,6 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<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>
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
@@ -18,9 +20,9 @@
|
||||
<small id="topbar-title">最新动态</small>
|
||||
</span>
|
||||
</a>
|
||||
<span class="topbar-hint">只读</span>
|
||||
<span class="topbar-hint" id="topbar-hint" hidden>预览</span>
|
||||
</header>
|
||||
<div class="preview-banner" id="preview-banner" hidden>预览数据 · 只读 · 不影响真实数据库</div>
|
||||
<div class="preview-banner" id="preview-banner" hidden>预览数据 · 不影响真实数据库</div>
|
||||
<main class="main-shell">
|
||||
<div id="view"></div>
|
||||
</main>
|
||||
|
||||
@@ -1,43 +1,57 @@
|
||||
: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;
|
||||
--bg: #f7f2ea;
|
||||
--bg-deep: #efe7db;
|
||||
--surface: #fffdf9;
|
||||
--surface-2: #f2ebe0;
|
||||
--surface-3: #e9ddca;
|
||||
--text: #2a201a;
|
||||
--muted: #7b6a58;
|
||||
--faint: #a3927f;
|
||||
--border: #e6d8c4;
|
||||
--grid: #eadfce;
|
||||
--accent: #a9702f;
|
||||
--accent-strong: #8a5417;
|
||||
--accent-soft: #f3e2c5;
|
||||
--accent-contrast: #ffffff;
|
||||
--blue: #5f7f96;
|
||||
--blue-soft: #e3edf3;
|
||||
--pink: #c25a52;
|
||||
--warning: #b9793a;
|
||||
--warning-soft: #f7e6c8;
|
||||
--danger: #b4553f;
|
||||
--success: #5f7a4e;
|
||||
--radius: 16px;
|
||||
--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-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);
|
||||
--bg: #1b1411;
|
||||
--bg-deep: #140f0c;
|
||||
--surface: #221a15;
|
||||
--surface-2: #2a201a;
|
||||
--surface-3: #342820;
|
||||
--text: #f3e9dc;
|
||||
--muted: #c9b6a0;
|
||||
--faint: #9c8976;
|
||||
--border: #3a2c22;
|
||||
--grid: #3a2c22;
|
||||
--accent: #e9b97c;
|
||||
--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;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, var(--accent), #ff9fbc);
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--accent-strong), var(--accent));
|
||||
color: var(--accent-contrast);
|
||||
font-weight: 800;
|
||||
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 strong { font-size: 15px; letter-spacing: 0.2px; }
|
||||
@@ -118,11 +132,11 @@ h1, h2, h3, p { margin: 0; }
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #5c3b00;
|
||||
background: #ffd479;
|
||||
color: var(--accent-contrast);
|
||||
background: var(--accent);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.preview-banner { color: #ffe7b0; background: #6b4a00; }
|
||||
.preview-banner { color: var(--accent-contrast); background: var(--accent); }
|
||||
}
|
||||
|
||||
.main-shell {
|
||||
@@ -161,7 +175,7 @@ h1, h2, h3, p { margin: 0; }
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-contrast);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
cursor: pointer;
|
||||
@@ -306,9 +320,9 @@ h1, h2, h3, p { margin: 0; }
|
||||
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; }
|
||||
.rank-badge.rank-1 { background: var(--accent); color: var(--accent-contrast); }
|
||||
.rank-badge.rank-2 { background: var(--accent-soft); color: var(--accent-strong); }
|
||||
.rank-badge.rank-3 { background: var(--surface-3); color: var(--muted); }
|
||||
.video-thumb, .hero-cover, .heat-thumb, .candidate-thumb {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -469,7 +483,7 @@ h1, h2, h3, p { margin: 0; }
|
||||
font-weight: 600;
|
||||
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.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; }
|
||||
.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 {
|
||||
min-height: 38px;
|
||||
padding: 8px 11px;
|
||||
@@ -613,8 +637,8 @@ h1, h2, h3, p { margin: 0; }
|
||||
.hero-number {
|
||||
padding: 18px;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(135deg, #2b2140, #4a2c4f 55%, #a33d5f);
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--accent-strong), var(--accent) 65%, var(--bg-deep));
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
.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; }
|
||||
@@ -680,8 +704,9 @@ h1, h2, h3, p { margin: 0; }
|
||||
max-width: min(88vw, 420px);
|
||||
padding: 9px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(20, 24, 32, 0.92);
|
||||
color: #fff;
|
||||
background: var(--bg-deep);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12.5px;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 10px);
|
||||
@@ -689,7 +714,7 @@ h1, h2, h3, p { margin: 0; }
|
||||
transition: opacity 0.18s, transform 0.18s;
|
||||
}
|
||||
.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) {
|
||||
.live-strip { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
|
||||
@@ -49,10 +49,10 @@ export async function renderCandidates(root) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="notice-row">
|
||||
<strong>Web 看板只读</strong>
|
||||
<span>${data.telegram_enabled ? "请在 Telegram 消息中处理候选。" : "Telegram 未启用,请使用 CLI 或数据库处理候选。"}</span>
|
||||
</div>
|
||||
${data.items.length ? `<div class="notice-row">
|
||||
<strong>候选处理</strong>
|
||||
<span>${data.telegram_enabled ? "请在 Telegram 消息中收藏、忽略或拉黑。" : "Telegram 未启用,请使用 CLI 或数据库处理候选。"}</span>
|
||||
</div>` : ""}
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("待确认")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { api } from "../api.js";
|
||||
import { centerActiveChips, emptyState, sectionHeader } from "../components.js";
|
||||
import { lineChart } from "../charts.js";
|
||||
import { lineChart, themeColor } from "../charts.js";
|
||||
import {
|
||||
escapeAttr, escapeHtml, formatCount, formatDate, formatDelta,
|
||||
} from "../format.js";
|
||||
@@ -82,7 +82,7 @@ export async function renderFans(root, { query }) {
|
||||
dates: fans.dates || [],
|
||||
values: fans.series || [],
|
||||
name: "粉丝",
|
||||
color: "#9b7bff",
|
||||
color: themeColor("accent"),
|
||||
scale: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { api, apiUrl } from "../api.js";
|
||||
import { api } from "../api.js";
|
||||
import {
|
||||
centerActiveChips, chip, emptyState, pager, sectionHeader, upCard, videoCard,
|
||||
} from "../components.js";
|
||||
@@ -8,7 +8,7 @@ import { replaceQuery } from "../router.js";
|
||||
const VIDEO_SORTS = [
|
||||
["heat", "综合热度"], ["view", "播放"], ["like", "点赞"], ["coin", "投币"],
|
||||
["favorite", "收藏"], ["share", "转发"], ["reply", "评论"], ["danmaku", "弹幕"],
|
||||
["interaction", "互动率"], ["growth", "增长"], ["growth_rate", "增速"],
|
||||
["interaction", "互动率"], ["growth", "播放增量"], ["growth_rate", "播放增速"],
|
||||
];
|
||||
const UP_SORTS = [
|
||||
["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 }) {
|
||||
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 class="rank-toolbar">
|
||||
<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>
|
||||
<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 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 天"]]
|
||||
@@ -106,12 +108,6 @@ export async function renderRankings(root, { query }) {
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -119,9 +115,6 @@ export async function renderRankings(root, { query }) {
|
||||
<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">
|
||||
|
||||
@@ -104,7 +104,7 @@ export async function renderSystem(root) {
|
||||
|
||||
<section class="panel">
|
||||
${sectionHeader("UP 黑名单")}
|
||||
<p class="section-note">只影响未来搜索发现;已入库视频继续追踪。Web 只读,移除请使用 CLI 或数据库。</p>
|
||||
<p class="section-note">只影响未来搜索发现;已入库视频继续追踪。移除请使用 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>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { api } from "../api.js";
|
||||
import {
|
||||
badge, emptyState, sectionHeader, videoCard,
|
||||
} from "../components.js";
|
||||
import { barChart, lineChart } from "../charts.js";
|
||||
import { barChart, lineChart, themeColor } from "../charts.js";
|
||||
import {
|
||||
escapeAttr, escapeHtml, formatCount, formatDate, formatPercent,
|
||||
} from "../format.js";
|
||||
@@ -89,7 +89,7 @@ export async function renderUp(root, { params, query }) {
|
||||
dates: up.series.dates,
|
||||
values: up.series.views,
|
||||
name: "总播放",
|
||||
color: "#fb7299",
|
||||
color: themeColor("accent"),
|
||||
scale: true,
|
||||
});
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export async function renderUp(root, { params, query }) {
|
||||
labels: topForBar.map((item) => (item.title || `#${item.aid}`).slice(0, 12)),
|
||||
values: topForBar.map((item) => item.metrics?.view || 0),
|
||||
name: "播放",
|
||||
color: "#00aeec",
|
||||
color: themeColor("blue"),
|
||||
});
|
||||
}
|
||||
root.querySelector("#up-sort")?.addEventListener("change", (event) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
badge, centerActiveChips, cover, emptyState, metricChips, metricGrid,
|
||||
sectionHeader, videoCard,
|
||||
} from "../components.js";
|
||||
import { lineChart, emptyChart } from "../charts.js";
|
||||
import { lineChart, themeColor } from "../charts.js";
|
||||
import {
|
||||
escapeAttr, escapeHtml, formatCount, formatDate, formatDelta,
|
||||
formatDuration, formatPercent, formatScore, metricLabel, statusLabel,
|
||||
@@ -119,7 +119,7 @@ export async function renderVideo(root, { params, query }) {
|
||||
dates: trend.dates || [],
|
||||
values: trend.series?.[metric] || [],
|
||||
name: metricLabel(metric),
|
||||
color: metric === "view" ? "#fb7299" : "#00aeec",
|
||||
color: metric === "view" ? themeColor("accent") : themeColor("blue"),
|
||||
scale: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -239,10 +239,6 @@ def test_fans_candidates_system_and_export():
|
||||
assert system["next_runs"]["snapshot"] > 0
|
||||
assert system["next_runs"]["folder_sync"] > 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:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -119,9 +119,7 @@ def test_readonly_routes_and_static():
|
||||
assert status == 404
|
||||
|
||||
status, _, payload = running.request("/api/export.csv?sort=view")
|
||||
assert status == 200
|
||||
assert payload.startswith(b"\xef\xbb\xbf")
|
||||
assert "咻咻满".encode("utf-8") in payload
|
||||
assert status == 404
|
||||
|
||||
status, headers, _ = running.request("/api/overview")
|
||||
assert headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
Reference in new issue
Block a user