mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-02 08:40:26 +00:00
refactor: 整体重构 (#1381)
* feat: pnpm new * Refactor build and release workflows, update dependencies Switch build scripts and workflows from npm to pnpm, update build and artifact paths, and simplify release workflow by removing version detection and changelog steps. Add new dependencies (silk-wasm, express, ws, node-pty-prebuilt-multiarch), update exports in package.json files, and add vite config for napcat-framework. Also, rename manifest.json for framework package and fix static asset copying in shell build config.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import key from '@/const/key';
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
const ChangePasswordCard = () => {
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting, errors },
|
||||
reset,
|
||||
watch,
|
||||
} = useForm<{
|
||||
oldToken: string;
|
||||
newToken: string;
|
||||
}>({
|
||||
defaultValues: {
|
||||
oldToken: '',
|
||||
newToken: '',
|
||||
},
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [, setToken] = useLocalStorage(key.token, '');
|
||||
|
||||
// 监听旧密码的值
|
||||
const oldTokenValue = watch('oldToken');
|
||||
|
||||
const onSubmit = handleWebuiSubmit(async (data) => {
|
||||
try {
|
||||
// 使用正常密码更新流程
|
||||
await WebUIManager.changePassword(data.oldToken, data.newToken);
|
||||
|
||||
toast.success('修改成功');
|
||||
setToken('');
|
||||
localStorage.removeItem(key.token);
|
||||
navigate('/web_login');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`修改失败: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>修改密码 - NapCat WebUI</title>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name='oldToken'
|
||||
rules={{
|
||||
required: '旧密码不能为空',
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return '旧密码不能为空';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label='旧密码'
|
||||
placeholder='请输入旧密码'
|
||||
type='password'
|
||||
isRequired
|
||||
isInvalid={!!errors.oldToken}
|
||||
errorMessage={errors.oldToken?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name='newToken'
|
||||
rules={{
|
||||
required: '新密码不能为空',
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: '新密码至少需要6个字符',
|
||||
},
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return '新密码不能为空';
|
||||
}
|
||||
if (value.trim().length !== value.length) {
|
||||
return '新密码不能包含前后空格';
|
||||
}
|
||||
if (value === oldTokenValue) {
|
||||
return '新密码不能与旧密码相同';
|
||||
}
|
||||
// 检查是否包含字母
|
||||
if (!/[a-zA-Z]/.test(value)) {
|
||||
return '新密码必须包含字母';
|
||||
}
|
||||
// 检查是否包含数字
|
||||
if (!/[0-9]/.test(value)) {
|
||||
return '新密码必须包含数字';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label='新密码'
|
||||
placeholder='至少6位,包含字母和数字'
|
||||
type='password'
|
||||
isRequired
|
||||
isInvalid={!!errors.newToken}
|
||||
errorMessage={errors.newToken?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordCard;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import clsx from 'clsx';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import ChangePasswordCard from './change_password';
|
||||
import LoginConfigCard from './login';
|
||||
import OneBotConfigCard from './onebot';
|
||||
import ServerConfigCard from './server';
|
||||
import ThemeConfigCard from './theme';
|
||||
import WebUIConfigCard from './webui';
|
||||
|
||||
export interface ConfigPageProps {
|
||||
children?: React.ReactNode
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const ConfingPageItem: React.FC<ConfigPageProps> = ({
|
||||
children,
|
||||
size = 'md',
|
||||
}) => {
|
||||
return (
|
||||
<Card className='bg-opacity-50 backdrop-blur-sm'>
|
||||
<CardBody className='items-center py-5'>
|
||||
<div
|
||||
className={clsx('max-w-full flex flex-col gap-2', {
|
||||
'w-72': size === 'sm',
|
||||
'w-96': size === 'md',
|
||||
'w-[32rem]': size === 'lg',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ConfigPage () {
|
||||
const isMediumUp = useMediaQuery({ minWidth: 768 });
|
||||
const navigate = useNavigate();
|
||||
const search = useSearchParams({
|
||||
tab: 'onebot',
|
||||
})[0];
|
||||
const tab = search.get('tab') ?? 'onebot';
|
||||
|
||||
return (
|
||||
<section className='w-[1000px] max-w-full md:mx-auto gap-4 py-8 px-2 md:py-10'>
|
||||
<Tabs
|
||||
aria-label='config tab'
|
||||
fullWidth
|
||||
className='w-full'
|
||||
isVertical={isMediumUp}
|
||||
selectedKey={tab}
|
||||
onSelectionChange={(key) => {
|
||||
navigate(`/config?tab=${key}`);
|
||||
}}
|
||||
classNames={{
|
||||
tabList: 'sticky flex top-14 bg-opacity-50 backdrop-blur-sm',
|
||||
panel: 'w-full relative',
|
||||
base: 'md:!w-auto flex-grow-0 flex-shrink-0 mr-0',
|
||||
cursor: 'bg-opacity-60 backdrop-blur-sm',
|
||||
}}
|
||||
>
|
||||
<Tab title='OneBot配置' key='onebot'>
|
||||
<ConfingPageItem>
|
||||
<OneBotConfigCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title='服务器配置' key='server'>
|
||||
<ConfingPageItem>
|
||||
<ServerConfigCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title='WebUI配置' key='webui'>
|
||||
<ConfingPageItem>
|
||||
<WebUIConfigCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title='登录配置' key='login'>
|
||||
<ConfingPageItem>
|
||||
<LoginConfigCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
<Tab title='修改密码' key='token'>
|
||||
<ConfingPageItem>
|
||||
<ChangePasswordCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
|
||||
<Tab title='主题配置' key='theme'>
|
||||
<ConfingPageItem size='lg'>
|
||||
<ThemeConfigCard />
|
||||
</ConfingPageItem>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
|
||||
const LoginConfigCard = () => {
|
||||
const {
|
||||
data: quickLoginData,
|
||||
loading: quickLoginLoading,
|
||||
error: quickLoginError,
|
||||
refreshAsync: refreshQuickLogin,
|
||||
} = useRequest(QQManager.getQuickLoginQQ);
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleOnebotSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setOnebotValue,
|
||||
} = useForm<{
|
||||
quickLoginQQ: string;
|
||||
}>({
|
||||
defaultValues: {
|
||||
quickLoginQQ: '',
|
||||
},
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
setOnebotValue('quickLoginQQ', quickLoginData ?? '');
|
||||
};
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (data) => {
|
||||
try {
|
||||
await QQManager.setQuickLoginQQ(data.quickLoginQQ);
|
||||
toast.success('保存成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`保存失败: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
const onRefresh = async () => {
|
||||
try {
|
||||
await refreshQuickLogin();
|
||||
toast.success('刷新成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`刷新失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [quickLoginData]);
|
||||
|
||||
if (quickLoginLoading) return <PageLoading loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>OneBot配置 - NapCat WebUI</title>
|
||||
<div className='flex-shrink-0 w-full'>快速登录QQ</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name='quickLoginQQ'
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label='快速登录QQ'
|
||||
placeholder='请输入QQ号'
|
||||
isDisabled={!!quickLoginError}
|
||||
errorMessage={quickLoginError ? '获取快速登录QQ失败' : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting || quickLoginLoading}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginConfigCard;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
import SwitchCard from '@/components/switch_card';
|
||||
|
||||
import useConfig from '@/hooks/use-config';
|
||||
|
||||
const OneBotConfigCard = () => {
|
||||
const { config, saveConfigWithoutNetwork, refreshConfig } = useConfig();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleOnebotSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setOnebotValue,
|
||||
} = useForm<IConfig['onebot']>({
|
||||
defaultValues: {
|
||||
musicSignUrl: '',
|
||||
enableLocalFile2Url: false,
|
||||
parseMultMsg: false,
|
||||
},
|
||||
});
|
||||
const reset = () => {
|
||||
setOnebotValue('musicSignUrl', config.musicSignUrl);
|
||||
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url);
|
||||
setOnebotValue('parseMultMsg', config.parseMultMsg);
|
||||
};
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (data) => {
|
||||
try {
|
||||
await saveConfigWithoutNetwork(data);
|
||||
toast.success('保存成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`保存失败: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
const onRefresh = async (shotTip = true) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await refreshConfig();
|
||||
if (shotTip) toast.success('刷新成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`刷新失败: ${msg}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
onRefresh(false);
|
||||
}, []);
|
||||
|
||||
if (loading) return <PageLoading loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>OneBot配置 - NapCat WebUI</title>
|
||||
<Controller
|
||||
control={control}
|
||||
name='musicSignUrl'
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label='音乐签名地址'
|
||||
placeholder='请输入音乐签名地址'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='enableLocalFile2Url'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description='启用本地文件到URL'
|
||||
label='启用本地文件到URL'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='parseMultMsg'
|
||||
render={({ field }) => (
|
||||
<SwitchCard
|
||||
{...field}
|
||||
description='启用上报解析合并消息'
|
||||
label='启用上报解析合并消息'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OneBotConfigCard;
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { Switch } from '@heroui/switch';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
const ServerConfigCard = () => {
|
||||
const {
|
||||
data: configData,
|
||||
loading: configLoading,
|
||||
error: configError,
|
||||
refreshAsync: refreshConfig,
|
||||
} = useRequest(WebUIManager.getWebUIConfig);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleConfigSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setConfigValue,
|
||||
} = useForm<{
|
||||
host: string;
|
||||
port: number;
|
||||
loginRate: number;
|
||||
disableWebUI: boolean;
|
||||
disableNonLANAccess: boolean;
|
||||
}>({
|
||||
defaultValues: {
|
||||
host: '0.0.0.0',
|
||||
port: 6099,
|
||||
loginRate: 10,
|
||||
disableWebUI: false,
|
||||
disableNonLANAccess: false,
|
||||
},
|
||||
});
|
||||
|
||||
const reset = () => {
|
||||
if (configData) {
|
||||
setConfigValue('host', configData.host);
|
||||
setConfigValue('port', configData.port);
|
||||
setConfigValue('loginRate', configData.loginRate);
|
||||
setConfigValue('disableWebUI', configData.disableWebUI);
|
||||
setConfigValue('disableNonLANAccess', configData.disableNonLANAccess);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = handleConfigSubmit(async (data) => {
|
||||
try {
|
||||
await WebUIManager.updateWebUIConfig(data);
|
||||
toast.success('保存成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`保存失败: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
const onRefresh = async () => {
|
||||
try {
|
||||
await refreshConfig();
|
||||
toast.success('刷新成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`刷新失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [configData]);
|
||||
|
||||
if (configLoading) return <PageLoading loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>服务器配置 - NapCat WebUI</title>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full'>服务器配置</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name='host'
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label='监听地址'
|
||||
placeholder='请输入监听地址'
|
||||
description='服务器监听的IP地址,0.0.0.0表示监听所有网卡'
|
||||
isDisabled={!!configError}
|
||||
errorMessage={configError ? '获取配置失败' : undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='port'
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type='number'
|
||||
value={field.value?.toString() || ''}
|
||||
label='监听端口'
|
||||
placeholder='请输入监听端口'
|
||||
description='服务器监听的端口号,范围1-65535'
|
||||
isDisabled={!!configError}
|
||||
errorMessage={configError ? '获取配置失败' : undefined}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='loginRate'
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type='number'
|
||||
value={field.value?.toString() || ''}
|
||||
label='登录速率限制'
|
||||
placeholder='请输入登录速率限制'
|
||||
description='每小时允许的登录尝试次数'
|
||||
isDisabled={!!configError}
|
||||
errorMessage={configError ? '获取配置失败' : undefined}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full'>安全配置</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name='disableWebUI'
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
isSelected={field.value}
|
||||
onValueChange={(value) => field.onChange(value)}
|
||||
isDisabled={!!configError}
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<span>禁用WebUI</span>
|
||||
<span className='text-sm text-default-400'>
|
||||
启用后将完全禁用WebUI服务,需要重启生效
|
||||
</span>
|
||||
</div>
|
||||
</Switch>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name='disableNonLANAccess'
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
isSelected={field.value}
|
||||
onValueChange={(value) => field.onChange(value)}
|
||||
isDisabled={!!configError}
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<span>禁用非局域网访问</span>
|
||||
<span className='text-sm text-default-400'>
|
||||
启用后只允许局域网内的设备访问WebUI,提高安全性
|
||||
</span>
|
||||
</div>
|
||||
</Switch>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting || configLoading}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerConfigCard;
|
||||
@@ -0,0 +1,282 @@
|
||||
import { Accordion, AccordionItem } from '@heroui/accordion';
|
||||
import { Card, CardBody, CardHeader } from '@heroui/card';
|
||||
import { useRequest } from 'ahooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||
import toast from 'react-hot-toast';
|
||||
import { FaUserAstronaut } from 'react-icons/fa';
|
||||
import { FaPaintbrush } from 'react-icons/fa6';
|
||||
import { IoIosColorPalette } from 'react-icons/io';
|
||||
import { MdDarkMode, MdLightMode } from 'react-icons/md';
|
||||
|
||||
import themes from '@/const/themes';
|
||||
|
||||
import ColorPicker from '@/components/ColorPicker';
|
||||
import SaveButtons from '@/components/button/save_buttons';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import { colorKeys, generateTheme, loadTheme } from '@/utils/theme';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
export type PreviewThemeCardProps = {
|
||||
theme: ThemeInfo;
|
||||
onPreview: () => void;
|
||||
};
|
||||
|
||||
const values = [
|
||||
'',
|
||||
'-50',
|
||||
'-100',
|
||||
'-200',
|
||||
'-300',
|
||||
'-400',
|
||||
'-500',
|
||||
'-600',
|
||||
'-700',
|
||||
'-800',
|
||||
'-900',
|
||||
];
|
||||
const colors = [
|
||||
'primary',
|
||||
'secondary',
|
||||
'success',
|
||||
'danger',
|
||||
'warning',
|
||||
'default',
|
||||
];
|
||||
|
||||
function PreviewThemeCard ({ theme, onPreview }: PreviewThemeCardProps) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = generateTheme(theme.theme, theme.name);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
document.head.appendChild(style);
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<Card
|
||||
ref={cardRef}
|
||||
shadow='sm'
|
||||
radius='sm'
|
||||
isPressable
|
||||
onPress={onPreview}
|
||||
className={clsx('text-primary bg-primary-50', theme.name)}
|
||||
>
|
||||
<CardHeader className='pb-0 flex flex-col items-start gap-1'>
|
||||
<div className='px-1 rounded-md bg-primary text-primary-foreground'>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className='text-xs flex items-center gap-1 text-primary-300'>
|
||||
<FaUserAstronaut />
|
||||
{theme.author ?? '未知'}
|
||||
</div>
|
||||
<div className='text-xs text-primary-200'>{theme.description}</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{colors.map((color) => (
|
||||
<div className='flex gap-1 items-center flex-wrap' key={color}>
|
||||
<div className='text-xs w-4 text-right'>
|
||||
{color[0].toUpperCase()}
|
||||
</div>
|
||||
{values.map((value) => (
|
||||
<div
|
||||
key={value}
|
||||
className={clsx(
|
||||
'w-2 h-2 rounded-full shadow-small',
|
||||
`bg-${color}${value}`
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 使用 useRef 存储 style 标签引用
|
||||
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
// 在组件挂载时创建 style 标签,并在卸载时清理
|
||||
useEffect(() => {
|
||||
const styleTag = document.createElement('style');
|
||||
document.head.appendChild(styleTag);
|
||||
styleTagRef.current = styleTag;
|
||||
return () => {
|
||||
if (styleTagRef.current) {
|
||||
document.head.removeChild(styleTagRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const theme = useWatch({ control, name: 'theme' });
|
||||
|
||||
const reset = () => {
|
||||
if (data) setOnebotValue('theme', data);
|
||||
};
|
||||
|
||||
const onSubmit = handleOnebotSubmit(async (data) => {
|
||||
try {
|
||||
await WebUIManager.setThemeConfig(data.theme);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme && styleTagRef.current) {
|
||||
const css = generateTheme(theme);
|
||||
styleTagRef.current.innerHTML = css;
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
if (loading) return <PageLoading loading />;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='py-24 text-danger-500 text-center'>{error.message}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</AccordionItem>
|
||||
|
||||
<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'
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeConfigCard;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Input } from '@heroui/input';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
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 useMusic from '@/hooks/use-music';
|
||||
|
||||
import { siteConfig } from '@/config/site';
|
||||
import FileManager from '@/controllers/file_manager';
|
||||
|
||||
const WebUIConfigCard = () => {
|
||||
const {
|
||||
control,
|
||||
handleSubmit: handleWebuiSubmit,
|
||||
formState: { isSubmitting },
|
||||
setValue: setWebuiValue,
|
||||
} = useForm<IConfig['webui']>({
|
||||
defaultValues: {
|
||||
background: '',
|
||||
musicListID: '',
|
||||
customIcons: {},
|
||||
},
|
||||
});
|
||||
|
||||
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '');
|
||||
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
|
||||
key.customIcons,
|
||||
{}
|
||||
);
|
||||
const { setListId, listId } = useMusic();
|
||||
|
||||
const reset = () => {
|
||||
setWebuiValue('musicListID', listId);
|
||||
setWebuiValue('customIcons', customIcons);
|
||||
setWebuiValue('background', b64img);
|
||||
};
|
||||
|
||||
const onSubmit = handleWebuiSubmit((data) => {
|
||||
try {
|
||||
setListId(data.musicListID);
|
||||
setCustomIcons(data.customIcons);
|
||||
setB64img(data.background);
|
||||
toast.success('保存成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`保存失败: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset();
|
||||
}, [listId, customIcons, b64img]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>WebUI配置 - NapCat WebUI</title>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full'>WebUI字体</div>
|
||||
<div className='text-sm text-default-400'>
|
||||
此项不需要手动保存,上传成功后需清空网页缓存并刷新
|
||||
<FileInput
|
||||
label='中文字体'
|
||||
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'>WebUI音乐播放器</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name='musicListID'
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
label='网易云音乐歌单ID(网页内音乐播放器)'
|
||||
placeholder='请输入歌单ID'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex-shrink-0 w-full'>背景图</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name='background'
|
||||
render={({ field }) => <ImageInput {...field} />}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div>自定义图标</div>
|
||||
{siteConfig.navItems.map((item) => (
|
||||
<Controller
|
||||
key={item.label}
|
||||
control={control}
|
||||
name={`customIcons.${item.label}`}
|
||||
render={({ field }) => <ImageInput {...field} label={item.label} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SaveButtons
|
||||
onSubmit={onSubmit}
|
||||
reset={reset}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebUIConfigCard;
|
||||
Reference in New Issue
Block a user