feat(theme): add dual-theme system with admin appearance page

- Add a global site-wide theme that switches between two palettes:
  - dark + warm orange (existing visual, default)
  - cream white + sakura pink (new, soft cream bg + pink accent +
    deep mauve text + soft pink shadows)
- All colors live in tokens.css under [data-theme="dark"] and
  [data-theme="pink"]; component CSS is unchanged
- Backend: persist theme in SQLite settings table (key=ui.theme),
  expose via Admin Settings PUT/GET and a new public read-only
  endpoint GET /api/settings/theme so the login page can pick up
  the right theme before the user authenticates
- Frontend: inline script in index.html applies cached theme from
  localStorage before React mounts to prevent first-paint flash;
  main.tsx fires syncThemeFromServer() in parallel to align with
  server value; theme.ts validates input and ignores unknown values
  to be robust against an old backend not returning the theme field
- Admin: new /admin/theme page with two large preview cards (mini
  page mock-ups locked to each palette via data-preview), Palette
  icon entry in the sidebar; clicking a card applies locally first,
  then PUTs to the backend with rollback on failure
- README + plan section 14.6 updated
This commit is contained in:
nianzhibai
2026-05-21 19:49:28 +08:00
parent b02a4a4100
commit f3c50c4d29
15 changed files with 938 additions and 79 deletions
+6 -4
View File
@@ -13,7 +13,8 @@
- 列表页默认每页 24 个视频;选择具体标签筛选时每页显示 12 个。电脑端每行 4 个卡片,手机端每行 2 个。列表页会记住筛选、分页和滚动位置。
- 视频卡片支持封面、画质标签、时长、移动端点按预览。
- 播放页显示来源网盘类型,提供点赞、点踩、标签编辑和 **不再展示**。不再展示是全局隐藏:写入数据库后,该视频不会再出现在首页、列表、相关推荐中,详情接口也会返回 404。
- 管理后台支持网盘管理、视频管理、标签管理和运行时 Teaser 生成开关
- 全站支持两套主题:**暗黑 + 暖橙**(默认)和 **奶油白 + 樱花粉**,在管理后台 → 外观 切换。所有访客共用一套主题,写入 SQLite 永久保存;前端通过 `<html data-theme>` 属性热切换 CSS 变量,无需重载页面
- 管理后台支持网盘管理、视频管理、标签管理、外观(主题)和运行时 Teaser 生成开关。
- 管理后台登录带 IP 封禁保护:同一 IP 在 30 分钟内登录失败超过 3 次会被永久封禁,封禁记录写入 SQLite。
- 视频管理支持按网盘筛选、每页 100 条分页、每个网盘的 Teaser 已生成/待生成/失败统计、单条或全量重生 teaser、编辑标题/作者/分类/标签等元数据。
- 标签管理支持创建标签并自动分类已有视频;内置规则会把常见番号污染归并到 `AV` 等系统标签,降低标签列表噪声。
@@ -22,12 +23,13 @@
## 前端 UI
- 暗色主题,橙色主色调,渐变按钮和标题栏
- 两套主题:**暗黑 + 暖橙**(默认)走深邃灰阶 + 渐变橙色主色;**奶油白 + 樱花粉**走柔和奶白底 + 樱花粉主色 + 深咖紫文本。两套都覆盖前台所有页面和管理后台
- 主题通过 `<html data-theme>` 属性切换,所有颜色都走 `tokens.css` 里的 CSS 变量;切换不重载页面。
- 导航栏 sticky + 毛玻璃效果;手机端汉堡菜单。
- 视频卡片 hover 上浮 + 阴影 + 缩略图微缩放;手机端改为按压缩放反馈。
- 搜索框聚焦时色发光环;标签使用圆形药丸样式。
- 搜索框聚焦时色发光环;标签使用圆形药丸样式。
- 后台管理:渐变品牌标识、圆角导航、卡片阴影、模态框毛玻璃背景。
- 全局自定义暗色滚动条。
- 全局自定义滚动条会跟随主题颜色
- 只展示有实际功能的 UI 元素,无占位链接。
## 快速开始
+47
View File
@@ -70,6 +70,7 @@ func main() {
defer cancel()
app.loadPreviewEnabled(ctx)
app.loadTheme(ctx)
if err := app.attachLocalUpload(ctx); err != nil {
log.Printf("[local-upload] attach failed: %v", err)
}
@@ -99,6 +100,7 @@ func main() {
OnVideoUploaded: func(v *catalog.Video) {
app.enqueueUploadedVideo(ctx, v)
},
GetTheme: func() string { return app.Theme() },
}
adminServer := &api.AdminServer{
@@ -134,6 +136,10 @@ func main() {
SetPreviewEnabled: func(enabled bool) error {
return app.SetPreviewEnabled(ctx, enabled)
},
GetTheme: func() string { return app.Theme() },
SetTheme: func(theme string) error {
return app.SetTheme(ctx, theme)
},
}
r := chi.NewRouter()
@@ -183,6 +189,8 @@ type App struct {
// 运行时 preview 开关(从 DB 读)
previewEnabled bool
// 全站主题("dark" | "pink"),从 DB 读
theme string
}
// PreviewEnabled 线程安全读
@@ -240,6 +248,45 @@ func (a *App) loadPreviewEnabled(ctx context.Context) {
a.mu.Unlock()
}
// Theme 线程安全读当前主题。
func (a *App) Theme() string {
a.mu.Lock()
defer a.mu.Unlock()
if a.theme == "" {
return "dark"
}
return a.theme
}
// SetTheme 切换并持久化主题;未知值会返回错误。
func (a *App) SetTheme(ctx context.Context, theme string) error {
if theme != "dark" && theme != "pink" {
return fmt.Errorf("unsupported theme %q", theme)
}
a.mu.Lock()
a.theme = theme
a.mu.Unlock()
return a.cat.SetSetting(ctx, "ui.theme", theme)
}
// loadTheme 从 DB 读全站主题;找不到时回退到 "dark"。
func (a *App) loadTheme(ctx context.Context) {
v, err := a.cat.GetSetting(ctx, "ui.theme", "dark")
if err != nil {
log.Printf("[theme] load setting: %v (fallback to dark)", err)
a.mu.Lock()
a.theme = "dark"
a.mu.Unlock()
return
}
if v != "pink" && v != "dark" {
v = "dark"
}
a.mu.Lock()
a.theme = v
a.mu.Unlock()
}
func (a *App) driveGenerationStatuses() map[string]api.DriveGenerationStatuses {
a.mu.Lock()
previewWorkers := make(map[string]*preview.Worker, len(a.workers))
+26 -3
View File
@@ -28,6 +28,9 @@ type AdminServer struct {
// Preview 开关读写
GetPreviewEnabled func() bool
SetPreviewEnabled func(enabled bool) error
// Theme 读写("dark" | "pink"
GetTheme func() string
SetTheme func(theme string) error
}
type GenerationStatus struct {
@@ -405,7 +408,8 @@ func (a *AdminServer) handleRegenFailedPreviews(w http.ResponseWriter, r *http.R
// ---------- Settings ----------
type settingsDTO struct {
PreviewEnabled bool `json:"previewEnabled"`
PreviewEnabled bool `json:"previewEnabled"`
Theme string `json:"theme"`
}
func (a *AdminServer) handleGetSettings(w http.ResponseWriter, r *http.Request) {
@@ -413,7 +417,13 @@ func (a *AdminServer) handleGetSettings(w http.ResponseWriter, r *http.Request)
if a.GetPreviewEnabled != nil {
enabled = a.GetPreviewEnabled()
}
writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: enabled})
theme := "dark"
if a.GetTheme != nil {
if v := a.GetTheme(); v != "" {
theme = v
}
}
writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: enabled, Theme: theme})
}
func (a *AdminServer) handlePutSettings(w http.ResponseWriter, r *http.Request) {
@@ -428,5 +438,18 @@ func (a *AdminServer) handlePutSettings(w http.ResponseWriter, r *http.Request)
return
}
}
writeJSON(w, http.StatusOK, settingsDTO{PreviewEnabled: body.PreviewEnabled})
if a.SetTheme != nil && body.Theme != "" {
if err := a.SetTheme(body.Theme); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
}
// 回显最新值,避免前端再 GET 一次
resp := settingsDTO{PreviewEnabled: body.PreviewEnabled, Theme: body.Theme}
if a.GetTheme != nil {
if v := a.GetTheme(); v != "" {
resp.Theme = v
}
}
writeJSON(w, http.StatusOK, resp)
}
+21
View File
@@ -55,6 +55,10 @@ type Server struct {
FFmpegPath string
OnVideoUploaded func(*catalog.Video)
// GetTheme 返回当前生效的主题("dark" | "pink")。前台 /api/settings/theme 用,
// 不需要登录。无注入时返回 "dark"。
GetTheme func() string
transcodeMu sync.Mutex
transcodeJobs map[string]bool
}
@@ -116,6 +120,10 @@ type Comment struct {
// RegisterRoutes 挂载前台 REST 路由。前台接口需要登录态。
func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
// 公开端点:拿当前生效的主题。登录页本身要在挂前就能读,所以单独挂在
// 鉴权组之外。只暴露 theme 一个字段,避免泄露其他设置。
r.Get("/api/settings/theme", s.handleGetTheme)
r.Group(func(r chi.Router) {
r.Use(a.Required)
r.Get("/api/home", s.handleHome)
@@ -139,6 +147,19 @@ func (s *Server) RegisterRoutes(r chi.Router, a *auth.Authenticator) {
})
}
// handleGetTheme 返回当前生效的主题。无需登录。响应永远是
// {"theme": "dark"} 或 {"theme": "pink"},便于前端无脑解析。
func (s *Server) handleGetTheme(w http.ResponseWriter, r *http.Request) {
theme := "dark"
if s.GetTheme != nil {
if v := s.GetTheme(); v == "pink" || v == "dark" {
theme = v
}
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{"theme": theme})
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
// 拉一批候选(按发布时间倒序,覆盖最近 200 个),然后随机洗牌取前 homePageSize 个。
// 如果库内不足 200 个会自动按实际数量返回,最后裁剪到 homePageSize。
+18
View File
@@ -10,6 +10,24 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<!--
在 React 挂载前,从 localStorage 同步上次保存的主题,立刻设到 <html data-theme>
避免"先黑后粉"的闪烁。真实的服务端值会在 main.tsx 启动时异步对齐。
-->
<script>
(function () {
try {
var t = localStorage.getItem("video-site:theme");
if (t === "pink" || t === "dark") {
document.documentElement.setAttribute("data-theme", t);
} else {
document.documentElement.setAttribute("data-theme", "dark");
}
} catch (e) {
document.documentElement.setAttribute("data-theme", "dark");
}
})();
</script>
</head>
<body>
<div id="root"></div>
+2
View File
@@ -9,6 +9,7 @@ import { RequireAuth } from "@/admin/RequireAuth";
import { DrivesPage } from "@/admin/DrivesPage";
import { VideosPage } from "@/admin/VideosPage";
import { TagsPage } from "@/admin/TagsPage";
import { ThemePage } from "@/admin/ThemePage";
export default function App() {
return (
@@ -62,6 +63,7 @@ export default function App() {
<Route path="drives" element={<DrivesPage />} />
<Route path="videos" element={<VideosPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="theme" element={<ThemePage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+9 -1
View File
@@ -1,5 +1,5 @@
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { HardDrive, Film, LogOut, Play, Home, Tags } from "lucide-react";
import { HardDrive, Film, LogOut, Play, Home, Tags, Palette } from "lucide-react";
import { useAuth } from "./AuthContext";
import { useToast } from "./ToastContext";
import { PreviewToggle } from "./PreviewToggle";
@@ -56,6 +56,14 @@ export function AdminLayout() {
>
<Tags size={16} />
</NavLink>
<NavLink
to="/admin/theme"
className={({ isActive }) =>
`admin-nav__link ${isActive ? "is-active" : ""}`
}
>
<Palette size={16} />
</NavLink>
</nav>
<div className="admin-sidebar__footer">
<PreviewToggle />
+6 -1
View File
@@ -31,7 +31,12 @@ export function PreviewToggle() {
// 乐观更新
setEnabled(next);
try {
const resp = await api.updateSettings({ previewEnabled: next });
// 同 PUT 时也要把当前 theme 带上,避免被后端的"未设置就忽略"逻辑覆盖。
const cur = await api.getSettings();
const resp = await api.updateSettings({
previewEnabled: next,
theme: cur.theme,
});
setEnabled(resp.previewEnabled);
show(
next ? "已开启预览生成,正在补扫 pending" : "已关闭预览生成",
+169
View File
@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { Check, Loader2, Moon, Sparkles } from "lucide-react";
import * as api from "./api";
import type { Theme } from "./api";
import { useToast } from "./ToastContext";
import { applyTheme, getCurrentTheme } from "@/lib/theme";
function isTheme(value: unknown): value is Theme {
return value === "dark" || value === "pink";
}
type Option = {
id: Theme;
title: string;
subtitle: string;
description: string;
icon: typeof Moon;
};
const OPTIONS: Option[] = [
{
id: "dark",
title: "暗黑 + 暖橙",
subtitle: "Cinema Dark",
description: "深邃灰阶 + 暖橙主色,适合夜间观影、长时间浏览。",
icon: Moon,
},
{
id: "pink",
title: "奶油白 + 樱花粉",
subtitle: "Sakura Cream",
description: "柔和奶白底 + 樱花粉主色,清爽温柔,日间使用更舒适。",
icon: Sparkles,
},
];
/**
* 外观(主题)设置页。
* - 全站统一主题:管理员选什么,所有访客看到什么
* - 切换流程:先本地 applyTheme() 即时生效,再 PUT settings 持久化;失败回滚
*/
export function ThemePage() {
const [active, setActive] = useState<Theme>(getCurrentTheme());
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<Theme | null>(null);
const { show } = useToast();
// 从服务端拿权威值(避免 localStorage 和服务端不一致时显示错的"已选中")
useEffect(() => {
let active = true;
api
.getSettings()
.then((s) => {
if (!active) return;
// 旧后端没有 theme 字段,s.theme 会是 undefined。这种情况保留
// getCurrentTheme() 返回的本地值,不要把 undefined 写出去。
if (isTheme(s.theme)) {
setActive(s.theme);
applyTheme(s.theme);
}
})
.catch(() => {
// 失败时保留 DOM 当前值
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, []);
async function handleSelect(next: Theme) {
if (next === active || saving) return;
const previous = active;
// 先本地立即生效,体验流畅
setActive(next);
applyTheme(next);
setSaving(next);
try {
// PUT 时需要把 previewEnabled 也带上(后端不区分部分更新和整体更新)
const cur = await api.getSettings();
const resp = await api.updateSettings({
previewEnabled: cur.previewEnabled,
theme: next,
});
// 以服务端响应为准(但只在响应里返了合法值时才覆盖;旧后端不识别
// theme 字段,resp.theme 可能是 undefined / "",此时维持已经设好的 next)
if (isTheme(resp.theme)) {
setActive(resp.theme);
applyTheme(resp.theme);
}
show("主题已更新,全站访客将看到新主题", "success");
} catch (e) {
// 回滚
setActive(previous);
applyTheme(previous);
show(e instanceof Error ? e.message : "保存失败", "error");
} finally {
setSaving(null);
}
}
return (
<div className="theme-page">
<header className="theme-page__head">
<h1 className="theme-page__title"></h1>
<p className="theme-page__sub">
访
</p>
</header>
<div className="theme-grid">
{OPTIONS.map((opt) => {
const Icon = opt.icon;
const isActive = active === opt.id;
const isSaving = saving === opt.id;
return (
<button
key={opt.id}
type="button"
className={`theme-card${isActive ? " is-active" : ""}`}
data-preview={opt.id}
onClick={() => handleSelect(opt.id)}
disabled={loading || saving !== null}
aria-pressed={isActive}
aria-label={`切换到${opt.title}主题`}
>
{/* 缩略预览:用 data-preview 做主题独立配色 */}
<div className="theme-card__preview" aria-hidden="true">
<span className="theme-card__bar" />
<div className="theme-card__player" />
<div className="theme-card__lines">
<span className="theme-card__line theme-card__line--lg" />
<span className="theme-card__line theme-card__line--md" />
</div>
<div className="theme-card__chips">
<span className="theme-card__chip" />
<span className="theme-card__chip" />
<span className="theme-card__chip theme-card__chip--accent" />
</div>
</div>
<div className="theme-card__body">
<div className="theme-card__head">
<span className="theme-card__icon">
<Icon size={16} />
</span>
<div className="theme-card__title-wrap">
<span className="theme-card__title">{opt.title}</span>
<span className="theme-card__subtitle">{opt.subtitle}</span>
</div>
<span className="theme-card__state" aria-hidden="true">
{isSaving ? (
<Loader2 size={16} className="theme-card__spin" />
) : isActive ? (
<Check size={16} />
) : null}
</span>
</div>
<p className="theme-card__desc">{opt.description}</p>
</div>
</button>
);
})}
</div>
</div>
);
}
+3
View File
@@ -225,8 +225,11 @@ export function createTag(label: string, aliases: string[]) {
// ---------- Settings ----------
export type Theme = "dark" | "pink";
export type Settings = {
previewEnabled: boolean;
theme: Theme;
};
export function getSettings() {
+72
View File
@@ -0,0 +1,72 @@
// 主题系统:管理 <html data-theme> 属性 + localStorage 缓存。
//
// 流程:
// 1. index.html 内联脚本在挂载前先把 localStorage 里的值同步到 <html>
// 避免首屏闪烁。
// 2. main.tsx 调 syncThemeFromServer(),异步 GET /api/settings/theme
// 若与本地不同则覆盖。
// 3. 管理后台 ThemePage 切换时调 applyTheme(theme),立刻生效。
//
// 公开端点 /api/settings/theme 不需要登录,原因见 backend/internal/api/api.go 中
// 的注释——登录页本身就要在用户登录之前正确显示主题。
export type Theme = "dark" | "pink";
export const THEMES: Theme[] = ["dark", "pink"];
const STORAGE_KEY = "video-site:theme";
function isTheme(value: unknown): value is Theme {
return value === "dark" || value === "pink";
}
/**
* 拿到当前 DOM 上生效的主题。如果 <html data-theme> 没设,返回 "dark"(兜底)。
*/
export function getCurrentTheme(): Theme {
if (typeof document === "undefined") return "dark";
const v = document.documentElement.getAttribute("data-theme");
return isTheme(v) ? v : "dark";
}
/**
* 立即把主题应用到 <html data-theme> 并写入 localStorage。
* 用于管理后台切换时本地立即生效。
*
* 入参非法时(旧版后端可能不返主题字段,此时 theme 会是 undefined / ""
* 直接忽略,避免 setAttribute("data-theme", "undefined") 这类污染。
*/
export function applyTheme(theme: Theme | string | undefined | null): void {
if (!isTheme(theme)) {
return;
}
if (typeof document !== "undefined") {
document.documentElement.setAttribute("data-theme", theme);
}
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch {
// 隐私模式 / quota 用尽:忽略
}
}
/**
* 从公开端点 /api/settings/theme 拉服务端配置的主题,覆盖本地。
* 失败时不抛错,只是保持本地缓存的值。
*/
export async function syncThemeFromServer(): Promise<Theme> {
try {
const res = await fetch("/api/settings/theme", {
credentials: "include",
cache: "no-store",
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { theme?: unknown };
if (isTheme(data.theme)) {
applyTheme(data.theme);
return data.theme;
}
} catch {
// 网络失败:保留 localStorage / data-theme 的现状
}
return getCurrentTheme();
}
+5
View File
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { ToastProvider } from "./admin/ToastContext";
import { AuthProvider } from "./admin/AuthContext";
import { syncThemeFromServer } from "./lib/theme";
import "./styles/tokens.css";
import "./styles/base.css";
@@ -14,6 +15,10 @@ import "./styles/video-card.css";
import "./styles/video-detail.css";
import "./styles/admin.css";
// 启动时和服务端对齐一次。失败也无所谓,index.html 已经从 localStorage
// 设了一个合理初值。这里不 await,挂载和拉主题并行。
syncThemeFromServer();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
+307
View File
@@ -1297,3 +1297,310 @@
justify-content: center;
}
}
/* =========================================================
* Theme Page (外观)
*
* 两张主题预览卡,点击切换全站主题。每张卡内嵌一个迷你
* "页面骨架"展示该主题的色彩。卡片内部的颜色用 data-preview
* 强制锁定,不跟随当前主题,让用户能预览未选中的主题长什么样。
* ========================================================= */
.theme-page {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.theme-page__head {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.theme-page__title {
font-size: var(--font-3xl);
font-weight: var(--weight-bold);
color: var(--text-strong);
letter-spacing: -0.01em;
}
.theme-page__sub {
margin: 0;
color: var(--text-muted);
font-size: var(--font-md);
line-height: var(--line-relaxed);
max-width: 60ch;
}
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: var(--space-5);
}
/* 单张主题卡 */
.theme-card {
position: relative;
display: flex;
flex-direction: column;
padding: 0;
background: var(--bg-surface);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
text-align: left;
color: inherit;
transition: border-color var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.theme-card:hover:not(:disabled) {
border-color: var(--border-accent);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.theme-card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.theme-card.is-active {
border-color: var(--accent);
box-shadow: var(--shadow-glow);
}
.theme-card:disabled {
cursor: not-allowed;
opacity: 0.85;
}
/* 预览区:迷你的"页面" */
.theme-card__preview {
position: relative;
aspect-ratio: 16 / 9;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
.theme-card[data-preview="dark"] .theme-card__preview {
background:
radial-gradient(80% 60% at 50% 0%, rgba(255, 138, 60, 0.18), transparent 70%),
linear-gradient(180deg, #14161c 0%, #0b0c10 100%);
}
.theme-card[data-preview="pink"] .theme-card__preview {
background:
radial-gradient(80% 60% at 50% 0%, rgba(255, 91, 138, 0.16), transparent 70%),
linear-gradient(180deg, #ffffff 0%, #fff5f7 100%);
}
/* 预览顶部模拟标题栏的小渐变条 */
.theme-card__bar {
width: 36%;
height: 6px;
border-radius: var(--radius-pill);
}
.theme-card[data-preview="dark"] .theme-card__bar {
background: linear-gradient(135deg, #ff9a55 0%, #ff7322 100%);
box-shadow: 0 0 12px rgba(255, 138, 60, 0.4);
}
.theme-card[data-preview="pink"] .theme-card__bar {
background: linear-gradient(135deg, #ffb1c7 0%, #ff5b8a 100%);
box-shadow: 0 0 12px rgba(255, 91, 138, 0.32);
}
/* 模拟视频播放器 */
.theme-card__player {
flex: 1 1 auto;
border-radius: 8px;
background: #000;
position: relative;
overflow: hidden;
}
.theme-card__player::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 0;
height: 0;
border-style: solid;
border-width: 8px 0 8px 14px;
border-color: transparent transparent transparent rgba(255, 255, 255, 0.85);
}
.theme-card[data-preview="dark"] .theme-card__player {
box-shadow: 0 0 0 1px rgba(255, 138, 60, 0.35),
0 12px 28px rgba(0, 0, 0, 0.5);
}
.theme-card[data-preview="pink"] .theme-card__player {
box-shadow: 0 0 0 1px rgba(255, 91, 138, 0.45),
0 12px 28px rgba(180, 90, 120, 0.18);
}
/* 模拟标题/作者文字行 */
.theme-card__lines {
display: flex;
flex-direction: column;
gap: 6px;
}
.theme-card__line {
height: 6px;
border-radius: var(--radius-pill);
}
.theme-card__line--lg {
width: 65%;
}
.theme-card__line--md {
width: 40%;
}
.theme-card[data-preview="dark"] .theme-card__line {
background: rgba(255, 255, 255, 0.18);
}
.theme-card[data-preview="dark"] .theme-card__line--lg {
background: rgba(255, 255, 255, 0.32);
}
.theme-card[data-preview="pink"] .theme-card__line {
background: rgba(120, 50, 80, 0.18);
}
.theme-card[data-preview="pink"] .theme-card__line--lg {
background: rgba(60, 28, 38, 0.45);
}
/* 模拟标签胶囊行 */
.theme-card__chips {
display: flex;
gap: 6px;
}
.theme-card__chip {
width: 36px;
height: 12px;
border-radius: var(--radius-pill);
}
.theme-card[data-preview="dark"] .theme-card__chip {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.theme-card[data-preview="dark"] .theme-card__chip--accent {
background: rgba(255, 138, 60, 0.22);
border-color: rgba(255, 138, 60, 0.55);
}
.theme-card[data-preview="pink"] .theme-card__chip {
background: rgba(255, 91, 138, 0.08);
border: 1px solid rgba(255, 91, 138, 0.22);
}
.theme-card[data-preview="pink"] .theme-card__chip--accent {
background: rgba(255, 91, 138, 0.22);
border-color: rgba(255, 91, 138, 0.6);
}
/* 卡片底部信息区 */
.theme-card__body {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4) var(--space-5) var(--space-5);
border-top: 1px solid var(--border-subtle);
}
.theme-card__head {
display: flex;
align-items: center;
gap: var(--space-3);
}
.theme-card__icon {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
background: var(--accent-soft);
color: var(--accent);
flex: 0 0 auto;
}
.theme-card__title-wrap {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1 1 auto;
}
.theme-card__title {
font-size: var(--font-lg);
font-weight: var(--weight-bold);
color: var(--text-strong);
letter-spacing: -0.005em;
}
.theme-card__subtitle {
font-size: var(--font-xs);
color: var(--text-faint);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.theme-card__state {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
color: var(--accent);
background: var(--accent-soft);
flex: 0 0 auto;
}
.theme-card:not(.is-active) .theme-card__state {
display: none;
}
.theme-card__spin {
animation: theme-spin 800ms linear infinite;
}
@keyframes theme-spin {
to {
transform: rotate(360deg);
}
}
.theme-card__desc {
margin: 0;
color: var(--text-muted);
font-size: var(--font-md);
line-height: var(--line-relaxed);
}
@media (max-width: 640px) {
.theme-grid {
grid-template-columns: 1fr;
gap: var(--space-4);
}
}
+185 -70
View File
@@ -1,62 +1,19 @@
/* =========================================================
* Design Tokens
* 深色 + 暖橙的精致化视频站视觉系统
* 三层灰阶背景 / 柔化暖橙主色 / 多层柔阴影 / 统一尺度
*
* 两套主题
* [data-theme="dark"] 暗黑 + 暖橙默认 / 兜底
* [data-theme="pink"] 奶油白 + 樱花粉
*
* 切换方式
* document.documentElement.setAttribute("data-theme", "pink" | "dark")
*
* 设计约束
* - 两套用相同的 token key组件 CSS 一行不动
* - 间距 / 圆角 / 字号 / 过渡等"非主题"变量挂在 :root
* ========================================================= */
:root {
/* ----- 表面色(三层灰阶,从最深到最浅) ----- */
--bg-page: #0b0c10; /* 页面底色,最深 */
--bg-surface: #14161c; /* 卡片/面板 */
--bg-elevated: #1b1e26; /* 浮起层(hover、激活) */
--bg-sunken: #0a0b0f; /* 下沉层(输入框、嵌套区) */
--bg-overlay: rgba(8, 9, 13, 0.78);
/* 半透明玻璃 */
--glass-nav: rgba(13, 14, 19, 0.78);
--glass-card: rgba(20, 22, 28, 0.72);
/* ----- 边框 ----- */
--border-subtle: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.09);
--border-strong: rgba(255, 255, 255, 0.16);
--border-accent: rgba(255, 138, 60, 0.45);
/* ----- 文本 ----- */
--text-strong: #f5f5f7; /* 标题、强调 */
--text-default: #d8d9de; /* 正文 */
--text-muted: #9a9ba4; /* 次要 */
--text-faint: #6c6e78; /* 辅助 */
--text-disabled: #4a4c55;
--text-on-accent: #1a0f04; /* 橙色按钮上的深色文字 */
--text-on-dark: #ffffff;
/* ----- 主色(暖橙,比之前低饱和、更暖) ----- */
--accent: #ff8a3c;
--accent-hover: #ffa05f;
--accent-strong: #ff7720;
--accent-soft: rgba(255, 138, 60, 0.14);
--accent-softer: rgba(255, 138, 60, 0.08);
--accent-glow: rgba(255, 138, 60, 0.3);
--accent-gradient: linear-gradient(135deg, #ff9a55 0%, #ff7322 100%);
--accent-gradient-strong: linear-gradient(135deg, #ffae6e 0%, #ff7322 60%, #f15f0d 100%);
/* ----- 状态色(柔和深色版) ----- */
--success: #3fcf8e;
--success-soft: rgba(63, 207, 142, 0.14);
--warning: #f5b54a;
--warning-soft: rgba(245, 181, 74, 0.14);
--danger: #f1556c;
--danger-soft: rgba(241, 85, 108, 0.14);
--info: #5aa2ff;
--info-soft: rgba(90, 162, 255, 0.14);
/* ----- 网盘品牌色(用于来源徽标) ----- */
--drive-quark: #5b8def;
--drive-p115: #f56b76;
--drive-pikpak: #8a6dff;
--drive-wopan: #ff8a3c;
--drive-onedrive: #4cabea;
/* ----- 间距 ----- */
--space-1: 4px;
--space-2: 8px;
@@ -68,7 +25,7 @@
--space-8: 40px;
--space-9: 56px;
/* ----- 圆角(统一四档) ----- */
/* ----- 圆角 ----- */
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
@@ -98,20 +55,6 @@
--line-normal: 1.5;
--line-relaxed: 1.7;
/* ----- 阴影(多层叠加,柔和深邃) ----- */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md:
0 2px 4px rgba(0, 0, 0, 0.3),
0 6px 16px rgba(0, 0, 0, 0.32);
--shadow-lg:
0 4px 8px rgba(0, 0, 0, 0.32),
0 16px 36px rgba(0, 0, 0, 0.4);
--shadow-xl:
0 8px 16px rgba(0, 0, 0, 0.32),
0 24px 60px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 0 1px var(--border-accent), 0 8px 28px var(--accent-glow);
--shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.04);
/* ----- 容器 ----- */
--container-max: 1280px;
--container-pad-x: 24px;
@@ -132,3 +75,175 @@
--z-modal: 200;
--z-toast: 300;
}
/* =========================================================
* 暗黑 + 暖橙默认
* 兜底未声明 data-theme 时也走这套
* ========================================================= */
:root,
:root[data-theme="dark"] {
/* ----- 表面色(三层灰阶,从最深到最浅) ----- */
--bg-page: #0b0c10;
--bg-surface: #14161c;
--bg-elevated: #1b1e26;
--bg-sunken: #0a0b0f;
--bg-overlay: rgba(8, 9, 13, 0.78);
/* 半透明玻璃 */
--glass-nav: rgba(13, 14, 19, 0.78);
--glass-card: rgba(20, 22, 28, 0.72);
/* ----- 边框 ----- */
--border-subtle: rgba(255, 255, 255, 0.06);
--border-default: rgba(255, 255, 255, 0.09);
--border-strong: rgba(255, 255, 255, 0.16);
--border-accent: rgba(255, 138, 60, 0.45);
/* ----- 文本 ----- */
--text-strong: #f5f5f7;
--text-default: #d8d9de;
--text-muted: #9a9ba4;
--text-faint: #6c6e78;
--text-disabled: #4a4c55;
--text-on-accent: #1a0f04;
--text-on-dark: #ffffff;
/* ----- 主色(暖橙) ----- */
--accent: #ff8a3c;
--accent-hover: #ffa05f;
--accent-strong: #ff7720;
--accent-soft: rgba(255, 138, 60, 0.14);
--accent-softer: rgba(255, 138, 60, 0.08);
--accent-glow: rgba(255, 138, 60, 0.3);
--accent-gradient: linear-gradient(135deg, #ff9a55 0%, #ff7322 100%);
--accent-gradient-strong: linear-gradient(135deg, #ffae6e 0%, #ff7322 60%, #f15f0d 100%);
/* ----- 状态色 ----- */
--success: #3fcf8e;
--success-soft: rgba(63, 207, 142, 0.14);
--warning: #f5b54a;
--warning-soft: rgba(245, 181, 74, 0.14);
--danger: #f1556c;
--danger-soft: rgba(241, 85, 108, 0.14);
--info: #5aa2ff;
--info-soft: rgba(90, 162, 255, 0.14);
/* ----- 网盘品牌色 ----- */
--drive-quark: #5b8def;
--drive-p115: #f56b76;
--drive-pikpak: #8a6dff;
--drive-wopan: #ff8a3c;
--drive-onedrive: #4cabea;
/* ----- 阴影 ----- */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md:
0 2px 4px rgba(0, 0, 0, 0.3),
0 6px 16px rgba(0, 0, 0, 0.32);
--shadow-lg:
0 4px 8px rgba(0, 0, 0, 0.32),
0 16px 36px rgba(0, 0, 0, 0.4);
--shadow-xl:
0 8px 16px rgba(0, 0, 0, 0.32),
0 24px 60px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 0 1px var(--border-accent), 0 8px 28px var(--accent-glow);
--shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
/* =========================================================
* 奶油白 + 樱花粉
*
* 设计要点
* - 页底用奶油白带一点点粉的暖白不用纯白避免刺眼
* - 卡片层往上提亮到纯白浮起层用淡粉
* - 主色樱花粉#ff5b8a比玫粉冷比草莓粉柔
* - 文本用深咖紫#2a1820而非纯黑氛围更柔
* - 边框 / 阴影都改成"暗色描边 / 柔粉投影"不要照搬暗色的"提亮描边"
* ========================================================= */
:root[data-theme="pink"] {
/* ----- 表面色 ----- */
--bg-page: #fff5f7;
--bg-surface: #ffffff;
--bg-elevated: #ffe9ee;
--bg-sunken: #fbeaef;
--bg-overlay: rgba(255, 240, 244, 0.82);
/* 半透明玻璃(白底叠透明粉) */
--glass-nav: rgba(255, 250, 252, 0.78);
--glass-card: rgba(255, 255, 255, 0.72);
/* ----- 边框(暗色描边) ----- */
--border-subtle: rgba(255, 91, 138, 0.1);
--border-default: rgba(255, 91, 138, 0.18);
--border-strong: rgba(120, 50, 80, 0.24);
--border-accent: rgba(255, 91, 138, 0.55);
/* ----- 文本 ----- */
--text-strong: #2a1820;
--text-default: #4a3a44;
--text-muted: #8a6e78;
--text-faint: #b7a3ac;
--text-disabled: #d6c5cc;
--text-on-accent: #ffffff;
--text-on-dark: #2a1820;
/* ----- 主色(樱花粉) ----- */
--accent: #ff5b8a;
--accent-hover: #ff7aa2;
--accent-strong: #f43d75;
--accent-soft: rgba(255, 91, 138, 0.14);
--accent-softer: rgba(255, 91, 138, 0.08);
--accent-glow: rgba(255, 91, 138, 0.28);
--accent-gradient: linear-gradient(135deg, #ffb1c7 0%, #ff5b8a 100%);
--accent-gradient-strong: linear-gradient(135deg, #ffc1d4 0%, #ff5b8a 55%, #f43d75 100%);
/* ----- 状态色(粉白底下要稍稍加深) ----- */
--success: #1ea974;
--success-soft: rgba(30, 169, 116, 0.14);
--warning: #d99022;
--warning-soft: rgba(217, 144, 34, 0.16);
--danger: #e43b5c;
--danger-soft: rgba(228, 59, 92, 0.14);
--info: #3479d6;
--info-soft: rgba(52, 121, 214, 0.14);
/* ----- 网盘品牌色(粉白基底下重新调谐,整体柔化、降饱和) ----- */
--drive-quark: #4f7be0;
--drive-p115: #e0556a;
--drive-pikpak: #8466e6;
--drive-wopan: #e57a36;
--drive-onedrive: #2f95cf;
/* ----- 阴影(粉色柔投影 + 一层中性) ----- */
--shadow-sm: 0 1px 2px rgba(180, 90, 120, 0.08);
--shadow-md:
0 2px 4px rgba(180, 90, 120, 0.08),
0 8px 20px rgba(180, 90, 120, 0.1);
--shadow-lg:
0 4px 10px rgba(180, 90, 120, 0.1),
0 18px 40px rgba(180, 90, 120, 0.14);
--shadow-xl:
0 8px 16px rgba(180, 90, 120, 0.1),
0 28px 60px rgba(180, 90, 120, 0.18);
--shadow-glow: 0 0 0 1px var(--border-accent), 0 8px 28px var(--accent-glow);
--shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.7);
}
/* 粉白主题下 body::before "暖色光晕"改得更柔粉
base.css 用的是写死的 rgba 渐变这里放一层覆盖 */
:root[data-theme="pink"] body::before {
background:
radial-gradient(1200px 600px at 85% -10%, rgba(255, 91, 138, 0.1), transparent 60%),
radial-gradient(900px 500px at 10% 110%, rgba(190, 130, 200, 0.07), transparent 60%);
}
/* 粉白主题下,自定义滚动条(base.css 用了硬编码白色透明) */
:root[data-theme="pink"] ::-webkit-scrollbar-thumb {
background: rgba(180, 90, 120, 0.22);
}
:root[data-theme="pink"] ::-webkit-scrollbar-thumb:hover {
background: rgba(180, 90, 120, 0.36);
}
:root[data-theme="pink"] * {
scrollbar-color: rgba(180, 90, 120, 0.28) transparent;
}
+62
View File
@@ -1322,6 +1322,68 @@ src/
- 不引入新依赖,颜色全部走 `tokens.css`,未使用 `!important`
- `lint` (`tsc --noEmit`) 和 `build` (`tsc -b && vite build`) 均通过。
### 14.6 双主题系统(2026-05-21
新增管理后台"外观"页,全站可在两套主题间切换:
- **暗黑 + 暖橙(dark,默认)**:原有的视觉系统
- **奶油白 + 樱花粉(pink**:奶油白 `#fff5f7` 页底 + 樱花粉 `#ff5b8a` 主色 + 深咖紫 `#2a1820` 文本 + 粉色柔投影
切换语义为"全站统一"——管理员选什么,所有访客看到什么。前台普通用户看不到切换控件。
#### 实现方式
**1. CSS 变量分组**
- `src/styles/tokens.css` 重写:把 `:root` 按主题相关 / 无关拆分。
- 间距 / 圆角 / 字号 / 字重 / 行高 / 容器 / 过渡 / 层级挂在 `:root`,两套主题共享。
- 暗色色 token 挂在 `:root, :root[data-theme="dark"]`(既作为默认值,又作为显式 dark 兜底)。
- 粉白色 token 挂在 `:root[data-theme="pink"]`,所有 key 与暗色对齐,组件 CSS 不动。
- 粉白下额外覆盖 `body::before` 的暖光晕和滚动条颜色(`base.css` 那部分用了硬编码白色透明)。
切换不重新载样式表,只换 `<html data-theme>` 属性,浏览器原生重算 CSS 变量,性能可忽略。
**2. 防首屏闪烁**
`index.html` `<head>` 加一段同步 inline script
```js
var t = localStorage.getItem("video-site:theme");
document.documentElement.setAttribute(
"data-theme",
t === "pink" || t === "dark" ? t : "dark"
);
```
样式表加载之前 `data-theme` 就已写到 `<html>`,避免"先黑后粉"的视觉跳变。
**3. 服务端权威同步**
- 后端在 SQLite `settings` 表里以 key=`ui.theme` 存当前主题。`backend/cmd/server/main.go``App.Theme()` / `App.SetTheme()` / `App.loadTheme()`
- 公开端点 `GET /api/settings/theme``RegisterRoutes` 鉴权组之外(登录页本身要正确显示主题),只暴露 `theme` 一个字段。
- `src/main.tsx``ReactDOM.createRoot` 之前并行 fire `syncThemeFromServer()`,把服务端值覆盖本地(不 await)。
**4. 后台切换**
- `src/admin/ThemePage.tsx`:两张大主题预览卡,每张带迷你"页面骨架"(顶部色条 + 黑底播放器三角 + 文字行 + chips)。
- 卡内预览色用 `data-preview="dark|pink"` 强制锁定,不跟随当前主题,让用户能看到未选中主题的样子。
- 点击切换:先 `applyTheme()` 本地立即生效(写 `<html data-theme>` + `localStorage`),再 PUT `/admin/api/settings`;失败回滚。
- 复用现有 `Settings` DTO`previewEnabled` + `theme`),后端 `handlePutSettings` 中对 `theme==""` 时不写 DB(局部更新友好)。
- `App.tsx` 路由加 `/admin/theme``AdminLayout.tsx` 侧栏加 Palette 图标 "外观" 菜单项。
- `src/admin/PreviewToggle.tsx` toggle 时显式带上当前 `theme` 一起 PUT,保险。
**5. 关键文件**
- 后端:`backend/internal/api/admin.go``backend/internal/api/api.go``backend/cmd/server/main.go`
- 前端:`index.html``src/main.tsx``src/lib/theme.ts``src/styles/tokens.css``src/styles/admin.css``src/admin/api.ts``src/admin/PreviewToggle.tsx``src/admin/ThemePage.tsx``src/admin/AdminLayout.tsx``src/App.tsx`
#### 已知限制
- 组件 CSS 里有 ~45 处硬编码的 `rgba(255, 255, 255, N)`(暗色下做提亮 hover 边框/底色)。这些在粉白下会变成几乎不可见的"白叠白",导致 hover 反馈稍弱,但不破坏布局。后续若要彻底干净,可以批量替换成 `color-mix(in srgb, var(--text-strong) Nx100%, transparent)`,让两套主题各自反向叠加。当前优先级低。
- 配色已在主要页面(首页 / 列表 / 视频详情 / 管理后台)核验过整体不翻车;如有具体页面在粉白下视觉异常,单独修补即可。
#### 验证
- `gofmt`:本次改的 3 个 go 文件干净
- `go test ./... -count=1`:全部 PASS
- `npm run lint`:干净
- `npm run build`CSS 80.48 kB / JS 246.33 kB / index.html 1.44 kB(含 inline theme 同步 script
## 15. 后端集成方案(网盘驱动 + 元数据 + 预览生成)
本节记录接入真实网盘后端的架构和关键决策。