Refactor font handling and theme config, switch to CodeMirror editor

Replaces Monaco editor with CodeMirror in the frontend, removing related dependencies and configuration. Refactors font management to support multiple formats (woff, woff2, ttf, otf) and dynamic font switching, including backend API and frontend theme config UI. Adds gzip compression middleware to backend. Updates theme config to allow font selection and custom font upload, and improves theme preview and color customization UI. Cleans up unused code and improves sidebar and terminal font sizing responsiveness.
This commit is contained in:
手瓜一十雪
2025-12-24 18:02:54 +08:00
parent 5a5ae5a21d
commit 8ca55bb2ff
26 changed files with 1678 additions and 499 deletions

View File

@@ -1,28 +1,34 @@
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 } from 'react';
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import toast from 'react-hot-toast';
import { FaUserAstronaut } from 'react-icons/fa';
import { FaFont, FaUserAstronaut, FaCheck } from 'react-icons/fa';
import { FaPaintbrush } from 'react-icons/fa6';
import { IoIosColorPalette } from 'react-icons/io';
import { MdDarkMode, MdLightMode } from 'react-icons/md';
import { IoMdRefresh } from 'react-icons/io';
import themes from '@/const/themes';
import ColorPicker from '@/components/ColorPicker';
import SaveButtons from '@/components/button/save_buttons';
import FileInput from '@/components/input/file_input';
import PageLoading from '@/components/page_loading';
import { colorKeys, generateTheme, loadTheme } from '@/utils/theme';
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 = [
@@ -47,7 +53,7 @@ const colors = [
'default',
];
function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
function PreviewThemeCard ({ theme, onPreview, isSelected }: PreviewThemeCardProps) {
const style = document.createElement('style');
style.innerHTML = generateTheme(theme.theme, theme.name);
const cardRef = useRef<HTMLDivElement>(null);
@@ -64,8 +70,19 @@ function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
radius='sm'
isPressable
onPress={onPreview}
className={clsx('text-primary bg-primary-50', theme.name)}
className={clsx(
'text-primary bg-primary-50 relative transition-all',
theme.name,
isSelected && 'ring-2 ring-primary ring-offset-2'
)}
>
{isSelected && (
<div className="absolute top-1 right-1 z-10">
<Chip size="sm" color="primary" variant="solid">
<FaCheck size={10} />
</Chip>
</div>
)}
<CardHeader className='pb-0 flex flex-col items-start gap-1'>
<div className='px-1 rounded-md bg-primary text-primary-foreground'>
{theme.name}
@@ -100,6 +117,29 @@ function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
);
}
// 比较两个主题配置是否相同(不比较 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<string, string> = {
'aacute': 'Aa 偷吃可爱长大的',
'system': '系统默认',
'custom': '自定义字体',
};
const ThemeConfigCard = () => {
const { data, loading, error, refreshAsync } = useRequest(
WebUIManager.getThemeConfig
@@ -116,12 +156,17 @@ const ThemeConfigCard = () => {
theme: {
dark: {},
light: {},
fontMode: 'aacute',
},
},
});
const [dataLoaded, setDataLoaded] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// 使用 useRef 存储 style 标签引用
const styleTagRef = useRef<HTMLStyleElement | null>(null);
const originalDataRef = useRef<ThemeConfig | null>(null);
// 在组件挂载时创建 style 标签,并在卸载时清理
useEffect(() => {
@@ -137,13 +182,45 @@ const ThemeConfigCard = () => {
const theme = useWatch({ control, name: 'theme' });
const reset = () => {
if (data) setOnebotValue('theme', data);
};
// 检测是否有未保存的更改
useEffect(() => {
if (originalDataRef.current && dataLoaded) {
const colorsChanged = !isThemeColorsEqual(theme, originalDataRef.current);
const fontChanged = theme.fontMode !== originalDataRef.current.fontMode;
setHasUnsavedChanges(colorsChanged || fontChanged);
}
}, [theme, dataLoaded]);
const onSubmit = handleOnebotSubmit(async (data) => {
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(data.theme);
await WebUIManager.setThemeConfig(formData.theme);
// 更新原始数据引用
originalDataRef.current = formData.theme;
// 更新字体缓存
if (formData.theme.fontMode) {
updateFontCache(formData.theme.fontMode);
}
setHasUnsavedChanges(false);
toast.success('保存成功');
loadTheme();
} catch (error) {
@@ -164,7 +241,7 @@ const ThemeConfigCard = () => {
useEffect(() => {
reset();
}, [data]);
}, [data, reset]);
useEffect(() => {
if (theme && styleTagRef.current) {
@@ -173,6 +250,25 @@ const ThemeConfigCard = () => {
}
}, [theme]);
// 找到当前选中的主题(预览中的)
const selectedThemeName = useMemo(() => {
return themes.find(t => isThemeColorsEqual(t.theme, theme))?.name;
}, [theme]);
// 找到已保存的主题名称
const savedThemeName = useMemo(() => {
if (!originalDataRef.current) return null;
return themes.find(t => isThemeColorsEqual(t.theme, originalDataRef.current!))?.name || '自定义';
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataLoaded, hasUnsavedChanges]);
// 已保存的字体模式显示名称
const savedFontModeDisplayName = useMemo(() => {
const mode = originalDataRef.current?.fontMode || 'aacute';
return fontModeNames[mode] || mode;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataLoaded, hasUnsavedChanges]);
if (loading) return <PageLoading loading />;
if (error) {
@@ -185,96 +281,209 @@ const ThemeConfigCard = () => {
<>
<title> - NapCat WebUI</title>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
className='items-end w-full p-4'
/>
<div className='px-4 text-sm text-default-600'></div>
<Accordion variant='splitted' defaultExpandedKeys={['select']}>
<AccordionItem
key='select'
aria-label='Pick Color'
title='选择主题'
subtitle='可以切换夜间/白昼模式查看对应颜色'
className='shadow-small'
startContent={<IoIosColorPalette />}
>
<div className='flex flex-wrap gap-2'>
{themes.map((theme) => (
<PreviewThemeCard
key={theme.name}
theme={theme}
onPreview={() => {
setOnebotValue('theme', theme.theme);
}}
/>
))}
{/* 顶部操作栏 */}
<div className="sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 text-sm">
<span className="text-default-400">:</span>
<Chip size="sm" color="primary" variant="flat">
{savedThemeName || '加载中...'}
</Chip>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-default-400">:</span>
<Chip size="sm" color="secondary" variant="flat">
{savedFontModeDisplayName}
</Chip>
</div>
{hasUnsavedChanges && (
<Chip size="sm" color="warning" variant="solid">
</Chip>
)}
</div>
</AccordionItem>
<div className="flex items-center gap-2">
<Button
size="sm"
radius="full"
variant="flat"
className="font-medium bg-default-100 text-default-600 dark:bg-default-50/50"
onPress={() => {
reset();
toast.success('已重置');
}}
isDisabled={!hasUnsavedChanges}
>
</Button>
<Button
size="sm"
color='primary'
radius="full"
className="font-medium shadow-md shadow-primary/20"
isLoading={isSubmitting}
onPress={() => onSubmit()}
isDisabled={!hasUnsavedChanges}
>
</Button>
<Button
size="sm"
isIconOnly
radius='full'
variant='flat'
className="text-default-500 bg-default-100 dark:bg-default-50/50"
onPress={onRefresh}
>
<IoMdRefresh size={18} />
</Button>
</div>
</div>
</div>
<AccordionItem
key='pick'
aria-label='Pick Color'
title='自定义配色'
className='shadow-small'
startContent={<FaPaintbrush />}
>
<div className='space-y-2'>
{(['dark', 'light'] as const).map((mode) => (
<div
key={mode}
className={clsx(
'p-2 rounded-md',
mode === 'dark' ? 'text-white' : 'text-black',
mode === 'dark'
? 'bg-content1-foreground dark:bg-content1'
: 'bg-content1 dark:bg-content1-foreground'
)}
>
<h3 className='text-center p-2 rounded-md bg-content2 mb-2 text-default-800 flex items-center justify-center'>
{mode === 'dark'
? (
<MdDarkMode size={24} />
)
: (
<MdLightMode size={24} />
)}
{mode === 'dark' ? '夜间模式主题' : '白昼模式主题'}
</h3>
{colorKeys.map((key) => (
<div
key={key}
className='grid grid-cols-2 items-center mb-2 gap-2'
<div className="p-4">
<Accordion variant='splitted' defaultExpandedKeys={['font', 'select']}>
<AccordionItem
key='font'
aria-label='Font Settings'
title='字体设置'
subtitle='自定义WebUI显示的字体'
className='shadow-small'
startContent={<FaFont />}
>
<div className='flex flex-col gap-4'>
<Controller
control={control}
name="theme.fontMode"
render={({ field }) => (
<Select
label="字体预设"
selectedKeys={field.value ? [field.value] : ['aacute']}
onChange={(e) => field.onChange(e.target.value)}
className="max-w-xs"
disallowEmptySelection
>
<label className='text-right'>{key}</label>
<Controller
control={control}
name={`theme.${mode}.${key}`}
render={({ field: { value, onChange } }) => {
const hslArray = value?.split(' ') ?? [0, 0, 0];
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
return (
<ColorPicker
color={color}
onChange={(result) => {
onChange(
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
);
}}
/>
);
}}
/>
</div>
))}
<SelectItem key="aacute">Aa </SelectItem>
<SelectItem key="system"></SelectItem>
<SelectItem key="custom"></SelectItem>
</Select>
)}
/>
<div className='p-3 rounded-lg bg-default-100 dark:bg-default-50/30'>
<div className='text-sm text-default-500 mb-2'>
"自定义字体"
</div>
<FileInput
label='上传字体文件'
placeholder='选择字体文件 (.woff/.woff2/.ttf/.otf)'
accept='.ttf,.otf,.woff,.woff2'
onChange={async (file) => {
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);
}
}}
/>
</div>
))}
</div>
</AccordionItem>
</Accordion>
</div>
</AccordionItem>
<AccordionItem
key='select'
aria-label='Pick Color'
title='选择主题'
subtitle='点击主题卡片即可预览,记得保存'
className='shadow-small'
startContent={<IoIosColorPalette />}
>
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3'>
{themes.map((t) => (
<PreviewThemeCard
key={t.name}
theme={t}
isSelected={selectedThemeName === t.name}
onPreview={() => {
setOnebotValue('theme', { ...t.theme, fontMode: theme.fontMode });
}}
/>
))}
</div>
</AccordionItem>
<AccordionItem
key='pick'
aria-label='Pick Color'
title='自定义配色'
subtitle='精细调整每个颜色变量'
className='shadow-small'
startContent={<FaPaintbrush />}
>
<div className='space-y-4'>
{(['light', 'dark'] as const).map((mode) => (
<div
key={mode}
className={clsx(
'p-4 rounded-lg',
mode === 'dark' ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-black'
)}
>
<h3 className='flex items-center justify-center gap-2 p-2 rounded-md bg-opacity-20 mb-4 font-medium'>
{mode === 'dark' ? <MdDarkMode size={20} /> : <MdLightMode size={20} />}
{mode === 'dark' ? '深色模式' : '浅色模式'}
</h3>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
{colorKeys.map((colorKey) => (
<div
key={colorKey}
className='flex items-center gap-2 p-2 rounded bg-black/5 dark:bg-white/5'
>
<Controller
control={control}
name={`theme.${mode}.${colorKey}`}
render={({ field: { value, onChange } }) => {
const hslArray = value?.split(' ') ?? [0, 0, 0];
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
return (
<ColorPicker
color={color}
onChange={(result) => {
onChange(
`${result.hsl.h} ${result.hsl.s * 100}% ${result.hsl.l * 100}%`
);
}}
/>
);
}}
/>
<span className='text-xs font-mono truncate flex-1' title={colorKey}>
{colorKey.replace('--heroui-', '')}
</span>
</div>
))}
</div>
</div>
))}
</div>
</AccordionItem>
</Accordion>
</div>
</>
);
};

View File

@@ -7,11 +7,9 @@ import toast from 'react-hot-toast';
import key from '@/const/key';
import SaveButtons from '@/components/button/save_buttons';
import FileInput from '@/components/input/file_input';
import ImageInput from '@/components/input/image_input';
import { siteConfig } from '@/config/site';
import FileManager from '@/controllers/file_manager';
import WebUIManager from '@/controllers/webui_manager';
// Base64URL to Uint8Array converter
@@ -37,10 +35,10 @@ const WebUIConfigCard = () => {
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting },
setValue: setWebuiValue,
} = useForm<IConfig['webui']>({
} = useForm({
defaultValues: {
background: '',
customIcons: {},
customIcons: {} as Record<string, string>,
},
});
@@ -92,39 +90,6 @@ const WebUIConfigCard = () => {
return (
<>
<title>WebUI配置 - NapCat WebUI</title>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'>WebUI字体</div>
<div className='text-sm text-default-400'>
<FileInput
label='中文字体'
placeholder='选择字体文件'
accept='.ttf,.otf,.woff,.woff2'
onChange={async (file) => {
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);
}
}}
/>
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full font-bold text-default-600 dark:text-default-400 px-1'></div>
<Controller