统一前后台主题颜色管理
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user