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

-4
View File
@@ -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);
+2
View File
@@ -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");
+21 -20
View File
@@ -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 -1
View File
@@ -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 -2
View File
@@ -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>
+72 -47
View File
@@ -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("待确认")}
+2 -2
View File
@@ -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,
});
}
+12 -19
View File
@@ -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">
+1 -1
View File
@@ -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>
+3 -3
View File
@@ -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) => {
+2 -2
View File
@@ -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,
});
}