统一前后台主题颜色管理
This commit is contained in:
@@ -43,6 +43,7 @@ node cli.js <cmd> # 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"` 属性在 `<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/白屏。
|
||||
- 博文/帖子内容中的 `[image:文件名]` / `[file:文件名]` 标签:前台由 `MarkdownRenderer`(marked + DOMPurify 净化)、SEO 页由 `ssr.js` 渲染为 `/uploads/` 链接。**所有 markdown 渲染必须过 DOMPurify**。
|
||||
- 登录态:token 存 localStorage(key `token`),变更通过 `authchange` 事件通知 Layout 刷新(`api/client.js` 的 `notifyAuthChange`)。
|
||||
|
||||
@@ -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 (
|
||||
<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 (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
|
||||
@@ -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() {
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>主题色</Typography>
|
||||
<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' }} />
|
||||
<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>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+16
-8
@@ -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]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
+54
-1
@@ -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: '设置已保存' });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user