import { Accordion, AccordionItem } from '@heroui/accordion'; import { Button } from '@heroui/button'; import { Card, CardBody, CardHeader } from '@heroui/card'; import { Select, SelectItem } from '@heroui/select'; import { Chip } from '@heroui/chip'; 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); // 使用 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); }, [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 ( <> 主题配置 - NapCat WebUI {/* 顶部操作栏 */}
当前主题: {savedThemeName || '加载中...'}
字体: {savedFontModeDisplayName}
{hasUnsavedChanges && ( 有未保存的更改 )}
} >
( )} />
上传自定义字体(仅在选择"自定义字体"时生效)
{ try { await FileManager.uploadWebUIFont(file); toast.success('上传成功,即将刷新页面'); setTimeout(() => { window.location.reload(); }, 1000); } catch (error) { toast.error('上传失败: ' + (error as Error).message); } }} onDelete={async () => { try { await FileManager.deleteWebUIFont(); 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' ? '深色模式' : '浅色模式'}

{colorKeys.map((colorKey) => (
{ const hslArray = value?.split(' ') ?? [0, 0, 0]; const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`; return ( { onChange( `${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%` ); }} /> ); }} /> {colorKey.replace('--heroui-', '')}
))}
))}
); }; export default ThemeConfigCard;