import { Button } from '@heroui/button'; import { Card, CardBody, CardHeader } from '@heroui/card'; import { Select, SelectItem } from '@heroui/select'; import { Chip } from '@heroui/chip'; import { Tab, Tabs } from '@heroui/tabs'; import { useRequest } from 'ahooks'; import clsx from 'clsx'; import { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { Controller, useForm, useWatch } from 'react-hook-form'; import toast from 'react-hot-toast'; import { FaFont, FaUserAstronaut, FaCheck } from 'react-icons/fa'; import { FaPaintbrush } from 'react-icons/fa6'; import { IoIosColorPalette, IoMdRefresh } from 'react-icons/io'; import { MdDarkMode, MdLightMode } from 'react-icons/md'; import themes from '@/const/themes'; import ColorPicker from '@/components/ColorPicker'; import FileInput from '@/components/input/file_input'; import PageLoading from '@/components/page_loading'; import FileManager from '@/controllers/file_manager'; import { applyFont, colorKeys, generateTheme, loadTheme, updateFontCache } from '@/utils/theme'; import WebUIManager from '@/controllers/webui_manager'; export type PreviewThemeCardProps = { theme: ThemeInfo; onPreview: () => void; isSelected?: boolean; }; const values = [ '', '-50', '-100', '-200', '-300', '-400', '-500', '-600', '-700', '-800', '-900', ]; const colors = [ 'primary', 'secondary', 'success', 'danger', 'warning', 'default', ]; function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardProps) { const style = document.createElement('style'); style.innerHTML = generateTheme(theme.theme, theme.name); const cardRef = useRef(null); useEffect(() => { document.head.appendChild(style); return () => { document.head.removeChild(style); }; }, []); return ( {isSelected && (
)}
{theme.name}
{theme.author ?? '未知'}
{theme.description}
{colors.map((color) => (
{color[0].toUpperCase()}
{values.map((value) => (
))}
))}
); } // 比较两个主题配置是否相同(不比较 fontMode) const isThemeColorsEqual = (a: ThemeConfig, b: ThemeConfig): boolean => { if (!a || !b) return false; const aKeys = [...Object.keys(a.light || {}), ...Object.keys(a.dark || {})]; const bKeys = [...Object.keys(b.light || {}), ...Object.keys(b.dark || {})]; if (aKeys.length !== bKeys.length) return false; for (const key of Object.keys(a.light || {})) { if (a.light?.[key as keyof ThemeConfigItem] !== b.light?.[key as keyof ThemeConfigItem]) return false; } for (const key of Object.keys(a.dark || {})) { if (a.dark?.[key as keyof ThemeConfigItem] !== b.dark?.[key as keyof ThemeConfigItem]) return false; } return true; }; // 字体模式显示名称映射 const fontModeNames: Record = { aacute: 'Aa 偷吃可爱长大的', system: '系统默认', custom: '自定义字体', }; const ThemeConfigCard = () => { const { data, loading, error, refreshAsync } = useRequest( WebUIManager.getThemeConfig ); const { control, handleSubmit: handleOnebotSubmit, formState: { isSubmitting }, setValue: setOnebotValue, } = useForm<{ theme: ThemeConfig; }>({ defaultValues: { theme: { dark: {}, light: {}, fontMode: 'aacute', }, }, }); const [dataLoaded, setDataLoaded] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [customFontExists, setCustomFontExists] = useState(false); // 使用 useRef 存储 style 标签引用和状态 const styleTagRef = useRef(null); const originalDataRef = useRef(null); const hasUnsavedChangesRef = useRef(false); // 同步 hasUnsavedChanges 到 ref,供 cleanup 函数使用 useEffect(() => { hasUnsavedChangesRef.current = hasUnsavedChanges; }, [hasUnsavedChanges]); // 在组件挂载时创建 style 标签,并在卸载时清理 // 同时在卸载时恢复字体到已保存的状态(避免"伪自动保存"问题) useEffect(() => { const styleTag = document.createElement('style'); document.head.appendChild(styleTag); styleTagRef.current = styleTag; return () => { // 组件卸载时,只有在有未保存更改时才恢复到已保存的字体设置 // 避免在刷新页面后字体被意外重置 if (hasUnsavedChangesRef.current && originalDataRef.current?.fontMode) { applyFont(originalDataRef.current.fontMode); } if (styleTagRef.current) { document.head.removeChild(styleTagRef.current); } }; }, []); const theme = useWatch({ control, name: 'theme' }); // 检测是否有未保存的更改 useEffect(() => { if (originalDataRef.current && dataLoaded) { const colorsChanged = !isThemeColorsEqual(theme, originalDataRef.current); const fontChanged = theme.fontMode !== originalDataRef.current.fontMode; setHasUnsavedChanges(colorsChanged || fontChanged); } }, [theme, dataLoaded]); const reset = useCallback(() => { if (data) { setOnebotValue('theme', data); originalDataRef.current = data; // 应用已保存的字体设置 if (data.fontMode) { applyFont(data.fontMode); } } setDataLoaded(true); setHasUnsavedChanges(false); // 检查自定义字体是否存在 FileManager.checkWebUIFontExists().then(exists => { setCustomFontExists(exists); }).catch(err => console.error('Failed to check custom font:', err)); }, [data, setOnebotValue]); // 实时应用字体预设(预览) useEffect(() => { if (dataLoaded && theme.fontMode) { applyFont(theme.fontMode); } }, [theme.fontMode, dataLoaded]); const onSubmit = handleOnebotSubmit(async (formData) => { try { await WebUIManager.setThemeConfig(formData.theme); // 更新原始数据引用 originalDataRef.current = formData.theme; // 更新字体缓存 if (formData.theme.fontMode) { updateFontCache(formData.theme.fontMode); } setHasUnsavedChanges(false); toast.success('保存成功'); loadTheme(); } catch (error) { const msg = (error as Error).message; toast.error(`保存失败: ${msg}`); } }); const onRefresh = async () => { try { await refreshAsync(); toast.success('刷新成功'); } catch (error) { const msg = (error as Error).message; toast.error(`刷新失败: ${msg}`); } }; useEffect(() => { reset(); }, [data, reset]); useEffect(() => { if (theme && styleTagRef.current) { const css = generateTheme(theme); styleTagRef.current.innerHTML = css; } }, [theme]); // 找到当前选中的主题(预览中的) const selectedThemeName = useMemo(() => { return themes.find(t => isThemeColorsEqual(t.theme, theme))?.name; }, [theme]); // 找到已保存的主题名称 const savedThemeName = useMemo(() => { const savedData = originalDataRef.current || data; if (!savedData) return null; return themes.find(t => isThemeColorsEqual(t.theme, savedData))?.name || '自定义'; }, [data, dataLoaded, hasUnsavedChanges]); // 已保存的字体模式显示名称 const savedFontModeDisplayName = useMemo(() => { const savedData = originalDataRef.current || data; const mode = savedData?.fontMode || 'aacute'; return fontModeNames[mode] || mode; }, [data, dataLoaded, hasUnsavedChanges]); if (loading) { return (
); } if (error) { return (
{error.message}
); } return ( <> 主题配置 - SnowCat WebUI {/* 顶部操作栏 */}

外观设置

{savedThemeName || '加载中...'}
{savedFontModeDisplayName}
{hasUnsavedChanges && ( <>
待保存
)}
字体设置
} >

