Use tabs, redesign theme settings UI

Replace the Accordion-based layout with a Tabs component and overhaul the Theme settings page UI. Rework the top header to a compact title/status area showing current theme, font and unsaved state; restyle action buttons and refresh icon. Convert font settings into a Card with improved FileInput flow (attempt delete before upload, better success/error toasts and page reload), and present theme previews and custom color editors as Cards per light/dark mode with updated ColorPicker handling. Update imports accordingly and apply various layout / class refinements.
This commit is contained in:
手瓜一十雪 2026-02-01 14:53:00 +08:00
parent 0592f1a99a
commit 447f86e2b5

View File

@ -1,8 +1,8 @@
import { Accordion, AccordionItem } from '@heroui/accordion';
import { Button } from '@heroui/button'; import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card'; import { Card, CardBody, CardHeader } from '@heroui/card';
import { Select, SelectItem } from '@heroui/select'; import { Select, SelectItem } from '@heroui/select';
import { Chip } from '@heroui/chip'; import { Chip } from '@heroui/chip';
import { Tab, Tabs } from '@heroui/tabs';
import { useRequest } from 'ahooks'; import { useRequest } from 'ahooks';
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
@ -298,159 +298,180 @@ const ThemeConfigCard = () => {
<title> - NapCat WebUI</title> <title> - NapCat WebUI</title>
{/* 顶部操作栏 */} {/* 顶部操作栏 */}
<div className='sticky top-0 z-20 bg-background/80 backdrop-blur-md border-b border-divider'> <div className='w-full px-4 pt-4 pb-2'>
<div className='flex items-center justify-between p-4'> <div className='flex items-center justify-between'>
<div className='flex items-center gap-3 flex-wrap'> <div className='flex flex-col gap-1'>
<div className='flex items-center gap-2 text-sm'> <h1 className='text-xl font-bold text-default-900 tracking-tight'></h1>
<span className='text-default-400'>:</span> <div className='flex items-center gap-3 text-tiny text-default-500'>
<Chip size='sm' color='primary' variant='flat'> <div className='flex items-center gap-1.5'>
{savedThemeName || '加载中...'} <IoIosColorPalette className='text-primary' size={16} />
</Chip> <span className='font-medium text-default-700'>{savedThemeName || '加载中...'}</span>
</div>
<div className='w-px h-2.5 bg-default-300' />
<div className='flex items-center gap-1.5'>
<FaFont className='text-secondary' size={12} />
<span className='font-medium text-default-700'>{savedFontModeDisplayName}</span>
</div>
{hasUnsavedChanges && (
<>
<div className='w-px h-2.5 bg-default-300' />
<div className='flex items-center gap-1'>
<div className='w-1.5 h-1.5 rounded-full bg-warning animate-pulse' />
<span className='text-warning font-semibold'></span>
</div>
</>
)}
</div> </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> </div>
<div className='flex items-center gap-2'>
<div className='flex items-center gap-3'>
<Button <Button
size='sm' size='sm'
radius='full'
variant='flat' variant='flat'
className='font-medium bg-default-100 text-default-600 dark:bg-default-50/50' color='default'
className='font-medium bg-default-100 hover:bg-default-200 h-9'
onPress={() => { onPress={() => {
reset(); reset();
toast.success('已重置'); toast.success('已重置');
}} }}
isDisabled={!hasUnsavedChanges} isDisabled={!hasUnsavedChanges}
> >
</Button> </Button>
<Button <Button
size='sm' size='sm'
color='primary' color='primary'
radius='full' className='font-medium shadow-lg shadow-primary/20 px-6 h-9'
className='font-medium shadow-md shadow-primary/20'
isLoading={isSubmitting} isLoading={isSubmitting}
onPress={() => onSubmit()} onPress={() => onSubmit()}
isDisabled={!hasUnsavedChanges} isDisabled={!hasUnsavedChanges}
> >
</Button> </Button>
<div className='w-px h-6 bg-divider mx-1 hidden sm:block'></div>
<Button <Button
size='sm' size='sm'
isIconOnly isIconOnly
radius='full' variant='light'
variant='flat' className='text-default-500 hover:text-default-900 hidden sm:flex'
className='text-default-500 bg-default-100 dark:bg-default-50/50'
onPress={onRefresh} onPress={onRefresh}
> >
<IoMdRefresh size={18} /> <IoMdRefresh size={20} />
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<div className='p-4'> <div className='px-4 pt-0 pb-4 w-full h-full'>
<Accordion <Tabs
variant='splitted' aria-label="Theme Config Options"
defaultExpandedKeys={['font']} color="primary"
selectionMode='single' variant="underlined"
disableAnimation
classNames={{
tabList: "gap-8 w-full relative rounded-none p-0 border-b border-divider overflow-x-auto no-scrollbar",
cursor: "w-full bg-primary h-[3px] -bottom-[1.5px]",
tab: "max-w-fit px-0 h-12 hover:opacity-100 opacity-70 data-[selected=true]:opacity-100",
tabContent: "font-semibold py-2",
panel: "py-4"
}}
> >
<AccordionItem <Tab
key='font' key="font"
aria-label='Font Settings' title={
title='字体设置' <div className="flex items-center space-x-2">
subtitle='自定义WebUI显示的字体' <FaFont />
className='shadow-small' <span></span>
startContent={<FaFont />} </div>
}
> >
<div className='flex flex-col gap-4'> <Card className='shadow-sm border border-default-100 bg-background/60 backdrop-blur-md w-full'>
<Controller <CardBody className='p-6'>
control={control} <div className='flex flex-col gap-6 w-full'>
name='theme.fontMode' <div>
render={({ field }) => ( <h3 className='text-lg font-medium mb-1'>WebUI </h3>
<Select <p className='text-sm text-default-500 mb-4'></p>
label='字体预设' <Controller
selectedKeys={field.value ? [field.value] : ['aacute']} control={control}
onChange={(e) => field.onChange(e.target.value)} name='theme.fontMode'
className='max-w-xs' render={({ field }) => (
disallowEmptySelection <Select
> label='选择字体'
<SelectItem key='aacute'>Aa </SelectItem> variant='bordered'
<SelectItem key='system'></SelectItem> selectedKeys={field.value ? [field.value] : ['aacute']}
<SelectItem key='custom'></SelectItem> onChange={(e) => field.onChange(e.target.value)}
</Select> className='max-w-xs'
)} disallowEmptySelection
/> >
{theme.fontMode === 'custom' && ( <SelectItem key='aacute'>Aa </SelectItem>
<div className='p-3 rounded-lg bg-default-100 dark:bg-default-50/30'> <SelectItem key='system'></SelectItem>
<div className='text-sm text-default-500 mb-2'> <SelectItem key='custom'></SelectItem>
"自定义字体" </Select>
)}
/>
</div> </div>
{customFontExists && (
<div className='mb-2 flex items-center gap-2 text-sm text-primary'> {theme.fontMode === 'custom' && (
<FaCheck /> <div className='p-4 rounded-xl bg-default-50 border border-default-100'>
</div> <div className='flex items-center justify-between mb-4'>
)} <div className='text-sm font-medium'></div>
<FileInput {customFontExists && (
label='上传字体文件' <Chip size='sm' color='success' variant='flat' startContent={<FaCheck size={10} />}>
placeholder='选择字体文件 (.woff/.woff2/.ttf/.otf)'
accept='.ttf,.otf,.woff,.woff2' </Chip>
onChange={async (file) => { )}
try { </div>
// 如果已存在自定义字体,先尝试删除
if (customFontExists) { <FileInput
label='上传字体文件'
placeholder='拖拽或点击上传 (.woff/.woff2/.ttf/.otf)'
accept='.ttf,.otf,.woff,.woff2'
onChange={async (file) => {
try {
if (customFontExists) {
try {
await FileManager.deleteWebUIFont();
} catch (e) {
console.warn('Failed to delete existing font before upload:', e);
}
}
await FileManager.uploadWebUIFont(file);
toast.success('上传成功,即将刷新页面');
setTimeout(() => window.location.reload(), 1000);
} catch (error) {
toast.error('上传失败: ' + (error as Error).message);
}
}}
onDelete={async () => {
try { try {
await FileManager.deleteWebUIFont(); await FileManager.deleteWebUIFont();
} catch (e) { toast.success('删除成功,即将刷新页面');
console.warn('Failed to delete existing font before upload:', e); setTimeout(() => window.location.reload(), 1000);
// 继续尝试上传,后端可能会覆盖或报错 } catch (error) {
toast.error('删除失败: ' + (error as Error).message);
} }
} }}
/>
await FileManager.uploadWebUIFont(file); <p className='text-xs text-default-400 mt-2'>
toast.success('上传成功,即将刷新页面');
setTimeout(() => { </p>
window.location.reload(); </div>
}, 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>
)} </CardBody>
</div> </Card>
</AccordionItem> </Tab>
<AccordionItem <Tab
key='select' key="theme"
aria-label='Pick Color' title={
title='选择主题' <div className="flex items-center space-x-2">
subtitle='点击主题卡片即可预览,记得保存' <IoIosColorPalette size={18} />
className='shadow-small' <span></span>
startContent={<IoIosColorPalette />} </div>
}
> >
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3'> <div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4'>
{themes.map((t) => ( {themes.map((t) => (
<PreviewThemeCard <PreviewThemeCard
key={t.name} key={t.name}
@ -462,67 +483,77 @@ const ThemeConfigCard = () => {
/> />
))} ))}
</div> </div>
</AccordionItem> </Tab>
<AccordionItem <Tab
key='pick' key="custom-color"
aria-label='Pick Color' title={
title='自定义配色' <div className="flex items-center space-x-2">
subtitle='精细调整每个颜色变量' <FaPaintbrush />
className='shadow-small' <span></span>
startContent={<FaPaintbrush />} </div>
}
> >
<div className='space-y-4'> <div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
{(['light', 'dark'] as const).map((mode) => ( {(['light', 'dark'] as const).map((mode) => (
<div <Card key={mode} className={clsx('border shadow-sm', mode === 'dark' ? 'bg-[#18181b] border-zinc-800' : 'bg-white border-zinc-200')}>
key={mode} <CardHeader className='pb-0 pt-4 px-4 flex-col items-start'>
className={clsx( <div className='flex items-center gap-2 mb-1'>
'p-4 rounded-lg', {mode === 'dark' ? <MdDarkMode className="text-zinc-400" size={20} /> : <MdLightMode className="text-orange-400" size={20} />}
mode === 'dark' ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-black' <h4 className={clsx('font-bold text-large', mode === 'dark' ? 'text-white' : 'text-black')}>
)} {mode === 'dark' ? '深色模式' : '浅色模式'}
> </h4>
<h3 className='flex items-center justify-center gap-2 p-2 rounded-md bg-opacity-20 mb-4 font-medium'> </div>
{mode === 'dark' ? <MdDarkMode size={20} /> : <MdLightMode size={20} />} <p className={clsx('text-tiny', mode === 'dark' ? 'text-zinc-400' : 'text-zinc-500')}>
{mode === 'dark' ? '深色模式' : '浅色模式'} {mode === 'dark' ? '深色' : '浅色'}
</h3> </p>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'> </CardHeader>
{colorKeys.map((colorKey) => ( <CardBody className='p-4'>
<div <div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
key={colorKey} {colorKeys.map((colorKey) => (
className='flex items-center gap-2 p-2 rounded bg-black/5 dark:bg-white/5' <div
> key={colorKey}
<Controller className={clsx(
control={control} 'flex items-center gap-3 p-2 rounded-lg border transition-colors',
name={`theme.${mode}.${colorKey}`} mode === 'dark' ? 'bg-zinc-900/50 border-zinc-800 hover:bg-zinc-900' : 'bg-zinc-50 border-zinc-100 hover:bg-zinc-100'
render={({ field: { value, onChange } }) => { )}
const hslArray = value?.split(' ') ?? [0, 0, 0]; >
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`; <Controller
return ( control={control}
<ColorPicker name={`theme.${mode}.${colorKey}`}
color={color} render={({ field: { value, onChange } }) => {
onChange={(hslString) => { const hslArray = value?.split(' ') ?? [0, 0, 0];
// ColorPicker returns hsl(h, s%, l%) string const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
// We need to parse it and convert to "h s% l%" format for theme config return (
const match = hslString.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/); <ColorPicker
if (match) { color={color}
onChange(`${match[1]} ${match[2]}% ${match[3]}%`); onChange={(hslString) => {
} const match = hslString.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
}} if (match) {
/> onChange(`${match[1]} ${match[2]}% ${match[3]}%`);
); }
}} }}
/> />
<span className='text-xs font-mono truncate flex-1' title={colorKey}> );
{colorKey.replace('--heroui-', '')} }}
</span> />
</div> <div className='flex flex-col overflow-hidden'>
))} <span className={clsx('text-xs font-medium truncate', mode === 'dark' ? 'text-zinc-300' : 'text-zinc-700')}>
</div> {colorKey.replace('--heroui-', '')}
</div> </span>
<span className={clsx('text-[10px] truncate', mode === 'dark' ? 'text-zinc-500' : 'text-zinc-400')}>
Variable
</span>
</div>
</div>
))}
</div>
</CardBody>
</Card>
))} ))}
</div> </div>
</AccordionItem> </Tab>
</Accordion> </Tabs>
</div> </div>
</> </>
); );