423 lines
13 KiB
TypeScript
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(' ');
|
|
};
|