WebUI 字体

自定义界面显示的字体风格

( )} />
{theme.fontMode === 'custom' && (
自定义字体文件
{customFontExists && ( }> 已上传 )}
{ try { if (customFontExists) { try { await FileManager.deleteWebUIFont(); } catch (e) { console.warn('Failed to delete existing font before upload:', e); } } await FileManager.uploadWebUIFont(file); // 先自动把选择的字体模式设置为 custom,并保存配置,避免刷新后又变成了旧的/没保存的模式 const updatedTheme = { ...theme, fontMode: 'custom' }; await WebUIManager.setThemeConfig(updatedTheme); originalDataRef.current = updatedTheme; updateFontCache('custom'); setHasUnsavedChanges(false); loadTheme(); toast.success('上传并保存成功,即将刷新页面'); setTimeout(() => window.location.reload(), 1000); } catch (error) { toast.error('上传或保存失败: ' + (error as Error).message); } }} onDelete={async () => { try { await FileManager.deleteWebUIFont(); // 删除字体后,若当前选中的是自定义字体,则将其重置为系统默认(或其它)并保存,以防页面报错 if (theme.fontMode === 'custom') { const updatedTheme = { ...theme, fontMode: 'system' }; await WebUIManager.setThemeConfig(updatedTheme); originalDataRef.current = updatedTheme; updateFontCache('system'); setHasUnsavedChanges(false); loadTheme(); } toast.success('删除成功,即将刷新页面'); setTimeout(() => window.location.reload(), 1000); } catch (error) { toast.error('删除失败: ' + (error as Error).message); } }} />

注意:上传新字体会覆盖旧字体文件,更改后需要刷新页面生效。

)}
选择主题
} >
{themes.map((t) => ( { setOnebotValue('theme', { ...t.theme, fontMode: theme.fontMode }); }} /> ))}
自定义配色
} >
{(['light', 'dark'] as const).map((mode) => (
{mode === 'dark' ? : }

{mode === 'dark' ? '深色模式' : '浅色模式'}

调整{mode === 'dark' ? '深色' : '浅色'}主题下的颜色变量

{colorKeys.map((colorKey) => (
{ const hslArray = value?.split(' ') ?? [0, 0, 0]; const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`; return ( { const match = hslString.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/); if (match) { onChange(`${match[1]} ${match[2]}% ${match[3]}%`); } }} /> ); }} />
{colorKey.replace('--heroui-', '')} Variable
))}
))}
); }; export default ThemeConfigCard;