From 390c4ac53f7511be427ce9678eaee412336a4818 Mon Sep 17 00:00:00 2001 From: miaomiao <2733773236@qq.com> Date: Sat, 5 Sep 2026 23:12:17 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=89=8D=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E9=A2=9C=E8=89=B2=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + frontend/src/admin/main.jsx | 32 +- frontend/src/admin/pages/ThemeSettings.jsx | 26 +- frontend/src/admin/theme.css | 19 +- frontend/src/admin/theme.js | 31 +- frontend/src/api/settings.js | 27 +- frontend/src/components/Layout.jsx | 35 +- frontend/src/theme.jsx | 24 +- frontend/src/themeTokens.js | 358 +++++++++++++++++++++ public/css/style.css | 123 ++++--- routes/settings.js | 55 +++- 11 files changed, 643 insertions(+), 88 deletions(-) create mode 100644 frontend/src/themeTokens.js diff --git a/AGENTS.md b/AGENTS.md index 1d602ea..a81e1a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ node cli.js # status | health | db-check | start | stop | restart | por - 路由保持 v1 的 `.html` 后缀路径(`/blog.html`、`/forum.html` 等),SPA fallback 已支持,链接不用改。 - `frontend/index.html` 含 `${site_name}` / `${site_description}` / `${site_favicon}` 占位符,构建后由 server.js `serveIndex` 按 site_settings **运行时替换**(务必保留这 3 个占位符)。 - 主题:`data-theme="dark"` 属性在 `` 上(`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/白屏。 - 博文/帖子内容中的 `[image:文件名]` / `[file:文件名]` 标签:前台由 `MarkdownRenderer`(marked + DOMPurify 净化)、SEO 页由 `ssr.js` 渲染为 `/uploads/` 链接。**所有 markdown 渲染必须过 DOMPurify**。 - 登录态:token 存 localStorage(key `token`),变更通过 `authchange` 事件通知 Layout 刷新(`api/client.js` 的 `notifyAuthChange`)。 diff --git a/frontend/src/admin/main.jsx b/frontend/src/admin/main.jsx index d2ddf61..1f543e7 100644 --- a/frontend/src/admin/main.jsx +++ b/frontend/src/admin/main.jsx @@ -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 { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { ThemeProvider } from '@mui/material/styles'; @@ -6,6 +6,7 @@ import CssBaseline from '@mui/material/CssBaseline'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import { createMD3Theme } from './theme.js'; +import { THEME_SETTINGS_EVENT } from '../themeTokens.js'; import './theme.css'; import { getToken } from '../api/client.js'; import { me } from '../api/auth.js'; @@ -38,14 +39,13 @@ const basename = (pathname === '/admin' || pathname.startsWith('/admin/')) ? '/a function AdminApp() { 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 属性不保留, - // 以 localStorage 的 theme 为兜底(前台 theme.jsx 持久化同一 key)。 - const dark = document.documentElement.getAttribute('data-theme') === 'dark' - || localStorage.getItem('theme') === 'dark'; + const preferredTheme = localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'; + const dark = themeSettings.theme_force_dark === '1' || preferredTheme === 'dark'; useEffect(() => { if (dark) document.documentElement.setAttribute('data-theme', 'dark'); + else document.documentElement.removeAttribute('data-theme'); }, [dark]); useEffect(() => { @@ -62,10 +62,26 @@ function AdminApp() { }) .catch(() => { window.location.href = '/login.html'; }); getSettings() - .then((s) => { if (s.primary_color) setPrimary(s.primary_color); }) + .then((s) => setThemeSettings((current) => ({ ...current, ...s }))) .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') { return ( @@ -74,8 +90,6 @@ function AdminApp() { ); } - const theme = createMD3Theme({ mode: dark ? 'dark' : 'light', primary }); - return ( diff --git a/frontend/src/admin/pages/ThemeSettings.jsx b/frontend/src/admin/pages/ThemeSettings.jsx index 418a5bf..3005fe4 100644 --- a/frontend/src/admin/pages/ThemeSettings.jsx +++ b/frontend/src/admin/pages/ThemeSettings.jsx @@ -13,6 +13,15 @@ import { getSettings, saveSettings } from '../../api/settings.js'; import { uploadWallpaper } from '../../api/upload.js'; 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 主题卡片) */ export default function ThemeSettings() { const [form, setForm] = useState({ @@ -61,10 +70,15 @@ export default function ThemeSettings() { }; const save = async () => { + const validationError = validateThemeForm(form); + if (validationError) { + showSnack(validationError, 'error'); + return; + } setSaving(true); try { - await saveSettings({ - primary_color: form.primary_color, + const settings = { + primary_color: form.primary_color.trim().toLowerCase(), theme_wallpaper: form.theme_wallpaper.trim(), theme_wallpaper_scale: form.theme_wallpaper_scale, theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0', @@ -72,8 +86,10 @@ export default function ThemeSettings() { nav_style: form.nav_style, card_style: form.card_style, 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('主题设置已保存'); } catch (e) { showSnack(e.message, 'error'); @@ -89,7 +105,7 @@ export default function ThemeSettings() { 主题色 - + diff --git a/frontend/src/admin/theme.css b/frontend/src/admin/theme.css index fb270dc..48a2aa5 100644 --- a/frontend/src/admin/theme.css +++ b/frontend/src/admin/theme.css @@ -1,6 +1,6 @@ /* ============================================================ * 后台 MD3 全局微调:与前台(public/css/style.css)一致的氛围细节 - * 前台 token 变量未注入后台页面,这里按明暗各写一份。 + * 颜色、焦点色和玻璃透明度由后台 MUI Theme 注入 --admin-* 语义变量。 * ============================================================ */ /* 内容区入场动效(对应前台 contentFadeIn) */ @@ -26,11 +26,13 @@ body { /* 滚动条(对应前台细滚动条) */ ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: #cac4d0; border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: #79747e; } +::-webkit-scrollbar-thumb { background: var(--admin-outline-variant, #cac4d0); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--admin-outline, #79747e); } -[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #49454f; } -[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #938f99; } +:focus-visible { + outline: 2px solid var(--admin-primary, #6750a4); + outline-offset: 2px; +} /* 工单工作区:占满 AdminLayout 内容区,桌面保持列表/详情主次比例。 */ .ticket-workspace { @@ -199,11 +201,8 @@ body { } .ticket-save-saving { color: var(--mui-palette-text-secondary, inherit); } -.ticket-save-success { color: #2e7d32; } -.ticket-save-error { color: #d32f2f; } - -[data-theme="dark"] .ticket-save-success { color: #81c784; } -[data-theme="dark"] .ticket-save-error { color: #ef9a9a; } +.ticket-save-success { color: var(--admin-success, #2e7d32); } +.ticket-save-error { color: var(--admin-error, #d32f2f); } @media (max-width: 1199px) and (min-width: 700px) { .ticket-filter-grid { diff --git a/frontend/src/admin/theme.js b/frontend/src/admin/theme.js index bf2cadf..740c588 100644 --- a/frontend/src/admin/theme.js +++ b/frontend/src/admin/theme.js @@ -1,4 +1,5 @@ import { createTheme, alpha } from '@mui/material/styles'; +import { buildThemeTokens, DEFAULT_PRIMARY_COLOR, DEFAULT_GLASS_OPACITY } from '../themeTokens.js'; /* ============================================================ * 后台 Material Design 3 主题 @@ -10,7 +11,7 @@ import { createTheme, alpha } from '@mui/material/styles'; * 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 } = {}) { - const dark = mode === 'dark'; - const p = buildPalette(primary, dark); +export function createMD3Theme({ mode = 'light', primary = DEFAULT_PRIMARY_COLOR, glassOpacity = DEFAULT_GLASS_OPACITY } = {}) { + const p = buildThemeTokens({ mode, primary_color: primary, glass_opacity: glassOpacity }); + const safeGlassOpacity = p.glassOpacity; + const dark = p.mode === 'dark'; const shadows = md3Shadows(); const fontFamily = "'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif"; @@ -301,6 +303,25 @@ export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {}) }, shadows, 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 + 细边框,去硬阴影 */ MuiAppBar: { defaultProps: { color: 'inherit', elevation: 0 }, @@ -396,7 +417,7 @@ export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {}) styleOverrides: { rounded: { borderRadius: 12 }, elevation1: { - backgroundColor: p.surfaceContainerLow, + backgroundColor: alpha(p.surfaceContainerLow, safeGlassOpacity), border: `1px solid ${p.outlineVariant}`, boxShadow: 'none', backgroundImage: 'none', diff --git a/frontend/src/api/settings.js b/frontend/src/api/settings.js index 636be62..af871c9 100644 --- a/frontend/src/api/settings.js +++ b/frontend/src/api/settings.js @@ -1,16 +1,37 @@ 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_* 等 */ export function getPublicSettings() { - return request('/settings/public'); + return request(noStorePath('/settings/public'), { auth: false }); } /** 全量设置(仅管理员) */ export function getSettings() { - return request('/settings'); + return request(noStorePath('/settings')); } /** 保存设置(仅管理员) */ export function saveSettings(data) { - return request('/settings', { method: 'PUT', body: data }); + return request('/settings', { method: 'PUT', body: data }).then((result) => { + broadcastThemeSettings(data); + return result; + }); } diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index dda93c9..46c2912 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; 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 settingsApi from '../api/settings.js'; import { getToken, setToken, notifyAuthChange } from '../api/client.js'; @@ -81,9 +82,13 @@ export default function Layout() { // 加载站点设置 / 版本号 / 登录态(监听 authchange 事件,登录/退出/头像更新后刷新) useEffect(() => { + const updateSettings = (next) => { + if (!next || typeof next !== 'object') return; + setSettings((previous) => ({ ...previous, ...next })); + }; settingsApi.getPublicSettings() .then((s) => { - setSettings(s); + updateSettings(s); // 供第三方验证码(reCAPTCHA/Turnstile)读取(照搬 nav.js) window._recaptchaSiteKey = s.recaptcha_site_key || ''; window._turnstileSiteKey = s.turnstile_site_key || ''; @@ -102,17 +107,33 @@ export default function Layout() { }; 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) useEffect(() => { - if (settings.theme_force_dark === '1') { - setTheme('dark'); - document.documentElement.setAttribute('data-theme', 'dark'); - } + setTheme(settings.theme_force_dark === '1' ? 'dark' : getThemePreference(), { persist: false }); }, [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) useEffect(() => { const body = document.body; diff --git a/frontend/src/theme.jsx b/frontend/src/theme.jsx index 547d6bc..65f66f7 100644 --- a/frontend/src/theme.jsx +++ b/frontend/src/theme.jsx @@ -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" 控制 // (public/css/style.css 使用 [data-theme="dark"] 选择器,非 .dark 类) const THEME_KEY = 'theme'; 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) { if (theme === 'dark') { document.documentElement.setAttribute('data-theme', 'dark'); @@ -14,21 +21,22 @@ function applyTheme(theme) { } export function ThemeProvider({ children }) { - const [theme, setTheme] = useState(() => { - const saved = localStorage.getItem(THEME_KEY); - if (saved === 'dark' || saved === 'light') return saved; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - }); + const [theme, setThemeState] = useState(getThemePreference); + const setTheme = useCallback((nextTheme, { persist = true } = {}) => { + if (nextTheme !== 'dark' && nextTheme !== 'light') return; + if (persist) localStorage.setItem(THEME_KEY, nextTheme); + setThemeState(nextTheme); + }, []); useEffect(() => { applyTheme(theme); - localStorage.setItem(THEME_KEY, theme); + applyThemeTokens({ mode: theme }); }, [theme]); const value = useMemo( () => ({ theme, - toggleTheme: () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')), + toggleTheme: () => setTheme(theme === 'dark' ? 'light' : 'dark'), setTheme, }), [theme] diff --git a/frontend/src/themeTokens.js b/frontend/src/themeTokens.js new file mode 100644 index 0000000..4bdb85b --- /dev/null +++ b/frontend/src/themeTokens.js @@ -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 }; diff --git a/public/css/style.css b/public/css/style.css index a10978c..28ff841 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1,6 +1,14 @@ /* ===== Material Design 3 Theme ===== */ :root { --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 */ --md-ref-primary: #6750a4; --md-ref-on-primary: #ffffff; @@ -10,6 +18,10 @@ --md-ref-on-secondary: #ffffff; --md-ref-secondary-container: #e8def8; --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-on-surface: #1c1b1f; --md-ref-surface-variant: #e7e0ec; @@ -18,6 +30,16 @@ --md-ref-outline-variant: #cac4d0; --md-ref-error: #b3261e; --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-on-background: #1c1b1f; --md-ref-surface-container-low: #f7f2fa; @@ -31,6 +53,7 @@ [data-theme="dark"] { --md-ref-primary: #d0bcff; + --md-ref-primary-rgb: 208, 188, 255; --md-ref-on-primary: #381e72; --md-ref-primary-container: #4f378b; --md-ref-on-primary-container: #eadfff; @@ -38,6 +61,10 @@ --md-ref-on-secondary: #332d41; --md-ref-secondary-container: #4a4458; --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-on-surface: #e6e1e5; --md-ref-surface-variant: #49454f; @@ -46,6 +73,16 @@ --md-ref-outline-variant: #49454f; --md-ref-error: #f2b8b5; --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-on-background: #e6e1e5; --md-ref-surface-container-low: #211f26; @@ -371,7 +408,7 @@ input, select, textarea { input:focus, select:focus, textarea:focus { 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 { @@ -1646,16 +1683,22 @@ table tr:hover td { /* ===== Wallpaper & Glass Effects ===== */ :root { - --glass-bg: rgba(255,251,254,0.65); - --glass-bg-heavy: rgba(255,251,254,0.8); + --glass-opacity: 0.6; + --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-blur: 20px; --wallpaper: none; } [data-theme="dark"] { - --glass-bg: rgba(28,27,31,0.6); - --glass-bg-heavy: rgba(28,27,31,0.78); + --glass-bg: rgb(28 27 31 / var(--glass-opacity)); + --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); } @@ -1734,12 +1777,12 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card { --glass-bg / --glass-bg-heavy 的所有使用者(玻璃卡/导航/表格/弹窗)自动加强; 无壁纸时保持原有半透明质感不受影响 */ body.has-wallpaper { - --glass-bg: rgba(255,251,254,0.82); - --glass-bg-heavy: rgba(255,251,254,0.92); + --glass-bg: var(--glass-bg-wallpaper); + --glass-bg-heavy: var(--glass-bg-heavy-wallpaper); } [data-theme="dark"] body.has-wallpaper { - --glass-bg: rgba(28,27,31,0.74); - --glass-bg-heavy: rgba(28,27,31,0.84); + --glass-bg: var(--glass-bg-wallpaper); + --glass-bg-heavy: var(--glass-bg-heavy-wallpaper); } /* Glass card style */ @@ -1773,34 +1816,34 @@ body.has-wallpaper { /* 壁纸下页脚同源加强(页脚在 .page 之外,单独覆盖) */ 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 { - background: rgba(28, 27, 31, 0.75); + background: var(--glass-bg-heavy); } /* 壁纸下详情页正文卡 / 目录卡加强:阅读面需要更高不透明度(其余卡片由 body.has-wallpaper 的 --glass-bg 变量统一加强) */ body.has-wallpaper .blog-article, 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 .article-toc-side { - background: rgba(28, 27, 31, 0.74); + background: var(--glass-bg-heavy); } /* 壁纸下论坛侧栏玻璃底(B9):仿 .blog-article 的半透明 + 模糊 + 圆角, 保证壁纸下分类文字可读(深色主题用深色底) */ .has-wallpaper .forum-sidebar { - background: rgba(255, 251, 254, 0.5); + background: var(--glass-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: 16px; padding: 12px; } [data-theme="dark"] .has-wallpaper .forum-sidebar { - background: rgba(28, 27, 31, 0.55); + background: var(--glass-bg); } /* Blog waterfall cards with wallpaper */ @@ -2054,12 +2097,12 @@ body.has-wallpaper .article-toc-side { padding: 22px 28px 28px; border-top: 1px solid var(--md-ref-outline-variant); /* 磨砂玻璃:半透明底 + 模糊,壁纸下增强可读性 */ - background: rgba(255, 251, 254, 0.55); + background: var(--glass-bg); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); } [data-theme="dark"] .footer-classic { - background: rgba(28, 27, 31, 0.6); + background: var(--glass-bg); } .footer-classic .fc-left { font-size: 13px; @@ -2073,8 +2116,8 @@ body.has-wallpaper .article-toc-side { border-radius: 50%; margin: 0 10px; vertical-align: middle; - background: linear-gradient(135deg, var(--md-ref-primary), #d0bcff); - box-shadow: 0 0 8px rgba(103, 80, 164, 0.7); + background: var(--theme-primary-gradient); + box-shadow: var(--theme-primary-shadow); } .footer-classic .fc-right { display: inline-flex; @@ -2099,13 +2142,13 @@ body.has-wallpaper .article-toc-side { /* ---- 分栏导航 ---- */ .footer-columns { /* 磨砂玻璃:半透明底 + 模糊,壁纸下增强可读性 */ - background: rgba(255, 251, 254, 0.55); + background: var(--glass-bg); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-top: 1px solid var(--md-ref-outline-variant); } [data-theme="dark"] .footer-columns { - background: rgba(28, 27, 31, 0.6); + background: var(--glass-bg); } .footer-columns .fc-grid { display: grid; @@ -2179,8 +2222,8 @@ body.has-wallpaper .article-toc-side { border-radius: 50%; margin: 0 10px; vertical-align: middle; - background: linear-gradient(135deg, var(--md-ref-primary), #d0bcff); - box-shadow: 0 0 8px rgba(103, 80, 164, 0.7); + background: var(--theme-primary-gradient); + box-shadow: var(--theme-primary-shadow); } /* ---- 玻璃卡片 ---- */ @@ -2287,7 +2330,7 @@ body.has-wallpaper .article-toc-side { } .search-box:focus-within { 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 input { @@ -2630,7 +2673,7 @@ body.has-wallpaper .article-toc-side { gap: 6px 10px; padding: 8px 22px; 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); font-weight: 600; letter-spacing: 0.6px; @@ -2640,19 +2683,19 @@ body.has-wallpaper .article-toc-side { } .copyright-glow b, .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; background-clip: text; color: transparent; } @keyframes copyrightGlow { 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); } 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); - border-color: rgba(208, 188, 255, 0.5); + 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: color-mix(in srgb, var(--md-ref-primary-container) 50%, transparent); } } @@ -2668,12 +2711,12 @@ body.has-wallpaper .article-toc-side { font-weight: 700; letter-spacing: 1.2px; font-size: 13px; - color: #fff; - text-shadow: 0 0 10px rgba(208, 188, 255, 0.8), 0 0 20px rgba(103, 80, 164, 0.5); - background: linear-gradient(135deg, #6750a4, #7c4dff 40%, #b388ff 70%, #ff8fab); + color: var(--md-ref-on-primary); + 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: var(--theme-primary-gradient-wide); background-size: 200% 200%; 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 { content: ''; @@ -2681,7 +2724,7 @@ body.has-wallpaper .article-toc-side { inset: 0; border-radius: inherit; 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-composite: xor; mask-composite: exclude; @@ -2703,8 +2746,8 @@ body.has-wallpaper .article-toc-side { 50% { background-position: 100% 50%; } } @keyframes poweredPulse { - 0%, 100% { box-shadow: 0 0 16px rgba(124, 77, 255, 0.45), 0 0 36px rgba(103, 80, 164, 0.25); } - 50% { box-shadow: 0 0 24px rgba(124, 77, 255, 0.7), 0 0 56px rgba(103, 80, 164, 0.4); } + 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 color-mix(in srgb, var(--md-ref-primary) 70%, transparent), 0 0 56px rgba(var(--md-ref-primary-rgb), 0.4); } } @keyframes poweredBorder { 0% { filter: hue-rotate(0deg); opacity: 0.7; } @@ -2718,14 +2761,14 @@ body.has-wallpaper .article-toc-side { /* ===== 博客文章详情页壁纸下磨砂背景(增强可读性) ===== */ .blog-article { - background: rgba(255, 251, 254, 0.5); + background: var(--glass-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border-radius: 16px; padding: 24px; } [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 */ max-height: calc(100vh - 120px); overflow-y: auto; - background: rgba(255, 251, 254, 0.55); + background: var(--glass-bg); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); 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); } [data-theme="dark"] .article-toc-side { - background: rgba(28, 27, 31, 0.6); + background: var(--glass-bg); } .article-toc-side .toc-title { display: flex; diff --git a/routes/settings.js b/routes/settings.js index 7787072..cd31aac 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -4,6 +4,11 @@ const { authMiddleware, adminOnly } = require('../middleware/auth'); 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', 'recaptcha_site_key','turnstile_site_key', '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 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}]}。 // url 白名单:站内相对路径以 / 开头(排除 // 协议相对),或 http(s):// 外链;拒绝 javascript:/data:/vbscript: 等危险协议。 // 返回解析后的数组,非法返回 null。 @@ -71,6 +120,9 @@ router.get('/', 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 不落库(空串表示回退硬编码,放行) if (req.body.footer_columns !== undefined && 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)) { - 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: '设置已保存' }); });