mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-04 06:31:13 +00:00
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:
parent
0592f1a99a
commit
447f86e2b5
@ -1,8 +1,8 @@
|
||||
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 { Tab, Tabs } from '@heroui/tabs';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||
@ -298,159 +298,180 @@ const ThemeConfigCard = () => {
|
||||
<title>主题配置 - NapCat WebUI</title>
|
||||
|
||||
{/* 顶部操作栏 */}
|
||||
<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 className='w-full px-4 pt-4 pb-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<h1 className='text-xl font-bold text-default-900 tracking-tight'>外观设置</h1>
|
||||
<div className='flex items-center gap-3 text-tiny text-default-500'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<IoIosColorPalette className='text-primary' size={16} />
|
||||
<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 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 className='flex items-center gap-2'>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
<Button
|
||||
size='sm'
|
||||
radius='full'
|
||||
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={() => {
|
||||
reset();
|
||||
toast.success('已重置');
|
||||
}}
|
||||
isDisabled={!hasUnsavedChanges}
|
||||
>
|
||||
取消更改
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
color='primary'
|
||||
radius='full'
|
||||
className='font-medium shadow-md shadow-primary/20'
|
||||
className='font-medium shadow-lg shadow-primary/20 px-6 h-9'
|
||||
isLoading={isSubmitting}
|
||||
onPress={() => onSubmit()}
|
||||
isDisabled={!hasUnsavedChanges}
|
||||
>
|
||||
保存
|
||||
保存应用
|
||||
</Button>
|
||||
<div className='w-px h-6 bg-divider mx-1 hidden sm:block'></div>
|
||||
<Button
|
||||
size='sm'
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='flat'
|
||||
className='text-default-500 bg-default-100 dark:bg-default-50/50'
|
||||
variant='light'
|
||||
className='text-default-500 hover:text-default-900 hidden sm:flex'
|
||||
onPress={onRefresh}
|
||||
>
|
||||
<IoMdRefresh size={18} />
|
||||
<IoMdRefresh size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='p-4'>
|
||||
<Accordion
|
||||
variant='splitted'
|
||||
defaultExpandedKeys={['font']}
|
||||
selectionMode='single'
|
||||
<div className='px-4 pt-0 pb-4 w-full h-full'>
|
||||
<Tabs
|
||||
aria-label="Theme Config Options"
|
||||
color="primary"
|
||||
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
|
||||
key='font'
|
||||
aria-label='Font Settings'
|
||||
title='字体设置'
|
||||
subtitle='自定义WebUI显示的字体'
|
||||
className='shadow-small'
|
||||
startContent={<FaFont />}
|
||||
<Tab
|
||||
key="font"
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<FaFont />
|
||||
<span>字体设置</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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
|
||||
>
|
||||
<SelectItem key='aacute'>Aa 偷吃可爱长大的</SelectItem>
|
||||
<SelectItem key='system'>系统默认</SelectItem>
|
||||
<SelectItem key='custom'>自定义字体</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{theme.fontMode === 'custom' && (
|
||||
<div className='p-3 rounded-lg bg-default-100 dark:bg-default-50/30'>
|
||||
<div className='text-sm text-default-500 mb-2'>
|
||||
上传自定义字体(仅在选择"自定义字体"时生效)
|
||||
<Card className='shadow-sm border border-default-100 bg-background/60 backdrop-blur-md w-full'>
|
||||
<CardBody className='p-6'>
|
||||
<div className='flex flex-col gap-6 w-full'>
|
||||
<div>
|
||||
<h3 className='text-lg font-medium mb-1'>WebUI 字体</h3>
|
||||
<p className='text-sm text-default-500 mb-4'>自定义界面显示的字体风格</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name='theme.fontMode'
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label='选择字体'
|
||||
variant='bordered'
|
||||
selectedKeys={field.value ? [field.value] : ['aacute']}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
className='max-w-xs'
|
||||
disallowEmptySelection
|
||||
>
|
||||
<SelectItem key='aacute'>Aa 偷吃可爱长大的</SelectItem>
|
||||
<SelectItem key='system'>系统默认</SelectItem>
|
||||
<SelectItem key='custom'>自定义字体</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{customFontExists && (
|
||||
<div className='mb-2 flex items-center gap-2 text-sm text-primary'>
|
||||
<FaCheck /> 已上传自定义字体
|
||||
</div>
|
||||
)}
|
||||
<FileInput
|
||||
label='上传字体文件'
|
||||
placeholder='选择字体文件 (.woff/.woff2/.ttf/.otf)'
|
||||
accept='.ttf,.otf,.woff,.woff2'
|
||||
onChange={async (file) => {
|
||||
try {
|
||||
// 如果已存在自定义字体,先尝试删除
|
||||
if (customFontExists) {
|
||||
|
||||
{theme.fontMode === 'custom' && (
|
||||
<div className='p-4 rounded-xl bg-default-50 border border-default-100'>
|
||||
<div className='flex items-center justify-between mb-4'>
|
||||
<div className='text-sm font-medium'>自定义字体文件</div>
|
||||
{customFontExists && (
|
||||
<Chip size='sm' color='success' variant='flat' startContent={<FaCheck size={10} />}>
|
||||
已上传
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 {
|
||||
await FileManager.deleteWebUIFont();
|
||||
} catch (e) {
|
||||
console.warn('Failed to delete existing font before upload:', e);
|
||||
// 继续尝试上传,后端可能会覆盖或报错
|
||||
toast.success('删除成功,即将刷新页面');
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} catch (error) {
|
||||
toast.error('删除失败: ' + (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
<p className='text-xs text-default-400 mt-2'>
|
||||
注意:上传新字体会覆盖旧字体文件,更改后需要刷新页面生效。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Tab>
|
||||
|
||||
<AccordionItem
|
||||
key='select'
|
||||
aria-label='Pick Color'
|
||||
title='选择主题'
|
||||
subtitle='点击主题卡片即可预览,记得保存'
|
||||
className='shadow-small'
|
||||
startContent={<IoIosColorPalette />}
|
||||
<Tab
|
||||
key="theme"
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<IoIosColorPalette size={18} />
|
||||
<span>选择主题</span>
|
||||
</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) => (
|
||||
<PreviewThemeCard
|
||||
key={t.name}
|
||||
@ -462,67 +483,77 @@ const ThemeConfigCard = () => {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Tab>
|
||||
|
||||
<AccordionItem
|
||||
key='pick'
|
||||
aria-label='Pick Color'
|
||||
title='自定义配色'
|
||||
subtitle='精细调整每个颜色变量'
|
||||
className='shadow-small'
|
||||
startContent={<FaPaintbrush />}
|
||||
<Tab
|
||||
key="custom-color"
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<FaPaintbrush />
|
||||
<span>自定义配色</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
|
||||
{(['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={(hslString) => {
|
||||
// ColorPicker returns hsl(h, s%, l%) string
|
||||
// We need to parse it and convert to "h s% l%" format for theme config
|
||||
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>
|
||||
</div>
|
||||
<Card key={mode} className={clsx('border shadow-sm', mode === 'dark' ? 'bg-[#18181b] border-zinc-800' : 'bg-white border-zinc-200')}>
|
||||
<CardHeader className='pb-0 pt-4 px-4 flex-col items-start'>
|
||||
<div className='flex items-center gap-2 mb-1'>
|
||||
{mode === 'dark' ? <MdDarkMode className="text-zinc-400" size={20} /> : <MdLightMode className="text-orange-400" size={20} />}
|
||||
<h4 className={clsx('font-bold text-large', mode === 'dark' ? 'text-white' : 'text-black')}>
|
||||
{mode === 'dark' ? '深色模式' : '浅色模式'}
|
||||
</h4>
|
||||
</div>
|
||||
<p className={clsx('text-tiny', mode === 'dark' ? 'text-zinc-400' : 'text-zinc-500')}>
|
||||
调整{mode === 'dark' ? '深色' : '浅色'}主题下的颜色变量
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardBody className='p-4'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
|
||||
{colorKeys.map((colorKey) => (
|
||||
<div
|
||||
key={colorKey}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 p-2 rounded-lg border transition-colors',
|
||||
mode === 'dark' ? 'bg-zinc-900/50 border-zinc-800 hover:bg-zinc-900' : 'bg-zinc-50 border-zinc-100 hover:bg-zinc-100'
|
||||
)}
|
||||
>
|
||||
<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={(hslString) => {
|
||||
const match = hslString.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
|
||||
if (match) {
|
||||
onChange(`${match[1]} ${match[2]}% ${match[3]}%`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className='flex flex-col overflow-hidden'>
|
||||
<span className={clsx('text-xs font-medium truncate', mode === 'dark' ? 'text-zinc-300' : 'text-zinc-700')}>
|
||||
{colorKey.replace('--heroui-', '')}
|
||||
</span>
|
||||
<span className={clsx('text-[10px] truncate', mode === 'dark' ? 'text-zinc-500' : 'text-zinc-400')}>
|
||||
Variable
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user