1 Commits
Author SHA1 Message Date
miaomiao 390c4ac53f 统一前后台主题颜色管理 2026-09-05 23:12:17 +08:00
11 changed files with 643 additions and 88 deletions
+1
View File
@@ -43,6 +43,7 @@ node cli.js <cmd> # status | health | db-check | start | stop | restart | por
- 路由保持 v1 的 `.html` 后缀路径(`/blog.html``/forum.html` 等),SPA fallback 已支持,链接不用改。 - 路由保持 v1 的 `.html` 后缀路径(`/blog.html``/forum.html` 等),SPA fallback 已支持,链接不用改。
- `frontend/index.html``${site_name}` / `${site_description}` / `${site_favicon}` 占位符,构建后由 server.js `serveIndex` 按 site_settings **运行时替换**(务必保留这 3 个占位符)。 - `frontend/index.html``${site_name}` / `${site_description}` / `${site_favicon}` 占位符,构建后由 server.js `serveIndex` 按 site_settings **运行时替换**(务必保留这 3 个占位符)。
- 主题:`data-theme="dark"` 属性在 `<html>` 上(`public/css/style.css``[data-theme="dark"]` 选择器),非 `.dark` 类,非 prefers-color-scheme。切换逻辑在 `src/theme.jsx` - 主题:`data-theme="dark"` 属性在 `<html>` 上(`public/css/style.css``[data-theme="dark"]` 选择器),非 `.dark` 类,非 prefers-color-scheme。切换逻辑在 `src/theme.jsx`
- 主题颜色:`frontend/src/themeTokens.js` 是前后台共享的 Token 派生入口;前台通过 `applyThemeTokens` 写入 CSS 变量,后台 `admin/theme.js` 通过 `buildThemeTokens` 生成 MUI palette。主题设置保存后使用 `rainweb:theme-update` 事件和 localStorage storage 事件同步;颜色、玻璃透明度和主题策略由 `routes/settings.js` 校验。
- 构建:`npm run build``public/dist/`gitignored,不入库)。**生产部署必须构建**,否则页面 500/白屏。 - 构建:`npm run build``public/dist/`gitignored,不入库)。**生产部署必须构建**,否则页面 500/白屏。
- 博文/帖子内容中的 `[image:文件名]` / `[file:文件名]` 标签:前台由 `MarkdownRenderer`marked + DOMPurify 净化)、SEO 页由 `ssr.js` 渲染为 `/uploads/` 链接。**所有 markdown 渲染必须过 DOMPurify**。 - 博文/帖子内容中的 `[image:文件名]` / `[file:文件名]` 标签:前台由 `MarkdownRenderer`marked + DOMPurify 净化)、SEO 页由 `ssr.js` 渲染为 `/uploads/` 链接。**所有 markdown 渲染必须过 DOMPurify**。
- 登录态:token 存 localStoragekey `token`),变更通过 `authchange` 事件通知 Layout 刷新(`api/client.js``notifyAuthChange`)。 - 登录态:token 存 localStoragekey `token`),变更通过 `authchange` 事件通知 Layout 刷新(`api/client.js``notifyAuthChange`)。
+23 -9
View File
@@ -1,4 +1,4 @@
import React, { lazy, Suspense, useEffect, useState } from 'react'; import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles'; import { ThemeProvider } from '@mui/material/styles';
@@ -6,6 +6,7 @@ import CssBaseline from '@mui/material/CssBaseline';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { createMD3Theme } from './theme.js'; import { createMD3Theme } from './theme.js';
import { THEME_SETTINGS_EVENT } from '../themeTokens.js';
import './theme.css'; import './theme.css';
import { getToken } from '../api/client.js'; import { getToken } from '../api/client.js';
import { me } from '../api/auth.js'; import { me } from '../api/auth.js';
@@ -38,14 +39,13 @@ const basename = (pathname === '/admin' || pathname.startsWith('/admin/')) ? '/a
function AdminApp() { function AdminApp() {
const [status, setStatus] = useState('checking'); // checking | ok const [status, setStatus] = useState('checking'); // checking | ok
const [primary, setPrimary] = useState('#6750a4'); const [themeSettings, setThemeSettings] = useState({ primary_color: '#6750a4', glass_opacity: '0.6', theme_force_dark: '0' });
// 深色模式跟随前台偏好:整页跳转后 data-theme 属性不保留, const preferredTheme = localStorage.getItem('theme') === 'dark' ? 'dark' : 'light';
// 以 localStorage 的 theme 为兜底(前台 theme.jsx 持久化同一 key)。 const dark = themeSettings.theme_force_dark === '1' || preferredTheme === 'dark';
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
|| localStorage.getItem('theme') === 'dark';
useEffect(() => { useEffect(() => {
if (dark) document.documentElement.setAttribute('data-theme', 'dark'); if (dark) document.documentElement.setAttribute('data-theme', 'dark');
else document.documentElement.removeAttribute('data-theme');
}, [dark]); }, [dark]);
useEffect(() => { useEffect(() => {
@@ -62,10 +62,26 @@ function AdminApp() {
}) })
.catch(() => { window.location.href = '/login.html'; }); .catch(() => { window.location.href = '/login.html'; });
getSettings() getSettings()
.then((s) => { if (s.primary_color) setPrimary(s.primary_color); }) .then((s) => setThemeSettings((current) => ({ ...current, ...s })))
.catch(() => {}); .catch(() => {});
}, []); }, []);
useEffect(() => {
const handleThemeUpdate = (event) => {
if (event.detail && typeof event.detail === 'object') {
setThemeSettings((current) => ({ ...current, ...event.detail }));
}
};
window.addEventListener(THEME_SETTINGS_EVENT, handleThemeUpdate);
return () => window.removeEventListener(THEME_SETTINGS_EVENT, handleThemeUpdate);
}, []);
const theme = useMemo(() => createMD3Theme({
mode: dark ? 'dark' : 'light',
primary: themeSettings.primary_color || '#6750a4',
glassOpacity: themeSettings.glass_opacity,
}), [dark, themeSettings.primary_color, themeSettings.glass_opacity]);
if (status !== 'ok') { if (status !== 'ok') {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}> <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
@@ -74,8 +90,6 @@ function AdminApp() {
); );
} }
const theme = createMD3Theme({ mode: dark ? 'dark' : 'light', primary });
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
+21 -5
View File
@@ -13,6 +13,15 @@ import { getSettings, saveSettings } from '../../api/settings.js';
import { uploadWallpaper } from '../../api/upload.js'; import { uploadWallpaper } from '../../api/upload.js';
import { showSnack } from '../snack.jsx'; import { showSnack } from '../snack.jsx';
const HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
function validateThemeForm(form) {
if (!HEX_COLOR_RE.test(String(form.primary_color).trim())) return '主题色必须是有效的 HEX 色值(如 #6750a4';
const opacity = Number(form.glass_opacity);
if (!Number.isFinite(opacity) || opacity < 0.1 || opacity > 0.95) return '玻璃透明度必须在 0.1 到 0.95 之间';
return '';
}
/** 主题设置:主题色/壁纸(上传或URL)/导航卡片样式/玻璃模糊透明度/强制深色(迁移自 v1 主题卡片) */ /** 主题设置:主题色/壁纸(上传或URL)/导航卡片样式/玻璃模糊透明度/强制深色(迁移自 v1 主题卡片) */
export default function ThemeSettings() { export default function ThemeSettings() {
const [form, setForm] = useState({ const [form, setForm] = useState({
@@ -61,10 +70,15 @@ export default function ThemeSettings() {
}; };
const save = async () => { const save = async () => {
const validationError = validateThemeForm(form);
if (validationError) {
showSnack(validationError, 'error');
return;
}
setSaving(true); setSaving(true);
try { try {
await saveSettings({ const settings = {
primary_color: form.primary_color, primary_color: form.primary_color.trim().toLowerCase(),
theme_wallpaper: form.theme_wallpaper.trim(), theme_wallpaper: form.theme_wallpaper.trim(),
theme_wallpaper_scale: form.theme_wallpaper_scale, theme_wallpaper_scale: form.theme_wallpaper_scale,
theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0', theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0',
@@ -72,8 +86,10 @@ export default function ThemeSettings() {
nav_style: form.nav_style, nav_style: form.nav_style,
card_style: form.card_style, card_style: form.card_style,
glass_blur: form.glass_blur, glass_blur: form.glass_blur,
glass_opacity: form.glass_opacity, glass_opacity: String(Number(form.glass_opacity)),
}); };
await saveSettings(settings);
window.dispatchEvent(new CustomEvent('rainweb:theme-update', { detail: settings }));
showSnack('主题设置已保存'); showSnack('主题设置已保存');
} catch (e) { } catch (e) {
showSnack(e.message, 'error'); showSnack(e.message, 'error');
@@ -89,7 +105,7 @@ export default function ThemeSettings() {
<Typography variant="subtitle1" sx={{ mb: 1 }}>主题色</Typography> <Typography variant="subtitle1" sx={{ mb: 1 }}>主题色</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="color" value={form.primary_color} onChange={set('primary_color')} style={{ width: 48, height: 48, border: 'none', background: 'none', cursor: 'pointer' }} /> <input type="color" value={form.primary_color} onChange={set('primary_color')} style={{ width: 48, height: 48, border: 'none', background: 'none', cursor: 'pointer' }} />
<TextField label="主色调" value={form.primary_color} onChange={set('primary_color')} size="small" sx={{ width: 160 }} /> <TextField label="主色调" value={form.primary_color} onChange={set('primary_color')} size="small" error={form.primary_color !== '' && !HEX_COLOR_RE.test(form.primary_color.trim())} helperText="支持 3 位或 6 位 HEX 色值" sx={{ width: 190 }} />
</Box> </Box>
</Paper> </Paper>
+9 -10
View File
@@ -1,6 +1,6 @@
/* ============================================================ /* ============================================================
* 后台 MD3 全局微调:与前台(public/css/style.css)一致的氛围细节 * 后台 MD3 全局微调:与前台(public/css/style.css)一致的氛围细节
* 前台 token 变量未注入后台页面,这里按明暗各写一份 * 颜色、焦点色和玻璃透明度由后台 MUI Theme 注入 --admin-* 语义变量
* ============================================================ */ * ============================================================ */
/* 内容区入场动效(对应前台 contentFadeIn */ /* 内容区入场动效(对应前台 contentFadeIn */
@@ -26,11 +26,13 @@ body {
/* 滚动条(对应前台细滚动条) */ /* 滚动条(对应前台细滚动条) */
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cac4d0; border-radius: 3px; } ::-webkit-scrollbar-thumb { background: var(--admin-outline-variant, #cac4d0); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #79747e; } ::-webkit-scrollbar-thumb:hover { background: var(--admin-outline, #79747e); }
[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #49454f; } :focus-visible {
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #938f99; } outline: 2px solid var(--admin-primary, #6750a4);
outline-offset: 2px;
}
/* 工单工作区:占满 AdminLayout 内容区,桌面保持列表/详情主次比例。 */ /* 工单工作区:占满 AdminLayout 内容区,桌面保持列表/详情主次比例。 */
.ticket-workspace { .ticket-workspace {
@@ -199,11 +201,8 @@ body {
} }
.ticket-save-saving { color: var(--mui-palette-text-secondary, inherit); } .ticket-save-saving { color: var(--mui-palette-text-secondary, inherit); }
.ticket-save-success { color: #2e7d32; } .ticket-save-success { color: var(--admin-success, #2e7d32); }
.ticket-save-error { color: #d32f2f; } .ticket-save-error { color: var(--admin-error, #d32f2f); }
[data-theme="dark"] .ticket-save-success { color: #81c784; }
[data-theme="dark"] .ticket-save-error { color: #ef9a9a; }
@media (max-width: 1199px) and (min-width: 700px) { @media (max-width: 1199px) and (min-width: 700px) {
.ticket-filter-grid { .ticket-filter-grid {
+26 -5
View File
@@ -1,4 +1,5 @@
import { createTheme, alpha } from '@mui/material/styles'; import { createTheme, alpha } from '@mui/material/styles';
import { buildThemeTokens, DEFAULT_PRIMARY_COLOR, DEFAULT_GLASS_OPACITY } from '../themeTokens.js';
/* ============================================================ /* ============================================================
* 后台 Material Design 3 主题 * 后台 Material Design 3 主题
@@ -10,7 +11,7 @@ import { createTheme, alpha } from '@mui/material/styles';
* surface 色阶、弱边框 + 低阴影(surface tint)。 * surface 色阶、弱边框 + 低阴影(surface tint)。
* ============================================================ */ * ============================================================ */
const DEFAULT_SEED = '#6750a4'; const DEFAULT_SEED = DEFAULT_PRIMARY_COLOR;
/* ---------- 颜色工具 ---------- */ /* ---------- 颜色工具 ---------- */
@@ -242,9 +243,10 @@ function md3Shadows() {
/* ---------- 主题工厂 ---------- */ /* ---------- 主题工厂 ---------- */
export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {}) { export function createMD3Theme({ mode = 'light', primary = DEFAULT_PRIMARY_COLOR, glassOpacity = DEFAULT_GLASS_OPACITY } = {}) {
const dark = mode === 'dark'; const p = buildThemeTokens({ mode, primary_color: primary, glass_opacity: glassOpacity });
const p = buildPalette(primary, dark); const safeGlassOpacity = p.glassOpacity;
const dark = p.mode === 'dark';
const shadows = md3Shadows(); const shadows = md3Shadows();
const fontFamily = "'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif"; const fontFamily = "'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif";
@@ -301,6 +303,25 @@ export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {})
}, },
shadows, shadows,
components: { components: {
MuiCssBaseline: {
styleOverrides: {
':root': {
'--admin-primary': p.primary,
'--admin-on-primary': p.onPrimary,
'--admin-surface': p.surface,
'--admin-surface-container': p.surfaceContainer,
'--admin-surface-container-low': p.surfaceContainerLow,
'--admin-surface-container-high': p.surfaceContainerHigh,
'--admin-on-surface': p.onSurface,
'--admin-on-surface-variant': p.onSurfaceVariant,
'--admin-outline': p.outline,
'--admin-outline-variant': p.outlineVariant,
'--admin-error': p.error,
'--admin-success': p.success,
'--admin-glass-opacity': safeGlassOpacity,
},
},
},
/* MD3 顶栏:surfaceContainer + 细边框,去硬阴影 */ /* MD3 顶栏:surfaceContainer + 细边框,去硬阴影 */
MuiAppBar: { MuiAppBar: {
defaultProps: { color: 'inherit', elevation: 0 }, defaultProps: { color: 'inherit', elevation: 0 },
@@ -396,7 +417,7 @@ export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {})
styleOverrides: { styleOverrides: {
rounded: { borderRadius: 12 }, rounded: { borderRadius: 12 },
elevation1: { elevation1: {
backgroundColor: p.surfaceContainerLow, backgroundColor: alpha(p.surfaceContainerLow, safeGlassOpacity),
border: `1px solid ${p.outlineVariant}`, border: `1px solid ${p.outlineVariant}`,
boxShadow: 'none', boxShadow: 'none',
backgroundImage: 'none', backgroundImage: 'none',
+24 -3
View File
@@ -1,16 +1,37 @@
import { request } from './client.js'; import { request } from './client.js';
import { THEME_SETTINGS_EVENT, THEME_SETTINGS_SYNC_KEY } from '../themeTokens.js';
const THEME_SETTING_KEYS = new Set([
'primary_color', 'theme_wallpaper', 'theme_wallpaper_scale', 'theme_wallpaper_enabled',
'nav_style', 'card_style', 'glass_blur', 'glass_opacity', 'theme_force_dark',
]);
function noStorePath(path) {
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}_ts=${Date.now()}`;
}
function broadcastThemeSettings(settings) {
if (typeof window === 'undefined' || !settings || !Object.keys(settings).some((key) => THEME_SETTING_KEYS.has(key))) return;
const detail = { ...settings, updated_at: Date.now() };
window.dispatchEvent(new CustomEvent(THEME_SETTINGS_EVENT, { detail }));
try { window.localStorage.setItem(THEME_SETTINGS_SYNC_KEY, JSON.stringify(detail)); } catch {}
}
/** 公开设置(无需登录):site_name、theme_wallpaper、homepage_* 等 */ /** 公开设置(无需登录):site_name、theme_wallpaper、homepage_* 等 */
export function getPublicSettings() { export function getPublicSettings() {
return request('/settings/public'); return request(noStorePath('/settings/public'), { auth: false });
} }
/** 全量设置(仅管理员) */ /** 全量设置(仅管理员) */
export function getSettings() { export function getSettings() {
return request('/settings'); return request(noStorePath('/settings'));
} }
/** 保存设置(仅管理员) */ /** 保存设置(仅管理员) */
export function saveSettings(data) { export function saveSettings(data) {
return request('/settings', { method: 'PUT', body: data }); return request('/settings', { method: 'PUT', body: data }).then((result) => {
broadcastThemeSettings(data);
return result;
});
} }
+28 -7
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'; import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
import { useTheme } from '../theme.jsx'; import { getThemePreference, useTheme } from '../theme.jsx';
import { applyThemeTokens, THEME_SETTINGS_EVENT, THEME_SETTINGS_SYNC_KEY } from '../themeTokens.js';
import * as authApi from '../api/auth.js'; import * as authApi from '../api/auth.js';
import * as settingsApi from '../api/settings.js'; import * as settingsApi from '../api/settings.js';
import { getToken, setToken, notifyAuthChange } from '../api/client.js'; import { getToken, setToken, notifyAuthChange } from '../api/client.js';
@@ -81,9 +82,13 @@ export default function Layout() {
// 加载站点设置 / 版本号 / 登录态(监听 authchange 事件,登录/退出/头像更新后刷新) // 加载站点设置 / 版本号 / 登录态(监听 authchange 事件,登录/退出/头像更新后刷新)
useEffect(() => { useEffect(() => {
const updateSettings = (next) => {
if (!next || typeof next !== 'object') return;
setSettings((previous) => ({ ...previous, ...next }));
};
settingsApi.getPublicSettings() settingsApi.getPublicSettings()
.then((s) => { .then((s) => {
setSettings(s); updateSettings(s);
// 供第三方验证码(reCAPTCHA/Turnstile)读取(照搬 nav.js // 供第三方验证码(reCAPTCHA/Turnstile)读取(照搬 nav.js
window._recaptchaSiteKey = s.recaptcha_site_key || ''; window._recaptchaSiteKey = s.recaptcha_site_key || '';
window._turnstileSiteKey = s.turnstile_site_key || ''; window._turnstileSiteKey = s.turnstile_site_key || '';
@@ -102,17 +107,33 @@ export default function Layout() {
}; };
loadUser(); loadUser();
window.addEventListener('authchange', loadUser); window.addEventListener('authchange', loadUser);
return () => window.removeEventListener('authchange', loadUser); const onThemeSettings = (event) => updateSettings(event.detail);
const onStorage = (event) => {
if (event.key !== THEME_SETTINGS_SYNC_KEY || !event.newValue) return;
try { updateSettings(JSON.parse(event.newValue)); } catch {}
};
window.addEventListener(THEME_SETTINGS_EVENT, onThemeSettings);
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener('authchange', loadUser);
window.removeEventListener(THEME_SETTINGS_EVENT, onThemeSettings);
window.removeEventListener('storage', onStorage);
};
}, []); }, []);
// 强制深色模式(迁移自 nav.js applyTheme // 强制深色模式(迁移自 nav.js applyTheme
useEffect(() => { useEffect(() => {
if (settings.theme_force_dark === '1') { setTheme(settings.theme_force_dark === '1' ? 'dark' : getThemePreference(), { persist: false });
setTheme('dark');
document.documentElement.setAttribute('data-theme', 'dark');
}
}, [settings.theme_force_dark, setTheme]); }, [settings.theme_force_dark, setTheme]);
useEffect(() => {
applyThemeTokens({
primary_color: settings.primary_color,
mode: theme,
glass_opacity: settings.glass_opacity,
});
}, [settings.primary_color, settings.glass_opacity, theme]);
// 壁纸背景 + 玻璃磨砂(迁移自 nav.js applyTheme,直接作用于 body // 壁纸背景 + 玻璃磨砂(迁移自 nav.js applyTheme,直接作用于 body
useEffect(() => { useEffect(() => {
const body = document.body; const body = document.body;
+16 -8
View File
@@ -1,10 +1,17 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { applyThemeTokens } from './themeTokens.js';
// 与 v1 一致:深色模式由 document.documentElement 上的 data-theme="dark" 控制 // 与 v1 一致:深色模式由 document.documentElement 上的 data-theme="dark" 控制
// public/css/style.css 使用 [data-theme="dark"] 选择器,非 .dark 类) // public/css/style.css 使用 [data-theme="dark"] 选择器,非 .dark 类)
const THEME_KEY = 'theme'; const THEME_KEY = 'theme';
const ThemeContext = createContext(null); const ThemeContext = createContext(null);
export function getThemePreference() {
const saved = localStorage.getItem(THEME_KEY);
if (saved === 'dark' || saved === 'light') return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme) { function applyTheme(theme) {
if (theme === 'dark') { if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark'); document.documentElement.setAttribute('data-theme', 'dark');
@@ -14,21 +21,22 @@ function applyTheme(theme) {
} }
export function ThemeProvider({ children }) { export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => { const [theme, setThemeState] = useState(getThemePreference);
const saved = localStorage.getItem(THEME_KEY); const setTheme = useCallback((nextTheme, { persist = true } = {}) => {
if (saved === 'dark' || saved === 'light') return saved; if (nextTheme !== 'dark' && nextTheme !== 'light') return;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; if (persist) localStorage.setItem(THEME_KEY, nextTheme);
}); setThemeState(nextTheme);
}, []);
useEffect(() => { useEffect(() => {
applyTheme(theme); applyTheme(theme);
localStorage.setItem(THEME_KEY, theme); applyThemeTokens({ mode: theme });
}, [theme]); }, [theme]);
const value = useMemo( const value = useMemo(
() => ({ () => ({
theme, theme,
toggleTheme: () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')), toggleTheme: () => setTheme(theme === 'dark' ? 'light' : 'dark'),
setTheme, setTheme,
}), }),
[theme] [theme]
+358
View File
@@ -0,0 +1,358 @@
const DEFAULT_PRIMARY_COLOR = '#6750a4';
const DEFAULT_GLASS_OPACITY = 0.6;
const HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
function clamp(value, min = 0, max = 255) {
return Math.min(max, Math.max(min, Math.round(value)));
}
export function normalizeHexColor(value, fallback = DEFAULT_PRIMARY_COLOR) {
const raw = String(value ?? '').trim();
const safeFallback = HEX_COLOR_RE.test(String(fallback).trim()) ? String(fallback).trim() : DEFAULT_PRIMARY_COLOR;
const color = HEX_COLOR_RE.test(raw) ? raw : safeFallback;
const hex = color.slice(1).toLowerCase();
return `#${hex.length === 3 ? hex.split('').map((part) => part + part).join('') : hex}`;
}
function hexToRgb(hex) {
const value = normalizeHexColor(hex).slice(1);
const number = parseInt(value, 16);
return [(number >> 16) & 255, (number >> 8) & 255, number & 255];
}
function rgbToHex(red, green, blue) {
const toHex = (value) => clamp(value).toString(16).padStart(2, '0');
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`;
}
function mix(first, second, weight) {
const a = hexToRgb(first);
const b = hexToRgb(second);
return rgbToHex(
a[0] + (b[0] - a[0]) * weight,
a[1] + (b[1] - a[1]) * weight,
a[2] + (b[2] - a[2]) * weight
);
}
function hexToHsl(hex) {
const [red, green, blue] = hexToRgb(hex).map((value) => value / 255);
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const lightness = (max + min) / 2;
let hue = 0;
let saturation = 0;
if (max !== min) {
const delta = max - min;
saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
if (max === red) hue = (green - blue) / delta + (green < blue ? 6 : 0);
else if (max === green) hue = (blue - red) / delta + 2;
else hue = (red - green) / delta + 4;
hue *= 60;
}
return [hue, saturation, lightness];
}
function hslToHex(hue, saturation, lightness) {
const normalizedHue = ((hue % 360) + 360) % 360;
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
const x = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1));
const match = lightness - chroma / 2;
let rgb;
if (normalizedHue < 60) rgb = [chroma, x, 0];
else if (normalizedHue < 120) rgb = [x, chroma, 0];
else if (normalizedHue < 180) rgb = [0, chroma, x];
else if (normalizedHue < 240) rgb = [0, x, chroma];
else if (normalizedHue < 300) rgb = [x, 0, chroma];
else rgb = [chroma, 0, x];
return rgbToHex((rgb[0] + match) * 255, (rgb[1] + match) * 255, (rgb[2] + match) * 255);
}
function grayscale(hex) {
const [red, green, blue] = hexToRgb(hex);
const value = 0.299 * red + 0.587 * green + 0.114 * blue;
return rgbToHex(value, value, value);
}
function tone(seed, value) {
if (value >= 40) return mix(seed, '#ffffff', (value - 40) / 60);
return mix(seed, '#000000', (40 - value) / 40);
}
function tonal(seed, value, { hue = 0, saturation = 1 } = {}) {
let base = seed;
if (hue || saturation < 1) {
const [currentHue, currentSaturation, lightness] = hexToHsl(seed);
base = hslToHex(currentHue + hue, currentSaturation * saturation, lightness);
}
return tone(base, value);
}
function relativeLuminance(hex) {
return hexToRgb(hex).reduce((total, channel, index) => {
const value = channel / 255;
const linear = value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
return total + linear * [0.2126, 0.7152, 0.0722][index];
}, 0);
}
function contrastText(background) {
const luminance = relativeLuminance(background);
const whiteContrast = 1.05 / (luminance + 0.05);
const darkContrast = (luminance + 0.05) / 0.05;
return whiteContrast >= darkContrast ? '#ffffff' : '#1c1b1f';
}
function safeOpacity(value) {
const number = Number(value);
return Number.isFinite(number)
? Math.min(0.95, Math.max(0.1, number))
: DEFAULT_GLASS_OPACITY;
}
function rgba(hex, alpha) {
const [red, green, blue] = hexToRgb(hex);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
const LIGHT_SURFACES = {
surface: '#fffbfe',
onSurface: '#1c1b1f',
surfaceVariant: '#e7e0ec',
onSurfaceVariant: '#49454f',
surfaceContainerLow: '#f7f2fa',
surfaceContainer: '#f3edf7',
surfaceContainerHigh: '#ece6f0',
outline: '#79747e',
outlineVariant: '#cac4d0',
error: '#b3261e',
onError: '#ffffff',
inverseSurface: '#313033',
inverseOnSurface: '#f4eff4',
};
const DARK_SURFACES = {
surface: '#1c1b1f',
onSurface: '#e6e1e5',
surfaceVariant: '#49454f',
onSurfaceVariant: '#cac4d0',
surfaceContainerLow: '#211f26',
surfaceContainer: '#25232a',
surfaceContainerHigh: '#2b2930',
outline: '#938f99',
outlineVariant: '#49454f',
error: '#f2b8b5',
onError: '#601410',
inverseSurface: '#e6e1e5',
inverseOnSurface: '#313033',
};
const DEFAULT_LIGHT_PRIMARY = {
primary: DEFAULT_PRIMARY_COLOR,
onPrimary: '#ffffff',
primaryContainer: '#eadfff',
onPrimaryContainer: '#21005d',
secondary: '#625b71',
onSecondary: '#ffffff',
secondaryContainer: '#e8def8',
onSecondaryContainer: '#1d192b',
tertiary: '#7d5260',
onTertiary: '#ffffff',
tertiaryContainer: '#ffd8e4',
onTertiaryContainer: '#31111d',
};
const DEFAULT_DARK_PRIMARY = {
primary: '#d0bcff',
onPrimary: '#381e72',
primaryContainer: '#4f378b',
onPrimaryContainer: '#eadfff',
secondary: '#ccc2dc',
onSecondary: '#332d41',
secondaryContainer: '#4a4458',
onSecondaryContainer: '#e8def8',
tertiary: '#efb8c8',
onTertiary: '#492532',
tertiaryContainer: '#61343f',
onTertiaryContainer: '#ffd8e4',
};
function buildPalette(seed, dark) {
if (seed === DEFAULT_PRIMARY_COLOR) {
return { ...(dark ? DEFAULT_DARK_PRIMARY : DEFAULT_LIGHT_PRIMARY), ...(dark ? DARK_SURFACES : LIGHT_SURFACES) };
}
const surfaces = dark ? DARK_SURFACES : LIGHT_SURFACES;
const primary = tone(seed, dark ? 80 : 40);
const primaryContainer = tone(seed, dark ? 30 : 90);
const secondary = tonal(seed, dark ? 80 : 40, { saturation: 0.55 });
const secondaryContainer = tonal(seed, dark ? 30 : 90, { saturation: 0.55 });
const tertiary = tonal(seed, dark ? 80 : 40, { hue: 32 });
const tertiaryContainer = tonal(seed, dark ? 30 : 90, { hue: 32 });
return {
...surfaces,
primary,
onPrimary: dark ? tone(seed, 20) : contrastText(primary),
primaryContainer,
onPrimaryContainer: dark ? tone(seed, 90) : contrastText(primaryContainer),
secondary,
onSecondary: dark ? tone(seed, 20) : contrastText(secondary),
secondaryContainer,
onSecondaryContainer: dark ? tone(seed, 90) : contrastText(secondaryContainer),
tertiary,
onTertiary: dark ? tone(seed, 20) : contrastText(tertiary),
tertiaryContainer,
onTertiaryContainer: dark ? tone(seed, 90) : contrastText(tertiaryContainer),
};
}
function resolveMode(options) {
const requested = options.mode || options.lightDark || options.light_dark || options['light-dark'];
return requested === 'dark' ? 'dark' : 'light';
}
export function buildThemeTokens(options = {}) {
const mode = resolveMode(options);
const dark = mode === 'dark';
const primaryColor = normalizeHexColor(options.primaryColor ?? options.primary_color);
const glassOpacity = safeOpacity(options.glassOpacity ?? options.glass_opacity);
const palette = buildPalette(primaryColor, dark);
const primaryRgb = hexToRgb(palette.primary).join(', ');
const primaryColorRgb = hexToRgb(primaryColor).join(', ');
const glassSurface = palette.surface;
const glassHeavyOpacity = Math.min(0.98, glassOpacity + 0.18);
const glassWallpaperOpacity = Math.min(0.98, glassOpacity + 0.22);
return {
...palette,
mode,
primaryColor,
primary_color: primaryColor,
primaryRgb,
primary_rgb: primaryRgb,
primaryColorRgb,
background: palette.surface,
foreground: palette.onSurface,
card: palette.surfaceContainerLow,
cardForeground: palette.onSurface,
muted: palette.surfaceContainer,
mutedForeground: palette.onSurfaceVariant,
accent: palette.primaryContainer,
accentForeground: palette.onPrimaryContainer,
border: palette.outlineVariant,
input: palette.outlineVariant,
ring: palette.primary,
destructive: palette.error,
destructiveForeground: palette.onError,
errorContainer: dark ? '#93000a' : '#f9dedc',
onErrorContainer: dark ? '#ffdad6' : '#410e0b',
success: dark ? '#81c784' : '#2e7d32',
onSuccess: dark ? '#123d16' : '#ffffff',
successContainer: dark ? '#214d29' : '#d9f2df',
onSuccessContainer: dark ? '#b8efbd' : '#1b5e20',
warning: dark ? '#ffdda0' : '#7a5900',
onWarning: '#ffffff',
warningContainer: dark ? '#5a420d' : '#fff1d6',
onWarningContainer: dark ? '#ffdda0' : '#6b4300',
info: dark ? '#a8c7fa' : '#245b9e',
glassOpacity,
glass_opacity: String(glassOpacity),
glassBg: rgba(glassSurface, glassOpacity),
glassBgHeavy: rgba(glassSurface, glassHeavyOpacity),
glassBgWallpaper: rgba(glassSurface, glassWallpaperOpacity),
glassBgHeavyWallpaper: rgba(glassSurface, Math.min(0.99, glassHeavyOpacity + 0.04)),
glassBorder: rgba(dark ? '#ffffff' : '#ffffff', dark ? 0.1 : 0.2),
};
}
export function tokensToCssVars(tokens) {
const safeTokens = buildThemeTokens(tokens || {});
const vars = {
'--md-source': safeTokens.primaryColor,
'--md-ref-primary': safeTokens.primary,
'--md-ref-on-primary': safeTokens.onPrimary,
'--md-ref-primary-container': safeTokens.primaryContainer,
'--md-ref-on-primary-container': safeTokens.onPrimaryContainer,
'--md-ref-secondary': safeTokens.secondary,
'--md-ref-on-secondary': safeTokens.onSecondary,
'--md-ref-secondary-container': safeTokens.secondaryContainer,
'--md-ref-on-secondary-container': safeTokens.onSecondaryContainer,
'--md-ref-tertiary': safeTokens.tertiary,
'--md-ref-on-tertiary': safeTokens.onTertiary,
'--md-ref-tertiary-container': safeTokens.tertiaryContainer,
'--md-ref-on-tertiary-container': safeTokens.onTertiaryContainer,
'--md-ref-surface': safeTokens.surface,
'--md-ref-on-surface': safeTokens.onSurface,
'--md-ref-surface-variant': safeTokens.surfaceVariant,
'--md-ref-on-surface-variant': safeTokens.onSurfaceVariant,
'--md-ref-outline': safeTokens.outline,
'--md-ref-outline-variant': safeTokens.outlineVariant,
'--md-ref-error': safeTokens.error,
'--md-ref-on-error': safeTokens.onError,
'--md-ref-error-container': safeTokens.errorContainer,
'--md-ref-on-error-container': safeTokens.onErrorContainer,
'--md-ref-success': safeTokens.success,
'--md-ref-on-success': safeTokens.onSuccess,
'--md-ref-success-container': safeTokens.successContainer,
'--md-ref-on-success-container': safeTokens.onSuccessContainer,
'--md-ref-warning': safeTokens.warning,
'--md-ref-on-warning': safeTokens.onWarning,
'--md-ref-warning-container': safeTokens.warningContainer,
'--md-ref-on-warning-container': safeTokens.onWarningContainer,
'--md-ref-background': safeTokens.background,
'--md-ref-on-background': safeTokens.foreground,
'--md-ref-surface-container-low': safeTokens.surfaceContainerLow,
'--md-ref-surface-container': safeTokens.surfaceContainer,
'--md-ref-surface-container-high': safeTokens.surfaceContainerHigh,
'--md-ref-inverse-surface': safeTokens.inverseSurface,
'--md-ref-inverse-on-surface': safeTokens.inverseOnSurface,
'--md-card-bg': safeTokens.card,
'--md-ref-primary-rgb': safeTokens.primaryRgb,
'--primary-color': safeTokens.primaryColor,
'--primary-rgb': safeTokens.primaryRgb,
'--primary-color-rgb': safeTokens.primaryColorRgb,
'--background': safeTokens.background,
'--foreground': safeTokens.foreground,
'--card': safeTokens.card,
'--card-foreground': safeTokens.cardForeground,
'--muted': safeTokens.muted,
'--muted-foreground': safeTokens.mutedForeground,
'--accent': safeTokens.accent,
'--accent-foreground': safeTokens.accentForeground,
'--border': safeTokens.border,
'--input': safeTokens.input,
'--ring': safeTokens.ring,
'--destructive': safeTokens.destructive,
'--destructive-foreground': safeTokens.destructiveForeground,
'--glass-opacity': String(safeTokens.glassOpacity),
'--glass-opacity-heavy': String(Math.min(0.98, safeTokens.glassOpacity + 0.18)),
'--glass-bg': safeTokens.glassBg,
'--glass-bg-heavy': safeTokens.glassBgHeavy,
'--glass-bg-wallpaper': safeTokens.glassBgWallpaper,
'--glass-bg-heavy-wallpaper': safeTokens.glassBgHeavyWallpaper,
'--glass-border': safeTokens.glassBorder,
'--theme-primary-gradient': `linear-gradient(135deg, ${safeTokens.primary}, ${safeTokens.primaryContainer})`,
'--theme-primary-gradient-wide': `linear-gradient(90deg, ${safeTokens.primary}, ${safeTokens.primaryContainer}, ${safeTokens.tertiaryContainer})`,
'--theme-primary-gradient-soft': `linear-gradient(135deg, color-mix(in srgb, ${safeTokens.primary} 16%, transparent), color-mix(in srgb, ${safeTokens.secondary} 9%, transparent) 50%, color-mix(in srgb, ${safeTokens.tertiary} 10%, transparent))`,
'--theme-primary-shadow': `0 0 8px rgba(${safeTokens.primaryRgb}, 0.7)`,
'--theme-primary-glow': `0 0 16px rgba(${safeTokens.primaryRgb}, 0.45), 0 0 36px rgba(${safeTokens.primaryRgb}, 0.25)`,
};
return vars;
}
export const THEME_SETTINGS_EVENT = 'rainweb:theme-update';
export const THEME_SETTINGS_SYNC_KEY = 'rainweb:theme-settings';
/** 将设置派生出的 token 写入前台根节点,供 CSS 与非 React 内容共同使用。 */
export function applyThemeTokens(options = {}) {
const tokens = buildThemeTokens(options);
if (typeof document === 'undefined') return tokens;
Object.entries(tokensToCssVars(tokens)).forEach(([name, value]) => {
document.documentElement.style.setProperty(name, value);
});
return tokens;
}
export { DEFAULT_PRIMARY_COLOR, DEFAULT_GLASS_OPACITY };
+83 -40
View File
@@ -1,6 +1,14 @@
/* ===== Material Design 3 Theme ===== */ /* ===== Material Design 3 Theme ===== */
:root { :root {
--md-source: #6750a4; --md-source: #6750a4;
--md-ref-primary-rgb: 103, 80, 164;
--primary-color: #6750a4;
--primary-rgb: 103, 80, 164;
--theme-primary-gradient: linear-gradient(135deg, #6750a4, #eadfff);
--theme-primary-gradient-wide: linear-gradient(90deg, #6750a4, #eadfff, #ffd8e4);
--theme-primary-gradient-soft: linear-gradient(135deg, rgba(103, 80, 164, 0.16), rgba(98, 91, 113, 0.09) 50%, rgba(125, 82, 96, 0.1));
--theme-primary-shadow: 0 0 8px rgba(103, 80, 164, 0.7);
--theme-primary-glow: 0 0 16px rgba(103, 80, 164, 0.45), 0 0 36px rgba(103, 80, 164, 0.25);
/* Light theme */ /* Light theme */
--md-ref-primary: #6750a4; --md-ref-primary: #6750a4;
--md-ref-on-primary: #ffffff; --md-ref-on-primary: #ffffff;
@@ -10,6 +18,10 @@
--md-ref-on-secondary: #ffffff; --md-ref-on-secondary: #ffffff;
--md-ref-secondary-container: #e8def8; --md-ref-secondary-container: #e8def8;
--md-ref-on-secondary-container: #1d192b; --md-ref-on-secondary-container: #1d192b;
--md-ref-tertiary: #7d5260;
--md-ref-on-tertiary: #ffffff;
--md-ref-tertiary-container: #ffd8e4;
--md-ref-on-tertiary-container: #31111d;
--md-ref-surface: #fffbfe; --md-ref-surface: #fffbfe;
--md-ref-on-surface: #1c1b1f; --md-ref-on-surface: #1c1b1f;
--md-ref-surface-variant: #e7e0ec; --md-ref-surface-variant: #e7e0ec;
@@ -18,6 +30,16 @@
--md-ref-outline-variant: #cac4d0; --md-ref-outline-variant: #cac4d0;
--md-ref-error: #b3261e; --md-ref-error: #b3261e;
--md-ref-on-error: #ffffff; --md-ref-on-error: #ffffff;
--md-ref-error-container: #f9dedc;
--md-ref-on-error-container: #410e0b;
--md-ref-success: #2e7d32;
--md-ref-on-success: #ffffff;
--md-ref-success-container: #d9f2df;
--md-ref-on-success-container: #1b5e20;
--md-ref-warning: #8a5700;
--md-ref-on-warning: #ffffff;
--md-ref-warning-container: #fff1d6;
--md-ref-on-warning-container: #6b4300;
--md-ref-background: #fffbfe; --md-ref-background: #fffbfe;
--md-ref-on-background: #1c1b1f; --md-ref-on-background: #1c1b1f;
--md-ref-surface-container-low: #f7f2fa; --md-ref-surface-container-low: #f7f2fa;
@@ -31,6 +53,7 @@
[data-theme="dark"] { [data-theme="dark"] {
--md-ref-primary: #d0bcff; --md-ref-primary: #d0bcff;
--md-ref-primary-rgb: 208, 188, 255;
--md-ref-on-primary: #381e72; --md-ref-on-primary: #381e72;
--md-ref-primary-container: #4f378b; --md-ref-primary-container: #4f378b;
--md-ref-on-primary-container: #eadfff; --md-ref-on-primary-container: #eadfff;
@@ -38,6 +61,10 @@
--md-ref-on-secondary: #332d41; --md-ref-on-secondary: #332d41;
--md-ref-secondary-container: #4a4458; --md-ref-secondary-container: #4a4458;
--md-ref-on-secondary-container: #e8def8; --md-ref-on-secondary-container: #e8def8;
--md-ref-tertiary: #efb8c8;
--md-ref-on-tertiary: #492532;
--md-ref-tertiary-container: #61343f;
--md-ref-on-tertiary-container: #ffd8e4;
--md-ref-surface: #1c1b1f; --md-ref-surface: #1c1b1f;
--md-ref-on-surface: #e6e1e5; --md-ref-on-surface: #e6e1e5;
--md-ref-surface-variant: #49454f; --md-ref-surface-variant: #49454f;
@@ -46,6 +73,16 @@
--md-ref-outline-variant: #49454f; --md-ref-outline-variant: #49454f;
--md-ref-error: #f2b8b5; --md-ref-error: #f2b8b5;
--md-ref-on-error: #601410; --md-ref-on-error: #601410;
--md-ref-error-container: #93000a;
--md-ref-on-error-container: #ffdad6;
--md-ref-success: #81c784;
--md-ref-on-success: #123d16;
--md-ref-success-container: #214d29;
--md-ref-on-success-container: #b8efbd;
--md-ref-warning: #ffdda0;
--md-ref-on-warning: #422c00;
--md-ref-warning-container: #5a420d;
--md-ref-on-warning-container: #ffdda0;
--md-ref-background: #1c1b1f; --md-ref-background: #1c1b1f;
--md-ref-on-background: #e6e1e5; --md-ref-on-background: #e6e1e5;
--md-ref-surface-container-low: #211f26; --md-ref-surface-container-low: #211f26;
@@ -371,7 +408,7 @@ input, select, textarea {
input:focus, select:focus, textarea:focus { input:focus, select:focus, textarea:focus {
border-color: var(--md-ref-primary); border-color: var(--md-ref-primary);
box-shadow: 0 0 0 3px rgba(var(--md-ref-primary-rgb, 103, 80, 164), 0.15); box-shadow: 0 0 0 3px color-mix(in srgb, var(--md-ref-primary) 15%, transparent);
} }
textarea { textarea {
@@ -1646,16 +1683,22 @@ table tr:hover td {
/* ===== Wallpaper & Glass Effects ===== */ /* ===== Wallpaper & Glass Effects ===== */
:root { :root {
--glass-bg: rgba(255,251,254,0.65); --glass-opacity: 0.6;
--glass-bg-heavy: rgba(255,251,254,0.8); --glass-opacity-heavy: 0.8;
--glass-bg: rgb(255 251 254 / var(--glass-opacity));
--glass-bg-heavy: rgb(255 251 254 / var(--glass-opacity-heavy));
--glass-bg-wallpaper: rgb(255 251 254 / 0.82);
--glass-bg-heavy-wallpaper: rgb(255 251 254 / 0.92);
--glass-border: rgba(255,255,255,0.2); --glass-border: rgba(255,255,255,0.2);
--glass-blur: 20px; --glass-blur: 20px;
--wallpaper: none; --wallpaper: none;
} }
[data-theme="dark"] { [data-theme="dark"] {
--glass-bg: rgba(28,27,31,0.6); --glass-bg: rgb(28 27 31 / var(--glass-opacity));
--glass-bg-heavy: rgba(28,27,31,0.78); --glass-bg-heavy: rgb(28 27 31 / var(--glass-opacity-heavy));
--glass-bg-wallpaper: rgb(28 27 31 / 0.74);
--glass-bg-heavy-wallpaper: rgb(28 27 31 / 0.84);
--glass-border: rgba(255,255,255,0.08); --glass-border: rgba(255,255,255,0.08);
} }
@@ -1734,12 +1777,12 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
--glass-bg / --glass-bg-heavy 的所有使用者(玻璃卡/导航/表格/弹窗)自动加强; --glass-bg / --glass-bg-heavy 的所有使用者(玻璃卡/导航/表格/弹窗)自动加强;
无壁纸时保持原有半透明质感不受影响 */ 无壁纸时保持原有半透明质感不受影响 */
body.has-wallpaper { body.has-wallpaper {
--glass-bg: rgba(255,251,254,0.82); --glass-bg: var(--glass-bg-wallpaper);
--glass-bg-heavy: rgba(255,251,254,0.92); --glass-bg-heavy: var(--glass-bg-heavy-wallpaper);
} }
[data-theme="dark"] body.has-wallpaper { [data-theme="dark"] body.has-wallpaper {
--glass-bg: rgba(28,27,31,0.74); --glass-bg: var(--glass-bg-wallpaper);
--glass-bg-heavy: rgba(28,27,31,0.84); --glass-bg-heavy: var(--glass-bg-heavy-wallpaper);
} }
/* Glass card style */ /* Glass card style */
@@ -1773,34 +1816,34 @@ body.has-wallpaper {
/* 壁纸下页脚同源加强(页脚在 .page 之外,单独覆盖) */ /* 壁纸下页脚同源加强(页脚在 .page 之外,单独覆盖) */
body.has-wallpaper .footer-classic { body.has-wallpaper .footer-classic {
background: rgba(255, 251, 254, 0.82); background: var(--glass-bg-heavy);
} }
[data-theme="dark"] body.has-wallpaper .footer-classic { [data-theme="dark"] body.has-wallpaper .footer-classic {
background: rgba(28, 27, 31, 0.75); background: var(--glass-bg-heavy);
} }
/* 壁纸下详情页正文卡 / 目录卡加强:阅读面需要更高不透明度(其余卡片由 /* 壁纸下详情页正文卡 / 目录卡加强:阅读面需要更高不透明度(其余卡片由
body.has-wallpaper 的 --glass-bg 变量统一加强) */ body.has-wallpaper 的 --glass-bg 变量统一加强) */
body.has-wallpaper .blog-article, body.has-wallpaper .blog-article,
body.has-wallpaper .article-toc-side { body.has-wallpaper .article-toc-side {
background: rgba(255, 251, 254, 0.78); background: var(--glass-bg-heavy);
} }
[data-theme="dark"] body.has-wallpaper .blog-article, [data-theme="dark"] body.has-wallpaper .blog-article,
[data-theme="dark"] body.has-wallpaper .article-toc-side { [data-theme="dark"] body.has-wallpaper .article-toc-side {
background: rgba(28, 27, 31, 0.74); background: var(--glass-bg-heavy);
} }
/* 壁纸下论坛侧栏玻璃底(B9):仿 .blog-article 的半透明 + 模糊 + 圆角, /* 壁纸下论坛侧栏玻璃底(B9):仿 .blog-article 的半透明 + 模糊 + 圆角,
保证壁纸下分类文字可读(深色主题用深色底) */ 保证壁纸下分类文字可读(深色主题用深色底) */
.has-wallpaper .forum-sidebar { .has-wallpaper .forum-sidebar {
background: rgba(255, 251, 254, 0.5); background: var(--glass-bg);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border-radius: 16px; border-radius: 16px;
padding: 12px; padding: 12px;
} }
[data-theme="dark"] .has-wallpaper .forum-sidebar { [data-theme="dark"] .has-wallpaper .forum-sidebar {
background: rgba(28, 27, 31, 0.55); background: var(--glass-bg);
} }
/* Blog waterfall cards with wallpaper */ /* Blog waterfall cards with wallpaper */
@@ -2054,12 +2097,12 @@ body.has-wallpaper .article-toc-side {
padding: 22px 28px 28px; padding: 22px 28px 28px;
border-top: 1px solid var(--md-ref-outline-variant); border-top: 1px solid var(--md-ref-outline-variant);
/* 磨砂玻璃:半透明底 + 模糊,壁纸下增强可读性 */ /* 磨砂玻璃:半透明底 + 模糊,壁纸下增强可读性 */
background: rgba(255, 251, 254, 0.55); background: var(--glass-bg);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
} }
[data-theme="dark"] .footer-classic { [data-theme="dark"] .footer-classic {
background: rgba(28, 27, 31, 0.6); background: var(--glass-bg);
} }
.footer-classic .fc-left { .footer-classic .fc-left {
font-size: 13px; font-size: 13px;
@@ -2073,8 +2116,8 @@ body.has-wallpaper .article-toc-side {
border-radius: 50%; border-radius: 50%;
margin: 0 10px; margin: 0 10px;
vertical-align: middle; vertical-align: middle;
background: linear-gradient(135deg, var(--md-ref-primary), #d0bcff); background: var(--theme-primary-gradient);
box-shadow: 0 0 8px rgba(103, 80, 164, 0.7); box-shadow: var(--theme-primary-shadow);
} }
.footer-classic .fc-right { .footer-classic .fc-right {
display: inline-flex; display: inline-flex;
@@ -2099,13 +2142,13 @@ body.has-wallpaper .article-toc-side {
/* ---- 分栏导航 ---- */ /* ---- 分栏导航 ---- */
.footer-columns { .footer-columns {
/* 磨砂玻璃:半透明底 + 模糊,壁纸下增强可读性 */ /* 磨砂玻璃:半透明底 + 模糊,壁纸下增强可读性 */
background: rgba(255, 251, 254, 0.55); background: var(--glass-bg);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-top: 1px solid var(--md-ref-outline-variant); border-top: 1px solid var(--md-ref-outline-variant);
} }
[data-theme="dark"] .footer-columns { [data-theme="dark"] .footer-columns {
background: rgba(28, 27, 31, 0.6); background: var(--glass-bg);
} }
.footer-columns .fc-grid { .footer-columns .fc-grid {
display: grid; display: grid;
@@ -2179,8 +2222,8 @@ body.has-wallpaper .article-toc-side {
border-radius: 50%; border-radius: 50%;
margin: 0 10px; margin: 0 10px;
vertical-align: middle; vertical-align: middle;
background: linear-gradient(135deg, var(--md-ref-primary), #d0bcff); background: var(--theme-primary-gradient);
box-shadow: 0 0 8px rgba(103, 80, 164, 0.7); box-shadow: var(--theme-primary-shadow);
} }
/* ---- 玻璃卡片 ---- */ /* ---- 玻璃卡片 ---- */
@@ -2287,7 +2330,7 @@ body.has-wallpaper .article-toc-side {
} }
.search-box:focus-within { .search-box:focus-within {
border-color: var(--md-ref-primary); border-color: var(--md-ref-primary);
box-shadow: 0 0 0 3px rgba(103, 80, 164, 0.15); box-shadow: 0 0 0 3px rgba(var(--md-ref-primary-rgb), 0.15);
} }
.search-box .material-icons { font-size: 20px; flex-shrink: 0; } .search-box .material-icons { font-size: 20px; flex-shrink: 0; }
.search-box input { .search-box input {
@@ -2630,7 +2673,7 @@ body.has-wallpaper .article-toc-side {
gap: 6px 10px; gap: 6px 10px;
padding: 8px 22px; padding: 8px 22px;
border-radius: 999px; border-radius: 999px;
background: linear-gradient(135deg, rgba(103, 80, 164, 0.16), rgba(224, 163, 255, 0.09) 50%, rgba(255, 138, 171, 0.10)); background: var(--theme-primary-gradient-soft);
border: 1px solid var(--md-ref-outline-variant); border: 1px solid var(--md-ref-outline-variant);
font-weight: 600; font-weight: 600;
letter-spacing: 0.6px; letter-spacing: 0.6px;
@@ -2640,19 +2683,19 @@ body.has-wallpaper .article-toc-side {
} }
.copyright-glow b, .copyright-glow b,
.copyright-glow .grad { .copyright-glow .grad {
background: linear-gradient(90deg, var(--md-ref-primary), #d0bcff, #ff8fab); background: linear-gradient(90deg, var(--md-ref-primary), var(--md-ref-secondary), var(--md-ref-tertiary));
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;
} }
@keyframes copyrightGlow { @keyframes copyrightGlow {
0%, 100% { 0%, 100% {
box-shadow: 0 0 14px rgba(103, 80, 164, 0.30), 0 0 32px rgba(208, 188, 255, 0.12), inset 0 0 10px rgba(255, 255, 255, 0.05); box-shadow: 0 0 14px rgba(var(--md-ref-primary-rgb), 0.30), 0 0 32px color-mix(in srgb, var(--md-ref-primary-container) 12%, transparent), inset 0 0 10px rgba(255, 255, 255, 0.05);
border-color: var(--md-ref-outline-variant); border-color: var(--md-ref-outline-variant);
} }
50% { 50% {
box-shadow: 0 0 22px rgba(103, 80, 164, 0.55), 0 0 48px rgba(208, 188, 255, 0.25), inset 0 0 14px rgba(255, 255, 255, 0.08); box-shadow: 0 0 22px rgba(var(--md-ref-primary-rgb), 0.55), 0 0 48px color-mix(in srgb, var(--md-ref-primary-container) 25%, transparent), inset 0 0 14px rgba(255, 255, 255, 0.08);
border-color: rgba(208, 188, 255, 0.5); border-color: color-mix(in srgb, var(--md-ref-primary-container) 50%, transparent);
} }
} }
@@ -2668,12 +2711,12 @@ body.has-wallpaper .article-toc-side {
font-weight: 700; font-weight: 700;
letter-spacing: 1.2px; letter-spacing: 1.2px;
font-size: 13px; font-size: 13px;
color: #fff; color: var(--md-ref-on-primary);
text-shadow: 0 0 10px rgba(208, 188, 255, 0.8), 0 0 20px rgba(103, 80, 164, 0.5); text-shadow: 0 0 10px color-mix(in srgb, var(--md-ref-primary-container) 80%, transparent), 0 0 20px rgba(var(--md-ref-primary-rgb), 0.5);
background: linear-gradient(135deg, #6750a4, #7c4dff 40%, #b388ff 70%, #ff8fab); background: var(--theme-primary-gradient-wide);
background-size: 200% 200%; background-size: 200% 200%;
animation: poweredFlow 4s ease infinite, poweredPulse 2.4s ease-in-out infinite; animation: poweredFlow 4s ease infinite, poweredPulse 2.4s ease-in-out infinite;
box-shadow: 0 0 16px rgba(124, 77, 255, 0.45), 0 0 36px rgba(103, 80, 164, 0.25); box-shadow: 0 0 16px color-mix(in srgb, var(--md-ref-primary) 45%, transparent), 0 0 36px rgba(var(--md-ref-primary-rgb), 0.25);
} }
.powered-glow::before { .powered-glow::before {
content: ''; content: '';
@@ -2681,7 +2724,7 @@ body.has-wallpaper .article-toc-side {
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
padding: 1.5px; padding: 1.5px;
background: linear-gradient(120deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.15) 40%, rgba(255, 138, 171, 0.8)); background: linear-gradient(120deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.15) 40%, color-mix(in srgb, var(--md-ref-tertiary) 80%, transparent));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor; -webkit-mask-composite: xor;
mask-composite: exclude; mask-composite: exclude;
@@ -2703,8 +2746,8 @@ body.has-wallpaper .article-toc-side {
50% { background-position: 100% 50%; } 50% { background-position: 100% 50%; }
} }
@keyframes poweredPulse { @keyframes poweredPulse {
0%, 100% { box-shadow: 0 0 16px rgba(124, 77, 255, 0.45), 0 0 36px rgba(103, 80, 164, 0.25); } 0%, 100% { box-shadow: 0 0 16px color-mix(in srgb, var(--md-ref-primary) 45%, transparent), 0 0 36px rgba(var(--md-ref-primary-rgb), 0.25); }
50% { box-shadow: 0 0 24px rgba(124, 77, 255, 0.7), 0 0 56px rgba(103, 80, 164, 0.4); } 50% { box-shadow: 0 0 24px color-mix(in srgb, var(--md-ref-primary) 70%, transparent), 0 0 56px rgba(var(--md-ref-primary-rgb), 0.4); }
} }
@keyframes poweredBorder { @keyframes poweredBorder {
0% { filter: hue-rotate(0deg); opacity: 0.7; } 0% { filter: hue-rotate(0deg); opacity: 0.7; }
@@ -2718,14 +2761,14 @@ body.has-wallpaper .article-toc-side {
/* ===== 博客文章详情页壁纸下磨砂背景(增强可读性) ===== */ /* ===== 博客文章详情页壁纸下磨砂背景(增强可读性) ===== */
.blog-article { .blog-article {
background: rgba(255, 251, 254, 0.5); background: var(--glass-bg);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border-radius: 16px; border-radius: 16px;
padding: 24px; padding: 24px;
} }
[data-theme="dark"] .blog-article { [data-theme="dark"] .blog-article {
background: rgba(28, 27, 31, 0.55); background: var(--glass-bg);
} }
/* ===== 文章详情页布局:正文卡片(居中固定宽)+ 右侧悬浮目录 ===== /* ===== 文章详情页布局:正文卡片(居中固定宽)+ 右侧悬浮目录 =====
@@ -2751,7 +2794,7 @@ body.has-wallpaper .article-toc-side {
left: calc(50% + 388px); /* 正文半宽 360px + 间距 28px */ left: calc(50% + 388px); /* 正文半宽 360px + 间距 28px */
max-height: calc(100vh - 120px); max-height: calc(100vh - 120px);
overflow-y: auto; overflow-y: auto;
background: rgba(255, 251, 254, 0.55); background: var(--glass-bg);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid var(--md-ref-outline-variant); border: 1px solid var(--md-ref-outline-variant);
@@ -2760,7 +2803,7 @@ body.has-wallpaper .article-toc-side {
box-shadow: 0 4px 20px var(--md-shadow); box-shadow: 0 4px 20px var(--md-shadow);
} }
[data-theme="dark"] .article-toc-side { [data-theme="dark"] .article-toc-side {
background: rgba(28, 27, 31, 0.6); background: var(--glass-bg);
} }
.article-toc-side .toc-title { .article-toc-side .toc-title {
display: flex; display: flex;
+54 -1
View File
@@ -4,6 +4,11 @@ const { authMiddleware, adminOnly } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
router.use((req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
next();
});
const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color', const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
'recaptcha_site_key','turnstile_site_key', 'recaptcha_site_key','turnstile_site_key',
'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style', 'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style',
@@ -33,6 +38,50 @@ const ALL_KEYS = ['site_name','site_description','site_url','primary_color','rec
const ALLOWED_SET = [...ALL_KEYS, 'recaptcha_secret_key', 'smtp_pass', 'turnstile_secret_key', 'rainid_client_secret']; const ALLOWED_SET = [...ALL_KEYS, 'recaptcha_secret_key', 'smtp_pass', 'turnstile_secret_key', 'rainid_client_secret'];
const THEME_RULES = {
glass_blur: (value) => {
const raw = String(value).trim();
const number = Number(raw);
return /^\d+$/.test(raw) && Number.isInteger(number) && number >= 5 && number <= 40;
},
glass_opacity: (value) => {
const raw = String(value).trim();
const number = Number(raw);
return raw !== '' && Number.isFinite(number) && number >= 0.1 && number <= 0.95;
},
theme_force_dark: (value) => ['0', '1'].includes(String(value)),
theme_wallpaper_scale: (value) => ['cover', 'contain', 'repeat', 'stretch'].includes(String(value)),
nav_style: (value) => ['default', 'glass', 'capsule'].includes(String(value)),
card_style: (value) => ['default', 'glass'].includes(String(value)),
};
function normalizePrimaryColor(value) {
const raw = String(value).trim();
if (/^#[0-9a-f]{3}$/i.test(raw)) {
return '#' + raw.slice(1).split('').map((c) => c + c).join('').toLowerCase();
}
if (/^#[0-9a-f]{6}$/i.test(raw)) return raw.toLowerCase();
return null;
}
function validateThemeSettings(body) {
const validated = {};
if (body.primary_color !== undefined) {
const color = normalizePrimaryColor(body.primary_color);
if (!color) return { error: '主色调格式无效:仅支持 #RGB 或 #RRGGBB' };
validated.primary_color = color;
}
for (const [key, validate] of Object.entries(THEME_RULES)) {
if (body[key] === undefined) continue;
if (!validate(body[key])) return { error: `主题设置 ${key} 的值无效` };
validated[key] = String(body[key]).trim();
}
return { validated };
}
// footer_columns JSON 校验:数组(最多 3 栏,并行展示上限);每项 {title: string ≤20, links: [{label ≤50, url ≤200}]}。 // footer_columns JSON 校验:数组(最多 3 栏,并行展示上限);每项 {title: string ≤20, links: [{label ≤50, url ≤200}]}。
// url 白名单:站内相对路径以 / 开头(排除 // 协议相对),或 http(s):// 外链;拒绝 javascript:/data:/vbscript: 等危险协议。 // url 白名单:站内相对路径以 / 开头(排除 // 协议相对),或 http(s):// 外链;拒绝 javascript:/data:/vbscript: 等危险协议。
// 返回解析后的数组,非法返回 null。 // 返回解析后的数组,非法返回 null。
@@ -71,6 +120,9 @@ router.get('/', authMiddleware, adminOnly, (req, res) => {
}); });
router.put('/', authMiddleware, adminOnly, (req, res) => { router.put('/', authMiddleware, adminOnly, (req, res) => {
const themeValidation = validateThemeSettings(req.body);
if (themeValidation.error) return res.status(400).json({ error: themeValidation.error });
// footer_columns 特判校验:非法 JSON/结构/危险协议 → 400 不落库(空串表示回退硬编码,放行) // footer_columns 特判校验:非法 JSON/结构/危险协议 → 400 不落库(空串表示回退硬编码,放行)
if (req.body.footer_columns !== undefined && String(req.body.footer_columns) !== '') { if (req.body.footer_columns !== undefined && String(req.body.footer_columns) !== '') {
if (!validateFooterColumns(String(req.body.footer_columns))) { if (!validateFooterColumns(String(req.body.footer_columns))) {
@@ -79,8 +131,9 @@ router.put('/', authMiddleware, adminOnly, (req, res) => {
}); });
} }
} }
const values = { ...req.body, ...themeValidation.validated };
for (const [key, value] of Object.entries(req.body)) { for (const [key, value] of Object.entries(req.body)) {
if (ALLOWED_SET.includes(key)) db.setSetting(key, String(value)); if (ALLOWED_SET.includes(key)) db.setSetting(key, values[key] === undefined ? String(value) : values[key]);
} }
res.json({ message: '设置已保存' }); res.json({ message: '设置已保存' });
}); });