mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 08:45:41 +08:00
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:
@@ -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 元素,无占位链接。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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" : "已关闭预览生成",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. 后端集成方案(网盘驱动 + 元数据 + 预览生成)
|
||||
|
||||
本节记录接入真实网盘后端的架构和关键决策。
|
||||
|
||||
Reference in New Issue
Block a user