310 lines
8.7 KiB
TypeScript
310 lines
8.7 KiB
TypeScript
import React, {
|
|
createContext,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { useTheme as useNextTheme } from 'next-themes';
|
|
|
|
import { useAppearanceSettings } from '@/core/system-settings/hooks';
|
|
|
|
// 主题类型
|
|
export type ThemeType = 'dark' | 'light' | 'lightblue' | 'lightgreen';
|
|
|
|
// 主题配置
|
|
export interface ThemeConfig {
|
|
type: ThemeType;
|
|
name: string;
|
|
colors: {
|
|
background: string;
|
|
backgroundSecondary: string;
|
|
text: string;
|
|
textSecondary: string;
|
|
primary: string;
|
|
primaryHover: string;
|
|
border: string;
|
|
accent: string;
|
|
success: string;
|
|
warning: string;
|
|
error: string;
|
|
card: string;
|
|
cardHover: string;
|
|
input: string;
|
|
inputBorder: string;
|
|
overlay: string;
|
|
};
|
|
}
|
|
|
|
// 主题配置
|
|
const themes: Record<ThemeType, ThemeConfig> = {
|
|
dark: {
|
|
type: 'dark',
|
|
name: '深色主题',
|
|
colors: {
|
|
background: 'slate-900',
|
|
backgroundSecondary: 'slate-800',
|
|
text: 'white',
|
|
textSecondary: 'slate-400',
|
|
primary: 'blue-500',
|
|
primaryHover: 'blue-400',
|
|
border: 'blue-800',
|
|
accent: 'cyan-400',
|
|
success: 'emerald-500',
|
|
warning: 'amber-500',
|
|
error: 'rose-500',
|
|
card: 'slate-800',
|
|
cardHover: 'slate-700',
|
|
input: 'slate-800',
|
|
inputBorder: 'blue-800',
|
|
overlay: 'slate-900/50',
|
|
},
|
|
},
|
|
light: {
|
|
type: 'light',
|
|
name: '红白主题',
|
|
colors: {
|
|
background: 'white',
|
|
backgroundSecondary: 'red-50',
|
|
text: 'gray-900',
|
|
textSecondary: 'gray-600',
|
|
primary: 'red-600',
|
|
primaryHover: 'red-500',
|
|
border: 'red-200',
|
|
accent: 'red-700',
|
|
success: 'green-600',
|
|
warning: 'amber-500',
|
|
error: 'red-600',
|
|
card: 'white',
|
|
cardHover: 'red-50',
|
|
input: 'white',
|
|
inputBorder: 'red-200',
|
|
overlay: 'black/30',
|
|
},
|
|
},
|
|
lightblue: {
|
|
type: 'lightblue',
|
|
name: '浅蓝主题',
|
|
colors: {
|
|
background: 'white',
|
|
backgroundSecondary: 'blue-50',
|
|
text: 'gray-800',
|
|
textSecondary: 'gray-500',
|
|
primary: 'blue-600',
|
|
primaryHover: 'blue-500',
|
|
border: 'blue-200',
|
|
accent: 'blue-700',
|
|
success: 'emerald-600',
|
|
warning: 'amber-500',
|
|
error: 'red-600',
|
|
card: 'white',
|
|
cardHover: 'blue-50',
|
|
input: 'white',
|
|
inputBorder: 'blue-200',
|
|
overlay: 'black/30',
|
|
},
|
|
},
|
|
lightgreen: {
|
|
type: 'lightgreen',
|
|
name: '绿色主题',
|
|
colors: {
|
|
background: 'white',
|
|
backgroundSecondary: 'emerald-50',
|
|
text: 'emerald-950',
|
|
textSecondary: 'emerald-800',
|
|
primary: 'emerald-600',
|
|
primaryHover: 'emerald-500',
|
|
border: 'emerald-200',
|
|
accent: 'emerald-700',
|
|
success: 'emerald-600',
|
|
warning: 'amber-500',
|
|
error: 'red-600',
|
|
card: 'white',
|
|
cardHover: 'emerald-50',
|
|
input: 'white',
|
|
inputBorder: 'emerald-200',
|
|
overlay: 'black/30',
|
|
},
|
|
},
|
|
};
|
|
|
|
const STORAGE_KEY = 'strategy-theme';
|
|
// Marker that the user has explicitly toggled the theme (via the appearance
|
|
// settings page or any future toggle UI). When this flag is absent we treat
|
|
// any value stored in ``strategy-theme`` as one we put there ourselves while
|
|
// syncing the admin's default — so we'll re-sync on every boot, picking up
|
|
// admin changes. When the flag is present we never override the user.
|
|
const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
|
|
|
|
function readPersistedTheme(): ThemeType {
|
|
if (typeof window === 'undefined') {
|
|
return 'light';
|
|
}
|
|
const saved = localStorage.getItem(STORAGE_KEY);
|
|
if (saved === 'dark') return 'dark';
|
|
if (saved === 'lightblue') return 'lightblue';
|
|
if (saved === 'lightgreen') return 'lightgreen';
|
|
return 'light';
|
|
}
|
|
|
|
function hasExplicitChoice(): boolean {
|
|
if (typeof window === 'undefined') return false;
|
|
return localStorage.getItem(EXPLICIT_FLAG_KEY) === '1';
|
|
}
|
|
|
|
function markExplicitChoice(): void {
|
|
if (typeof window === 'undefined') return;
|
|
localStorage.setItem(EXPLICIT_FLAG_KEY, '1');
|
|
}
|
|
|
|
// 主题上下文
|
|
interface ThemeContextType {
|
|
theme: ThemeType;
|
|
themeConfig: ThemeConfig;
|
|
toggleTheme: () => void;
|
|
setTheme: (theme: ThemeType) => void;
|
|
/** next-themes 完成挂载前为 false,避免 hydration 与 DOM 不同步 */
|
|
mounted: boolean;
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
|
|
// 主题提供者
|
|
interface ThemeProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|
// forcedTheme:嵌入路由 / landing 由 AppProviders 强制的主题。强制期间
|
|
// 它决定渲染,且本组件不得写 localStorage(嵌入子应用与主站同域共享
|
|
// 存储,写入会覆盖用户在主站的选择)。
|
|
const { resolvedTheme, forcedTheme, setTheme: setNextTheme } = useNextTheme();
|
|
const [mounted, setMounted] = useState(false);
|
|
// Admin-configured global default. If the user hasn't explicitly chosen
|
|
// a theme yet, this value drives the rendered theme — and re-syncs on
|
|
// every mount so an admin change propagates without users clearing
|
|
// localStorage by hand.
|
|
const { data: appearance } = useAppearanceSettings();
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
// 迁移 / 规范化 localStorage:去掉旧的 'theme' 键,并清掉一切非法值。
|
|
// 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
|
|
// 「用户已选过」,admin 默认就再也注入不进来了。
|
|
useEffect(() => {
|
|
if (!mounted || forcedTheme) return;
|
|
const current = localStorage.getItem(STORAGE_KEY);
|
|
const legacy = localStorage.getItem('theme');
|
|
|
|
if (
|
|
current &&
|
|
current !== 'light' &&
|
|
current !== 'dark' &&
|
|
current !== 'lightblue' &&
|
|
current !== 'lightgreen'
|
|
) {
|
|
// Garbage value — wipe it so the boot-sync below can take over.
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
}
|
|
if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
|
// Migrate the legacy key as the user's explicit choice (it was set
|
|
// long enough ago that we should preserve their pick rather than
|
|
// override with admin default).
|
|
setNextTheme(legacy);
|
|
markExplicitChoice();
|
|
}
|
|
if (legacy) {
|
|
localStorage.removeItem('theme');
|
|
}
|
|
}, [mounted, forcedTheme, setNextTheme]);
|
|
|
|
// Boot-time sync: when the user has never picked a theme themselves,
|
|
// apply whatever the admin configured. Re-runs whenever the admin
|
|
// setting changes (so a logged-in user sees the new default after
|
|
// refreshing without having to clear cookies).
|
|
useEffect(() => {
|
|
if (!mounted || forcedTheme) return;
|
|
if (hasExplicitChoice()) return;
|
|
const adminDefault = appearance?.default_theme;
|
|
if (!adminDefault) return;
|
|
if (resolvedTheme !== adminDefault) {
|
|
setNextTheme(adminDefault);
|
|
}
|
|
}, [mounted, forcedTheme, appearance?.default_theme, resolvedTheme, setNextTheme]);
|
|
|
|
const effectiveTheme = forcedTheme ?? resolvedTheme;
|
|
const theme: ThemeType = mounted
|
|
? effectiveTheme === 'dark'
|
|
? 'dark'
|
|
: effectiveTheme === 'lightblue'
|
|
? 'lightblue'
|
|
: effectiveTheme === 'lightgreen'
|
|
? 'lightgreen'
|
|
: 'light'
|
|
: readPersistedTheme();
|
|
|
|
useEffect(() => {
|
|
if (typeof document === 'undefined') return;
|
|
|
|
const root = document.documentElement;
|
|
|
|
if (theme === 'dark') {
|
|
root.setAttribute('theme-mode', 'dark');
|
|
root.classList.add('dark');
|
|
root.removeAttribute('data-theme');
|
|
return;
|
|
}
|
|
|
|
root.removeAttribute('theme-mode');
|
|
root.classList.remove('dark');
|
|
|
|
if (theme === 'lightblue' || theme === 'lightgreen') {
|
|
root.setAttribute('data-theme', theme);
|
|
} else {
|
|
root.removeAttribute('data-theme');
|
|
}
|
|
}, [theme]);
|
|
|
|
const toggleTheme = () => {
|
|
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
|
markExplicitChoice();
|
|
};
|
|
|
|
const setTheme = (newTheme: ThemeType) => {
|
|
setNextTheme(newTheme);
|
|
markExplicitChoice();
|
|
};
|
|
|
|
const themeConfig = themes[theme];
|
|
|
|
return (
|
|
<ThemeContext.Provider
|
|
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }}
|
|
>
|
|
{children}
|
|
</ThemeContext.Provider>
|
|
);
|
|
};
|
|
|
|
// 使用主题的 Hook
|
|
export const useTheme = (): ThemeContextType => {
|
|
const context = useContext(ThemeContext);
|
|
if (!context) {
|
|
throw new Error('useTheme must be used within a ThemeProvider');
|
|
}
|
|
return context;
|
|
};
|
|
|
|
// 主题工具函数 - 生成动态类名
|
|
export const getThemeClasses = (colorKey: keyof ThemeConfig['colors']): string => {
|
|
const { themeConfig } = useTheme();
|
|
return themeConfig.colors[colorKey];
|
|
};
|
|
|
|
// 组合类名工具函数
|
|
export const cn = (...classes: (string | undefined | null | false)[]): string => {
|
|
return classes.filter(Boolean).join(' ');
|
|
};
|