Files
deerflow-code/frontend-web/src/contexts/ThemeContext.tsx
T

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(' ');
};