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,
|
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)
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,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,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>
|
||||||
|
|||||||
@@ -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("待确认")}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user