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

423 lines
13 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';
import {
BUILTIN_THEME_IDS,
type BuiltinThemeId,
type CustomThemePreset,
} from '@/core/system-settings/api';
import { getCustomThemeTokens, isCustomThemeId } from '@/core/theme/custom-theme';
import { isPositionRoundtableStandaloneMode } from '@/position-roundtable/demo/position-roundtable-demo-config';
// 主题类型
export type ThemeType = BuiltinThemeId | `custom-theme-${string}`;
// 主题配置
export interface ThemeConfig {
type: BuiltinThemeId;
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<BuiltinThemeId, 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',
},
},
iceblue: {
type: 'iceblue',
name: '冰川蓝主题',
colors: {
background: 'sky-50',
backgroundSecondary: 'sky-100',
text: 'slate-800',
textSecondary: 'slate-600',
// The CSS --primary token is exactly #9BCEF1. Darker blues are used
// only for readable text, hover and focus states.
primary: 'sky-200',
primaryHover: 'sky-300',
border: 'sky-200',
accent: 'sky-200',
success: 'emerald-600',
warning: 'amber-600',
error: 'red-600',
card: 'white',
cardHover: 'sky-50',
input: 'white',
inputBorder: 'sky-200',
overlay: 'slate-900/30',
},
},
lightyellow: {
type: 'lightyellow',
name: '浅黄色主题',
colors: {
background: 'lime-50',
backgroundSecondary: 'lime-100',
text: 'lime-950',
textSecondary: 'lime-800',
primary: 'lime-100',
primaryHover: 'lime-200',
border: 'lime-200',
accent: 'lime-100',
success: 'emerald-600',
warning: 'amber-600',
error: 'red-600',
card: 'white',
cardHover: 'lime-50',
input: 'white',
inputBorder: 'lime-200',
overlay: 'slate-900/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';
const CUSTOM_THEME_TOKEN_NAMES = Object.keys(
getCustomThemeTokens({ primary: '#0062D9', mode: 'light' }),
);
function isBuiltinTheme(value: string | null | undefined): value is BuiltinThemeId {
return typeof value === 'string' && (BUILTIN_THEME_IDS as readonly string[]).includes(value);
}
function getEnabledCustomTheme(
id: string | null | undefined,
presets: CustomThemePreset[] | undefined,
): CustomThemePreset | undefined {
return isCustomThemeId(id)
? presets?.find((preset) => preset.id === id && preset.enabled)
: undefined;
}
function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') {
return 'light';
}
const saved = localStorage.getItem(STORAGE_KEY);
if (isBuiltinTheme(saved)) return saved;
if (isCustomThemeId(saved)) return saved;
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;
/** Resolved visual mode; custom themes keep their own light/dark setting. */
baseMode: 'light' | 'dark';
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({
enabled: !isPositionRoundtableStandaloneMode(),
});
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 && !isBuiltinTheme(current) && !isCustomThemeId(current)) {
// 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 persistedTheme = readPersistedTheme();
const requestedTheme = (mounted ? effectiveTheme : persistedTheme) ?? 'light';
const customTheme = getEnabledCustomTheme(
requestedTheme,
appearance?.custom_themes,
);
const theme: ThemeType = isBuiltinTheme(requestedTheme)
? requestedTheme
: customTheme?.id ?? 'light';
const baseMode: 'light' | 'dark' = customTheme?.mode ?? (theme === 'dark' ? 'dark' : 'light');
useEffect(() => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// next-themes adds a class for every value passed to setTheme(). Custom
// themes use data attributes and CSS variables instead, so remove its
// transient class before applying the generated token set.
for (const className of [...root.classList]) {
if (isCustomThemeId(className)) {
root.classList.remove(className);
}
}
for (const tokenName of CUSTOM_THEME_TOKEN_NAMES) {
root.style.removeProperty(tokenName);
}
root.removeAttribute('data-custom-theme');
if (customTheme) {
for (const [tokenName, tokenValue] of Object.entries(
getCustomThemeTokens(customTheme),
)) {
root.style.setProperty(tokenName, tokenValue);
}
root.setAttribute('data-theme', customTheme.id);
root.setAttribute('data-custom-theme', 'true');
root.setAttribute('theme-mode', customTheme.mode);
root.classList.toggle('dark', customTheme.mode === 'dark');
return;
}
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' ||
theme === 'iceblue' ||
theme === 'lightyellow'
) {
root.setAttribute('data-theme', theme);
} else {
root.removeAttribute('data-theme');
}
}, [customTheme, theme]);
const toggleTheme = () => {
setNextTheme(theme === 'dark' ? 'light' : 'dark');
markExplicitChoice();
};
const setTheme = (newTheme: ThemeType) => {
setNextTheme(newTheme);
markExplicitChoice();
};
const themeConfig = themes[isBuiltinTheme(theme) ? theme : 'light'];
return (
<ThemeContext.Provider
value={{ theme, baseMode, 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];
};
/** Resolve custom runtime themes to the two visual modes expected by older
* integrations (reports, canvases and exported artifacts). */
export function themeToBaseMode(theme: ThemeType): 'light' | 'dark' {
if (theme === 'dark') return 'dark';
if (
isCustomThemeId(theme) &&
typeof document !== 'undefined' &&
document.documentElement.getAttribute('theme-mode') === 'dark'
) {
return 'dark';
}
return 'light';
}
// 组合类名工具函数
export const cn = (...classes: (string | undefined | null | false)[]): string => {
return classes.filter(Boolean).join(' ');
};