mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-03-01 16:20:25 +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:
204
packages/napcat-webui-frontend/src/pages/dashboard/about.tsx
Normal file
204
packages/napcat-webui-frontend/src/pages/dashboard/about.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Link } from '@heroui/link';
|
||||
import { Skeleton } from '@heroui/skeleton';
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useMemo } from 'react';
|
||||
import { BsTelegram, BsTencentQq } from 'react-icons/bs';
|
||||
import { IoDocument } from 'react-icons/io5';
|
||||
|
||||
import HoverTiltedCard from '@/components/hover_titled_card';
|
||||
import NapCatRepoInfo from '@/components/napcat_repo_info';
|
||||
import RotatingText from '@/components/rotating_text';
|
||||
|
||||
import { usePreloadImages } from '@/hooks/use-preload-images';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
function VersionInfo () {
|
||||
const { data, loading, error } = useRequest(WebUIManager.getPackageInfo);
|
||||
return (
|
||||
<div className='flex items-center gap-2 text-2xl font-bold'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='text-primary-500 drop-shadow-md'>NapCat</div>
|
||||
{error
|
||||
? (
|
||||
error.message
|
||||
)
|
||||
: loading
|
||||
? (
|
||||
<Spinner size='sm' />
|
||||
)
|
||||
: (
|
||||
<RotatingText
|
||||
texts={['WebUI', data?.version ?? '']}
|
||||
mainClassName='overflow-hidden flex items-center bg-primary-500 px-2 rounded-lg text-default-50 shadow-md'
|
||||
staggerFrom='last'
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '-120%' }}
|
||||
staggerDuration={0.025}
|
||||
splitLevelClassName='overflow-hidden'
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 400 }}
|
||||
rotationInterval={2000}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AboutPage () {
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const imageUrls = useMemo(
|
||||
() => [
|
||||
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
|
||||
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=dark',
|
||||
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
|
||||
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=dark',
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const { loadedUrls, isLoading } = usePreloadImages(imageUrls);
|
||||
|
||||
const getImageUrl = useMemo(
|
||||
() => (baseUrl: string) => {
|
||||
const theme = isDark ? 'dark' : 'light';
|
||||
const fullUrl = baseUrl.replace(
|
||||
/color_scheme=(?:light|dark)/,
|
||||
`color_scheme=${theme}`
|
||||
);
|
||||
return isLoading ? null : loadedUrls[fullUrl] ? fullUrl : null;
|
||||
},
|
||||
[isDark, isLoading, loadedUrls]
|
||||
);
|
||||
|
||||
const renderImage = useMemo(
|
||||
() => (baseUrl: string, alt: string) => {
|
||||
const imageUrl = getImageUrl(baseUrl);
|
||||
|
||||
if (!imageUrl) {
|
||||
return <Skeleton className='h-16 rounded-lg' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
className='flex-1 pointer-events-none select-none rounded-none'
|
||||
src={imageUrl}
|
||||
alt={alt}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[getImageUrl]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>关于 NapCat WebUI</title>
|
||||
<section className='max-w-7xl py-8 md:py-10 px-5 mx-auto space-y-10'>
|
||||
<div className='w-full flex flex-col md:flex-row gap-4'>
|
||||
<div className='flex flex-col md:flex-row items-center'>
|
||||
<HoverTiltedCard imageSrc={logo} overlayContent='' />
|
||||
</div>
|
||||
<div className='flex-1 flex flex-col gap-2 py-2'>
|
||||
<VersionInfo />
|
||||
<div className='space-y-1'>
|
||||
<p className='font-bold text-primary-400'>NapCat 是什么?</p>
|
||||
<p className='text-default-800'>
|
||||
基于TypeScript构建的Bot框架,通过相应的启动器或者框架,主动调用QQ
|
||||
Node模块提供给客户端的接口,实现Bot的功能.
|
||||
</p>
|
||||
<p className='font-bold text-primary-400'>魔法版介绍</p>
|
||||
<p className='text-default-800'>
|
||||
猫猫框架通过魔法的手段获得了 QQ 的发送消息、接收消息等接口。
|
||||
为了方便使用,猫猫框架将通过一种名为 OneBot 的约定将你的 HTTP /
|
||||
WebSocket 请求按照规范读取,
|
||||
再去调用猫猫框架所获得的QQ发送接口之类的接口。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-row gap-2 flex-wrap justify-around'>
|
||||
<Card
|
||||
as={Link}
|
||||
shadow='sm'
|
||||
isPressable
|
||||
isExternal
|
||||
href='https://qm.qq.com/q/F9cgs1N3Mc'
|
||||
>
|
||||
<CardBody className='flex-row items-center gap-2'>
|
||||
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
|
||||
<BsTencentQq size={16} />
|
||||
</span>
|
||||
<span>官方社群1</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
as={Link}
|
||||
shadow='sm'
|
||||
isPressable
|
||||
isExternal
|
||||
href='https://qm.qq.com/q/hSt0u9PVn'
|
||||
>
|
||||
<CardBody className='flex-row items-center gap-2'>
|
||||
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
|
||||
<BsTencentQq size={16} />
|
||||
</span>
|
||||
<span>官方社群2</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
as={Link}
|
||||
shadow='sm'
|
||||
isPressable
|
||||
isExternal
|
||||
href='https://t.me/napcatqq'
|
||||
>
|
||||
<CardBody className='flex-row items-center gap-2'>
|
||||
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
|
||||
<BsTelegram size={16} />
|
||||
</span>
|
||||
<span>Telegram</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
as={Link}
|
||||
shadow='sm'
|
||||
isPressable
|
||||
isExternal
|
||||
href='https://napcat.napneko.icu/'
|
||||
>
|
||||
<CardBody className='flex-row items-center gap-2'>
|
||||
<span className='p-2 rounded-small bg-primary-50 text-primary-500'>
|
||||
<IoDocument size={16} />
|
||||
</span>
|
||||
<span>使用文档</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
<div className='flex flex-col md:flex-row md:items-start gap-4'>
|
||||
<div className='w-full flex flex-col gap-4'>
|
||||
{renderImage(
|
||||
'https://next.ossinsight.io/widgets/official/compose-recent-active-contributors/thumbnail.png?repo_id=777721566&limit=30&image_size=auto&color_scheme=light',
|
||||
'Contributors'
|
||||
)}
|
||||
{renderImage(
|
||||
'https://next.ossinsight.io/widgets/official/compose-activity-trends/thumbnail.png?repo_id=41986369&image_size=auto&color_scheme=light',
|
||||
'Activity Trends'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NapCatRepoInfo />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'motion/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { TbSquareRoundedChevronLeftFilled } from 'react-icons/tb';
|
||||
|
||||
import oneBotHttpApi from '@/const/ob_api';
|
||||
import type { OneBotHttpApi } from '@/const/ob_api';
|
||||
|
||||
import OneBotApiDebug from '@/components/onebot/api/debug';
|
||||
import OneBotApiNavList from '@/components/onebot/api/nav_list';
|
||||
|
||||
export default function HttpDebug () {
|
||||
const [selectedApi, setSelectedApi] =
|
||||
useState<keyof OneBotHttpApi>('/set_qq_profile');
|
||||
const data = oneBotHttpApi[selectedApi];
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [openSideBar, setOpenSideBar] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef?.current?.scrollTo?.({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [selectedApi]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>HTTP调试 - NapCat WebUI</title>
|
||||
<OneBotApiNavList
|
||||
data={oneBotHttpApi}
|
||||
selectedApi={selectedApi}
|
||||
onSelect={setSelectedApi}
|
||||
openSideBar={openSideBar}
|
||||
/>
|
||||
<div ref={contentRef} className='flex-1 h-full overflow-x-hidden'>
|
||||
<motion.div
|
||||
className='absolute top-16 z-30 md:!ml-4'
|
||||
animate={{ marginLeft: openSideBar ? '16rem' : '1rem' }}
|
||||
transition={{ type: 'spring', stiffness: 150, damping: 15 }}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='primary'
|
||||
radius='md'
|
||||
variant='shadow'
|
||||
size='sm'
|
||||
onPress={() => setOpenSideBar(!openSideBar)}
|
||||
>
|
||||
<TbSquareRoundedChevronLeftFilled
|
||||
size={24}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
openSideBar ? '' : 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</motion.div>
|
||||
<OneBotApiDebug path={selectedApi} data={data} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
export default function DebugPage () {
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { Input } from '@heroui/input';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useCallback, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import key from '@/const/key';
|
||||
|
||||
import OneBotMessageList from '@/components/onebot/message_list';
|
||||
import OneBotSendModal from '@/components/onebot/send_modal';
|
||||
import WSStatus from '@/components/onebot/ws_status';
|
||||
|
||||
import { useWebSocketDebug } from '@/hooks/use-websocket-debug';
|
||||
|
||||
export default function WSDebug () {
|
||||
const url = new URL(window.location.origin);
|
||||
url.port = '3001';
|
||||
url.protocol = 'ws:';
|
||||
const defaultWsUrl = url.href;
|
||||
const [socketConfig, setSocketConfig] = useLocalStorage(key.wsDebugConfig, {
|
||||
url: defaultWsUrl,
|
||||
token: '',
|
||||
});
|
||||
const [inputUrl, setInputUrl] = useState(socketConfig.url);
|
||||
const [inputToken, setInputToken] = useState(socketConfig.token);
|
||||
|
||||
const { sendMessage, readyState, FilterMessagesType, filteredMessages } =
|
||||
useWebSocketDebug(socketConfig.url, socketConfig.token);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
if (!inputUrl.startsWith('ws://') && !inputUrl.startsWith('wss://')) {
|
||||
toast.error('WebSocket URL 不合法');
|
||||
return;
|
||||
}
|
||||
setSocketConfig({
|
||||
url: inputUrl,
|
||||
token: inputToken,
|
||||
});
|
||||
}, [inputUrl, inputToken]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Websocket调试 - NapCat WebUI</title>
|
||||
<div className='h-[calc(100vh-4rem)] overflow-hidden flex flex-col'>
|
||||
<Card className='mx-2 mt-2 flex-shrink-0 bg-opacity-50 backdrop-blur-sm'>
|
||||
<CardBody className='gap-2'>
|
||||
<div className='grid gap-2 items-center md:grid-cols-5'>
|
||||
<Input
|
||||
className='col-span-2'
|
||||
label='WebSocket URL'
|
||||
type='text'
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
placeholder='输入 WebSocket URL'
|
||||
/>
|
||||
<Input
|
||||
className='col-span-2'
|
||||
label='Token'
|
||||
type='text'
|
||||
value={inputToken}
|
||||
onChange={(e) => setInputToken(e.target.value)}
|
||||
placeholder='输入 Token'
|
||||
/>
|
||||
<div className='flex-shrink-0 flex gap-2 col-span-2 md:col-span-1'>
|
||||
<Button
|
||||
color='primary'
|
||||
onPress={handleConnect}
|
||||
size='lg'
|
||||
radius='full'
|
||||
className='w-full md:w-auto'
|
||||
>
|
||||
连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='p-2 border border-default-100 bg-content1 bg-opacity-50 rounded-md dark:bg-[rgb(30,30,30)]'>
|
||||
<div className='grid gap-2 md:grid-cols-5 items-center md:w-fit'>
|
||||
<WSStatus state={readyState} />
|
||||
<div className='md:w-64 max-w-full col-span-2'>
|
||||
{FilterMessagesType}
|
||||
</div>
|
||||
<OneBotSendModal sendMessage={sendMessage} />
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
<OneBotMessageList messages={filteredMessages} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import { BreadcrumbItem, Breadcrumbs } from '@heroui/breadcrumbs';
|
||||
import { Button } from '@heroui/button';
|
||||
import { Input } from '@heroui/input';
|
||||
import type { Selection, SortDescriptor } from '@react-types/shared';
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'motion/react';
|
||||
import path from 'path-browserify';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import toast from 'react-hot-toast';
|
||||
import { FiDownload, FiMove, FiPlus, FiUpload } from 'react-icons/fi';
|
||||
import { MdRefresh } from 'react-icons/md';
|
||||
import { TbTrash } from 'react-icons/tb';
|
||||
import { TiArrowBack } from 'react-icons/ti';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import CreateFileModal from '@/components/file_manage/create_file_modal';
|
||||
import FileEditModal from '@/components/file_manage/file_edit_modal';
|
||||
import FilePreviewModal from '@/components/file_manage/file_preview_modal';
|
||||
import FileTable from '@/components/file_manage/file_table';
|
||||
import MoveModal from '@/components/file_manage/move_modal';
|
||||
import RenameModal from '@/components/file_manage/rename_modal';
|
||||
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
|
||||
import FileManager, { FileInfo } from '@/controllers/file_manager';
|
||||
|
||||
export default function FileManagerPage () {
|
||||
const [files, setFiles] = useState<FileInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
||||
column: 'name',
|
||||
direction: 'ascending',
|
||||
});
|
||||
const dialog = useDialog();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
// 修改 currentPath 初始化逻辑,去掉可能的前导斜杠
|
||||
let currentPath = decodeURIComponent(location.hash.slice(1) || '/');
|
||||
if (/^\/[A-Z]:$/i.test(currentPath)) {
|
||||
currentPath = currentPath.slice(1);
|
||||
}
|
||||
const [editingFile, setEditingFile] = useState<{
|
||||
path: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [newFileName, setNewFileName] = useState('');
|
||||
const [fileType, setFileType] = useState<'file' | 'directory'>('file');
|
||||
const [selectedFiles, setSelectedFiles] = useState<Selection>(new Set());
|
||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
|
||||
const [renamingFile, setRenamingFile] = useState<string>('');
|
||||
const [moveTargetPath, setMoveTargetPath] = useState('');
|
||||
const [jumpPath, setJumpPath] = useState('');
|
||||
const [previewFile, setPreviewFile] = useState<string>('');
|
||||
const [showUpload, setShowUpload] = useState<boolean>(false);
|
||||
|
||||
const sortFiles = (files: FileInfo[], descriptor: typeof sortDescriptor) => {
|
||||
return [...files].sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
const direction = descriptor.direction === 'ascending' ? 1 : -1;
|
||||
switch (descriptor.column) {
|
||||
case 'name':
|
||||
return direction * a.name.localeCompare(b.name);
|
||||
case 'type': {
|
||||
const aType = a.isDirectory ? '目录' : '文件';
|
||||
const bType = a.isDirectory ? '目录' : '文件';
|
||||
return direction * aType.localeCompare(bType);
|
||||
}
|
||||
case 'size':
|
||||
return direction * ((a.size || 0) - (b.size || 0));
|
||||
case 'mtime':
|
||||
return (
|
||||
direction *
|
||||
(new Date(a.mtime).getTime() - new Date(b.mtime).getTime())
|
||||
);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadFiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const fileList = await FileManager.listFiles(currentPath);
|
||||
setFiles(sortFiles(fileList, sortDescriptor));
|
||||
} catch (_error) {
|
||||
toast.error('加载文件列表失败');
|
||||
setFiles([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
}, [currentPath]);
|
||||
|
||||
const handleSortChange = (descriptor: typeof sortDescriptor) => {
|
||||
setSortDescriptor(descriptor);
|
||||
setFiles((prev) => sortFiles(prev, descriptor));
|
||||
};
|
||||
|
||||
const handleDirectoryClick = (dirPath: string) => {
|
||||
if (dirPath === '..') {
|
||||
if (/^[A-Z]:$/i.test(currentPath)) {
|
||||
navigate('/file_manager#/');
|
||||
return;
|
||||
}
|
||||
const parentPath = path.dirname(currentPath);
|
||||
navigate(
|
||||
`/file_manager#${encodeURIComponent(parentPath === currentPath ? '/' : parentPath)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigate(
|
||||
`/file_manager#${encodeURIComponent(path.join(currentPath, dirPath))}`
|
||||
);
|
||||
};
|
||||
|
||||
const handleEdit = async (filePath: string) => {
|
||||
try {
|
||||
const content = await FileManager.readFile(filePath);
|
||||
setEditingFile({ path: filePath, content });
|
||||
} catch (_error) {
|
||||
toast.error('打开文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingFile) return;
|
||||
try {
|
||||
await FileManager.writeFile(editingFile.path, editingFile.content);
|
||||
toast.success('保存成功');
|
||||
setEditingFile(null);
|
||||
loadFiles();
|
||||
} catch (_error) {
|
||||
toast.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (filePath: string) => {
|
||||
dialog.confirm({
|
||||
title: '删除文件',
|
||||
content: <div>确定要删除文件 {filePath} 吗?</div>,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await FileManager.delete(filePath);
|
||||
toast.success('删除成功');
|
||||
loadFiles();
|
||||
} catch (_error) {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newFileName) return;
|
||||
const newPath = path.join(currentPath, newFileName);
|
||||
try {
|
||||
if (fileType === 'directory') {
|
||||
if (!(await FileManager.createDirectory(newPath))) {
|
||||
toast.error('目录已存在');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!(await FileManager.createFile(newPath))) {
|
||||
toast.error('文件已存在');
|
||||
return;
|
||||
}
|
||||
}
|
||||
toast.success('创建成功');
|
||||
setIsCreateModalOpen(false);
|
||||
setNewFileName('');
|
||||
loadFiles();
|
||||
} catch (error) {
|
||||
toast.error((error as Error)?.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
const selectedArray =
|
||||
selectedFiles instanceof Set
|
||||
? Array.from(selectedFiles)
|
||||
: files.map((f) => f.name);
|
||||
if (selectedArray.length === 0) return;
|
||||
dialog.confirm({
|
||||
title: '批量删除',
|
||||
content: <div>确定要删除选中的 {selectedArray.length} 个项目吗?</div>,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const paths = selectedArray.map((key) =>
|
||||
path.join(currentPath, key.toString())
|
||||
);
|
||||
await FileManager.batchDelete(paths);
|
||||
toast.success('批量删除成功');
|
||||
setSelectedFiles(new Set());
|
||||
loadFiles();
|
||||
} catch (_error) {
|
||||
toast.error('批量删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renamingFile || !newFileName) return;
|
||||
try {
|
||||
await FileManager.rename(
|
||||
path.join(currentPath, renamingFile),
|
||||
path.join(currentPath, newFileName)
|
||||
);
|
||||
toast.success('重命名成功');
|
||||
setIsRenameModalOpen(false);
|
||||
setRenamingFile('');
|
||||
setNewFileName('');
|
||||
loadFiles();
|
||||
} catch (_error) {
|
||||
toast.error('重命名失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMove = async (sourceName: string) => {
|
||||
if (!moveTargetPath) return;
|
||||
try {
|
||||
await FileManager.move(
|
||||
path.join(currentPath, sourceName),
|
||||
path.join(moveTargetPath, sourceName)
|
||||
);
|
||||
toast.success('移动成功');
|
||||
setIsMoveModalOpen(false);
|
||||
setMoveTargetPath('');
|
||||
loadFiles();
|
||||
} catch (_error) {
|
||||
toast.error('移动失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchMove = async () => {
|
||||
if (!moveTargetPath) return;
|
||||
const selectedArray =
|
||||
selectedFiles instanceof Set
|
||||
? Array.from(selectedFiles)
|
||||
: files.map((f) => f.name);
|
||||
if (selectedArray.length === 0) return;
|
||||
try {
|
||||
const items = selectedArray.map((name) => ({
|
||||
sourcePath: path.join(currentPath, name.toString()),
|
||||
targetPath: path.join(moveTargetPath, name.toString()),
|
||||
}));
|
||||
await FileManager.batchMove(items);
|
||||
toast.success('批量移动成功');
|
||||
setIsMoveModalOpen(false);
|
||||
setMoveTargetPath('');
|
||||
setSelectedFiles(new Set());
|
||||
loadFiles();
|
||||
} catch (_error) {
|
||||
toast.error('批量移动失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyPath = (fileName: string) => {
|
||||
navigator.clipboard.writeText(path.join(currentPath, fileName));
|
||||
toast.success('路径已复制');
|
||||
};
|
||||
|
||||
const handleMoveClick = (fileName: string) => {
|
||||
setRenamingFile(fileName);
|
||||
setMoveTargetPath('');
|
||||
setIsMoveModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDownload = (filePath: string) => {
|
||||
FileManager.download(filePath);
|
||||
};
|
||||
|
||||
const handleBatchDownload = async () => {
|
||||
const selectedArray =
|
||||
selectedFiles instanceof Set
|
||||
? Array.from(selectedFiles)
|
||||
: files.map((f) => f.name);
|
||||
if (selectedArray.length === 0) return;
|
||||
const paths = selectedArray.map((key) =>
|
||||
path.join(currentPath, key.toString())
|
||||
);
|
||||
await FileManager.batchDownload(paths);
|
||||
};
|
||||
|
||||
const handlePreview = (filePath: string) => {
|
||||
setPreviewFile(filePath);
|
||||
};
|
||||
|
||||
const onDrop = async (acceptedFiles: File[]) => {
|
||||
try {
|
||||
// 遍历处理文件,保持文件夹结构
|
||||
const processedFiles = acceptedFiles.map((file) => {
|
||||
const relativePath = file.webkitRelativePath || file.name;
|
||||
// 不需要额外的编码处理,浏览器会自动处理
|
||||
return new File([file], relativePath, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
});
|
||||
});
|
||||
|
||||
toast
|
||||
.promise(FileManager.upload(currentPath, processedFiles), {
|
||||
loading: '正在上传文件...',
|
||||
success: '上传成功',
|
||||
error: '上传失败',
|
||||
})
|
||||
.then(() => {
|
||||
loadFiles();
|
||||
});
|
||||
} catch (_error) {
|
||||
toast.error('上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
noClick: true,
|
||||
onDragOver: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
useFsAccessApi: false, // 添加此选项以避免某些浏览器的文件系统API问题
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='p-4'>
|
||||
<div className='mb-4 flex items-center gap-4 sticky top-14 z-10 bg-content1 py-1'>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={() => handleDirectoryClick('..')}
|
||||
className='text-lg'
|
||||
>
|
||||
<TiArrowBack />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={() => setIsCreateModalOpen(true)}
|
||||
className='text-lg'
|
||||
>
|
||||
<FiPlus />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color='primary'
|
||||
isLoading={loading}
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={loadFiles}
|
||||
className='text-lg'
|
||||
>
|
||||
<MdRefresh />
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
isIconOnly
|
||||
variant='flat'
|
||||
onPress={() => setShowUpload((prev) => !prev)}
|
||||
className='text-lg'
|
||||
>
|
||||
<FiUpload />
|
||||
</Button>
|
||||
|
||||
{((selectedFiles instanceof Set && selectedFiles.size > 0) ||
|
||||
selectedFiles === 'all') && (
|
||||
<>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
onPress={handleBatchDelete}
|
||||
className='text-sm'
|
||||
startContent={<TbTrash className='text-lg' />}
|
||||
>
|
||||
(
|
||||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||
)
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
onPress={() => {
|
||||
setMoveTargetPath('');
|
||||
setIsMoveModalOpen(true);
|
||||
}}
|
||||
className='text-sm'
|
||||
startContent={<FiMove className='text-lg' />}
|
||||
>
|
||||
(
|
||||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||
)
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
onPress={handleBatchDownload}
|
||||
className='text-sm'
|
||||
startContent={<FiDownload className='text-lg' />}
|
||||
>
|
||||
(
|
||||
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
|
||||
)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Breadcrumbs className='flex-1 shadow-small px-2 py-2 rounded-lg'>
|
||||
{currentPath.split('/').map((part, index, parts) => (
|
||||
<BreadcrumbItem
|
||||
key={part}
|
||||
isCurrent={index === parts.length - 1}
|
||||
onPress={() => {
|
||||
const newPath = parts.slice(0, index + 1).join('/');
|
||||
navigate(`/file_manager#${encodeURIComponent(newPath)}`);
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='输入跳转路径'
|
||||
value={jumpPath}
|
||||
onChange={(e) => setJumpPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && jumpPath.trim() !== '') {
|
||||
navigate(`/file_manager#${encodeURIComponent(jumpPath.trim())}`);
|
||||
}
|
||||
}}
|
||||
className='ml-auto w-64'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: showUpload ? 'auto' : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={clsx(
|
||||
'border-dashed rounded-lg text-center',
|
||||
isDragActive ? 'border-primary bg-primary/10' : 'border-default-300',
|
||||
showUpload ? 'mb-4 border-2' : 'border-none'
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div {...getRootProps()} className='w-full h-full p-4'>
|
||||
<input {...getInputProps()} multiple />
|
||||
<p>拖拽文件或文件夹到此处上传,或点击选择文件</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<FileTable
|
||||
files={files}
|
||||
currentPath={currentPath}
|
||||
loading={loading}
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={handleSortChange}
|
||||
selectedFiles={selectedFiles}
|
||||
onSelectionChange={setSelectedFiles}
|
||||
onDirectoryClick={handleDirectoryClick}
|
||||
onEdit={handleEdit}
|
||||
onPreview={handlePreview}
|
||||
onRenameRequest={(name) => {
|
||||
setRenamingFile(name);
|
||||
setNewFileName(name);
|
||||
setIsRenameModalOpen(true);
|
||||
}}
|
||||
onMoveRequest={handleMoveClick}
|
||||
onCopyPath={handleCopyPath}
|
||||
onDelete={handleDelete}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
|
||||
<FileEditModal
|
||||
isOpen={!!editingFile}
|
||||
file={editingFile}
|
||||
onClose={() => setEditingFile(null)}
|
||||
onSave={handleSave}
|
||||
onContentChange={(newContent) =>
|
||||
setEditingFile((prev) =>
|
||||
prev ? { ...prev, content: newContent ?? '' } : null
|
||||
)}
|
||||
/>
|
||||
|
||||
<FilePreviewModal
|
||||
isOpen={!!previewFile}
|
||||
filePath={previewFile}
|
||||
onClose={() => setPreviewFile('')}
|
||||
/>
|
||||
|
||||
<CreateFileModal
|
||||
isOpen={isCreateModalOpen}
|
||||
fileType={fileType}
|
||||
newFileName={newFileName}
|
||||
onTypeChange={setFileType}
|
||||
onNameChange={(e) => setNewFileName(e.target.value)}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
|
||||
<RenameModal
|
||||
isOpen={isRenameModalOpen}
|
||||
newFileName={newFileName}
|
||||
onNameChange={(e) => setNewFileName(e.target.value)}
|
||||
onClose={() => setIsRenameModalOpen(false)}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
|
||||
<MoveModal
|
||||
isOpen={isMoveModalOpen}
|
||||
moveTargetPath={moveTargetPath}
|
||||
selectionInfo={
|
||||
selectedFiles instanceof Set && selectedFiles.size > 0
|
||||
? `${selectedFiles.size} 个项目`
|
||||
: renamingFile
|
||||
}
|
||||
onClose={() => setIsMoveModalOpen(false)}
|
||||
onMove={() =>
|
||||
selectedFiles instanceof Set && selectedFiles.size > 0
|
||||
? handleBatchMove()
|
||||
: handleMove(renamingFile)}
|
||||
onSelect={(dir) => setMoveTargetPath(dir)} // 替换原有 onTargetChange
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
packages/napcat-webui-frontend/src/pages/dashboard/index.tsx
Normal file
118
packages/napcat-webui-frontend/src/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Card, CardBody } from '@heroui/card';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import NetworkItemDisplay from '@/components/display_network_item';
|
||||
import Hitokoto from '@/components/hitokoto';
|
||||
import QQInfoCard from '@/components/qq_info_card';
|
||||
import SystemInfo from '@/components/system_info';
|
||||
import SystemStatusDisplay from '@/components/system_status_display';
|
||||
|
||||
import useConfig from '@/hooks/use-config';
|
||||
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
const Networks: React.FC = () => {
|
||||
const { config, refreshConfig } = useConfig();
|
||||
const allNetWorkConfigLength =
|
||||
config.network.httpClients.length +
|
||||
config.network.websocketClients.length +
|
||||
config.network.websocketServers.length +
|
||||
config.network.httpServers.length;
|
||||
|
||||
useEffect(() => {
|
||||
refreshConfig();
|
||||
}, []);
|
||||
return (
|
||||
<div className='grid grid-cols-8 md:grid-cols-3 lg:grid-cols-6 gap-y-2 gap-x-1 md:gap-y-4 md:gap-x-4 py-5'>
|
||||
<NetworkItemDisplay count={allNetWorkConfigLength} label='网络配置' />
|
||||
<NetworkItemDisplay
|
||||
count={config.network.httpServers.length}
|
||||
label='HTTP服务器'
|
||||
size='sm'
|
||||
/>
|
||||
<NetworkItemDisplay
|
||||
count={config.network.httpClients.length}
|
||||
label='HTTP客户端'
|
||||
size='sm'
|
||||
/>
|
||||
<NetworkItemDisplay
|
||||
count={config.network.websocketServers.length}
|
||||
label='WS服务器'
|
||||
size='sm'
|
||||
/>
|
||||
<NetworkItemDisplay
|
||||
count={config.network.websocketClients.length}
|
||||
label='WS客户端'
|
||||
size='sm'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const QQInfo: React.FC = () => {
|
||||
const { data, loading, error } = useRequest(QQManager.getQQLoginInfo);
|
||||
return <QQInfoCard data={data} error={error} loading={loading} />;
|
||||
};
|
||||
|
||||
export interface SystemStatusCardProps {
|
||||
setArchInfo: (arch: string | undefined) => void;
|
||||
}
|
||||
const SystemStatusCard: React.FC<SystemStatusCardProps> = ({ setArchInfo }) => {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>();
|
||||
const isSetted = useRef(false);
|
||||
const getStatus = useCallback(() => {
|
||||
try {
|
||||
const event = WebUIManager.getSystemStatus(setSystemStatus);
|
||||
return event;
|
||||
} catch (_error) {
|
||||
toast.error('获取系统状态失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const close = getStatus();
|
||||
return () => {
|
||||
close?.close();
|
||||
};
|
||||
}, [getStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (systemStatus?.arch && !isSetted.current) {
|
||||
setArchInfo(systemStatus.arch);
|
||||
isSetted.current = true;
|
||||
}
|
||||
}, [systemStatus, setArchInfo]);
|
||||
|
||||
return <SystemStatusDisplay data={systemStatus} />;
|
||||
};
|
||||
|
||||
const DashboardIndexPage: React.FC = () => {
|
||||
const [archInfo, setArchInfo] = useState<string>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>基础信息 - NapCat WebUI</title>
|
||||
<section className='w-full p-2 md:p-4 md:max-w-[1000px] mx-auto'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<QQInfo />
|
||||
<SystemInfo archInfo={archInfo} />
|
||||
</div>
|
||||
<SystemStatusCard setArchInfo={setArchInfo} />
|
||||
</div>
|
||||
<Networks />
|
||||
<Card className='bg-opacity-60 shadow-sm shadow-primary-100'>
|
||||
<CardBody>
|
||||
<Hitokoto />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardIndexPage;
|
||||
79
packages/napcat-webui-frontend/src/pages/dashboard/logs.tsx
Normal file
79
packages/napcat-webui-frontend/src/pages/dashboard/logs.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import HistoryLogs from '@/components/log_com/history';
|
||||
import RealTimeLogs from '@/components/log_com/realtime';
|
||||
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
|
||||
export default function LogsPage () {
|
||||
const {
|
||||
data: logList,
|
||||
loading: logListLoading,
|
||||
error: logListError,
|
||||
refresh: refreshLogList,
|
||||
} = useRequest(WebUIManager.getLogList);
|
||||
const [selectedLog, setSelectedLog] = useState<string | null>(null);
|
||||
const [logContent, setLogContent] = useState<string | null>(null);
|
||||
const [logLoading, setLogLoading] = useState<boolean>(false);
|
||||
const onLogSelect = (name: string) => {
|
||||
setSelectedLog(name);
|
||||
};
|
||||
const onLoadLog = async () => {
|
||||
if (!selectedLog) {
|
||||
return;
|
||||
}
|
||||
setLogLoading(true);
|
||||
try {
|
||||
const result = await WebUIManager.getLogContent(selectedLog);
|
||||
setLogContent(result);
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
toast.error(`加载日志失败: ${msg}`);
|
||||
} finally {
|
||||
setLogLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (logList && logList.length > 0) {
|
||||
setSelectedLog(logList[0]);
|
||||
}
|
||||
}, [logList]);
|
||||
useEffect(() => {
|
||||
if (selectedLog) {
|
||||
onLoadLog();
|
||||
}
|
||||
}, [selectedLog]);
|
||||
return (
|
||||
<div className='h-[calc(100vh_-_8rem)] flex flex-col gap-4 items-center pt-4 px-2'>
|
||||
<Tabs
|
||||
aria-label='Logs'
|
||||
classNames={{
|
||||
panel: 'w-full flex-1 h-full py-0 flex flex-col gap-4',
|
||||
base: 'flex-shrink-0 !h-fit',
|
||||
tabList: 'bg-opacity-50 backdrop-blur-sm',
|
||||
cursor: 'bg-opacity-60 backdrop-blur-sm',
|
||||
}}
|
||||
>
|
||||
<Tab title='实时日志'>
|
||||
<RealTimeLogs />
|
||||
</Tab>
|
||||
<Tab title='历史日志'>
|
||||
<HistoryLogs
|
||||
list={logList || []}
|
||||
onSelect={onLogSelect}
|
||||
selectedLog={selectedLog || undefined}
|
||||
refreshList={refreshLogList}
|
||||
refreshLog={onLoadLog}
|
||||
listLoading={logListLoading}
|
||||
logLoading={logLoading}
|
||||
listError={logListError}
|
||||
logContent={logContent || undefined}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
407
packages/napcat-webui-frontend/src/pages/dashboard/network.tsx
Normal file
407
packages/napcat-webui-frontend/src/pages/dashboard/network.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { useDisclosure } from '@heroui/modal';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoMdRefresh } from 'react-icons/io';
|
||||
|
||||
import AddButton from '@/components/button/add_button';
|
||||
import HTTPClientDisplayCard from '@/components/display_card/http_client';
|
||||
import HTTPServerDisplayCard from '@/components/display_card/http_server';
|
||||
import HTTPSSEServerDisplayCard from '@/components/display_card/http_sse_server';
|
||||
import WebsocketClientDisplayCard from '@/components/display_card/ws_client';
|
||||
import WebsocketServerDisplayCard from '@/components/display_card/ws_server';
|
||||
import NetworkFormModal from '@/components/network_edit/modal';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import useConfig from '@/hooks/use-config';
|
||||
import useDialog from '@/hooks/use-dialog';
|
||||
|
||||
export interface SectionProps {
|
||||
title: string;
|
||||
color?:
|
||||
| 'violet'
|
||||
| 'yellow'
|
||||
| 'blue'
|
||||
| 'cyan'
|
||||
| 'green'
|
||||
| 'pink'
|
||||
| 'foreground';
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface EmptySectionProps {
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
const EmptySection: React.FC<EmptySectionProps> = ({ isEmpty }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx('text-default-400', {
|
||||
hidden: !isEmpty,
|
||||
})}
|
||||
>
|
||||
暂时还没有配置项哈
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function NetworkPage () {
|
||||
const {
|
||||
config,
|
||||
refreshConfig,
|
||||
deleteNetworkConfig,
|
||||
enableNetworkConfig,
|
||||
enableDebugNetworkConfig,
|
||||
} = useConfig();
|
||||
const [activeField, setActiveField] =
|
||||
useState<keyof OneBotConfig['network']>('httpServers');
|
||||
const [activeName, setActiveName] = useState<string>('');
|
||||
const {
|
||||
network: {
|
||||
httpServers,
|
||||
httpClients,
|
||||
httpSseServers,
|
||||
websocketServers,
|
||||
websocketClients,
|
||||
},
|
||||
} = config;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
const dialog = useDialog();
|
||||
const activeData = useMemo(() => {
|
||||
const findData = config.network[activeField].find(
|
||||
(item) => item.name === activeName
|
||||
);
|
||||
|
||||
return findData;
|
||||
}, [activeField, activeName, config]);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await refreshConfig();
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
|
||||
toast.error(`获取配置失败: ${msg}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickCreate = (key: keyof OneBotConfig['network']) => {
|
||||
setActiveField(key);
|
||||
setActiveName('');
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const onDelete = async (
|
||||
field: keyof OneBotConfig['network'],
|
||||
name: string
|
||||
) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
dialog.confirm({
|
||||
title: '删除配置',
|
||||
content: `确定要删除配置「${name}」吗?`,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteNetworkConfig(field, name);
|
||||
toast.success('删除配置成功');
|
||||
resolve();
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
|
||||
toast.error(`删除配置失败: ${msg}`);
|
||||
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onEnable = async (
|
||||
field: keyof OneBotConfig['network'],
|
||||
name: string
|
||||
) => {
|
||||
try {
|
||||
await enableNetworkConfig(field, name);
|
||||
toast.success('更新配置成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
|
||||
toast.error(`更新配置失败: ${msg}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const onEnableDebug = async (
|
||||
field: keyof OneBotConfig['network'],
|
||||
name: string
|
||||
) => {
|
||||
try {
|
||||
await enableDebugNetworkConfig(field, name);
|
||||
toast.success('更新配置成功');
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
|
||||
toast.error(`更新配置失败: ${msg}`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const onEdit = (field: keyof OneBotConfig['network'], name: string) => {
|
||||
setActiveField(field);
|
||||
setActiveName(name);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const renderCard = <T extends keyof OneBotConfig['network']> (
|
||||
type: T,
|
||||
item: OneBotConfig['network'][T][0],
|
||||
showType = false
|
||||
) => {
|
||||
switch (type) {
|
||||
case 'httpServers':
|
||||
return (
|
||||
<HTTPServerDisplayCard
|
||||
key={item.name}
|
||||
showType={showType}
|
||||
data={item as OneBotConfig['network']['httpServers'][0]}
|
||||
onDelete={async () => {
|
||||
await onDelete('httpServers', item.name);
|
||||
}}
|
||||
onEdit={() => {
|
||||
onEdit('httpServers', item.name);
|
||||
}}
|
||||
onEnable={async () => {
|
||||
await onEnable('httpServers', item.name);
|
||||
}}
|
||||
onEnableDebug={async () => {
|
||||
await onEnableDebug('httpServers', item.name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'httpClients':
|
||||
return (
|
||||
<HTTPClientDisplayCard
|
||||
key={item.name}
|
||||
showType={showType}
|
||||
data={item as OneBotConfig['network']['httpClients'][0]}
|
||||
onDelete={async () => {
|
||||
await onDelete('httpClients', item.name);
|
||||
}}
|
||||
onEdit={() => {
|
||||
onEdit('httpClients', item.name);
|
||||
}}
|
||||
onEnable={async () => {
|
||||
await onEnable('httpClients', item.name);
|
||||
}}
|
||||
onEnableDebug={async () => {
|
||||
await onEnableDebug('httpClients', item.name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'httpSseServers':
|
||||
return (
|
||||
<HTTPSSEServerDisplayCard
|
||||
key={item.name}
|
||||
showType={showType}
|
||||
data={item as OneBotConfig['network']['httpSseServers'][0]}
|
||||
onDelete={async () => {
|
||||
await onDelete('httpSseServers', item.name);
|
||||
}}
|
||||
onEdit={() => {
|
||||
onEdit('httpSseServers', item.name);
|
||||
}}
|
||||
onEnable={async () => {
|
||||
await onEnable('httpSseServers', item.name);
|
||||
}}
|
||||
onEnableDebug={async () => {
|
||||
await onEnableDebug('httpSseServers', item.name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'websocketServers':
|
||||
return (
|
||||
<WebsocketServerDisplayCard
|
||||
key={item.name}
|
||||
showType={showType}
|
||||
data={item as OneBotConfig['network']['websocketServers'][0]}
|
||||
onDelete={async () => {
|
||||
await onDelete('websocketServers', item.name);
|
||||
}}
|
||||
onEdit={() => {
|
||||
onEdit('websocketServers', item.name);
|
||||
}}
|
||||
onEnable={async () => {
|
||||
await onEnable('websocketServers', item.name);
|
||||
}}
|
||||
onEnableDebug={async () => {
|
||||
await onEnableDebug('websocketServers', item.name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'websocketClients':
|
||||
return (
|
||||
<WebsocketClientDisplayCard
|
||||
key={item.name}
|
||||
showType={showType}
|
||||
data={item as OneBotConfig['network']['websocketClients'][0]}
|
||||
onDelete={async () => {
|
||||
await onDelete('websocketClients', item.name);
|
||||
}}
|
||||
onEdit={() => {
|
||||
onEdit('websocketClients', item.name);
|
||||
}}
|
||||
onEnable={async () => {
|
||||
await onEnable('websocketClients', item.name);
|
||||
}}
|
||||
onEnableDebug={async () => {
|
||||
await onEnableDebug('websocketClients', item.name);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'all',
|
||||
title: '全部',
|
||||
items: [
|
||||
...httpServers,
|
||||
...httpClients,
|
||||
...websocketServers,
|
||||
...websocketClients,
|
||||
...httpSseServers,
|
||||
]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((item) => {
|
||||
if (httpServers.find((i) => i.name === item.name)) {
|
||||
return renderCard(
|
||||
'httpServers',
|
||||
item as OneBotConfig['network']['httpServers'][0],
|
||||
true
|
||||
);
|
||||
}
|
||||
if (httpSseServers.find((i) => i.name === item.name)) {
|
||||
return renderCard(
|
||||
'httpSseServers',
|
||||
item as OneBotConfig['network']['httpSseServers'][0],
|
||||
true
|
||||
);
|
||||
}
|
||||
if (httpClients.find((i) => i.name === item.name)) {
|
||||
return renderCard(
|
||||
'httpClients',
|
||||
item as OneBotConfig['network']['httpClients'][0],
|
||||
true
|
||||
);
|
||||
}
|
||||
if (websocketServers.find((i) => i.name === item.name)) {
|
||||
return renderCard(
|
||||
'websocketServers',
|
||||
item as OneBotConfig['network']['websocketServers'][0],
|
||||
true
|
||||
);
|
||||
}
|
||||
if (websocketClients.find((i) => i.name === item.name)) {
|
||||
return renderCard(
|
||||
'websocketClients',
|
||||
item as OneBotConfig['network']['websocketClients'][0],
|
||||
true
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'httpServers',
|
||||
title: 'HTTP服务器',
|
||||
items: httpServers.map((item) => renderCard('httpServers', item)),
|
||||
},
|
||||
{
|
||||
key: 'httpClients',
|
||||
title: 'HTTP客户端',
|
||||
items: httpClients.map((item) => renderCard('httpClients', item)),
|
||||
},
|
||||
{
|
||||
key: 'httpSseServers',
|
||||
title: 'HTTP SSE服务器',
|
||||
items: httpSseServers.map((item) => renderCard('httpSseServers', item)),
|
||||
},
|
||||
{
|
||||
key: 'websocketServers',
|
||||
title: 'Websocket服务器',
|
||||
items: websocketServers.map((item) =>
|
||||
renderCard('websocketServers', item)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'websocketClients',
|
||||
title: 'Websocket客户端',
|
||||
items: websocketClients.map((item) =>
|
||||
renderCard('websocketClients', item)
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>网络配置 - NapCat WebUI</title>
|
||||
<div className='p-2 md:p-4 relative'>
|
||||
<NetworkFormModal
|
||||
data={activeData}
|
||||
field={activeField}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
<PageLoading loading={loading} />
|
||||
<div className='flex mb-6 items-center gap-4'>
|
||||
<AddButton onOpen={handleClickCreate} />
|
||||
<Button
|
||||
isIconOnly
|
||||
color='primary'
|
||||
radius='full'
|
||||
variant='flat'
|
||||
onPress={refresh}
|
||||
>
|
||||
<IoMdRefresh size={24} />
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs
|
||||
aria-label='Network Configs'
|
||||
className='max-w-full'
|
||||
items={tabs}
|
||||
classNames={{
|
||||
tabList: 'bg-opacity-50 backdrop-blur-sm',
|
||||
cursor: 'bg-opacity-60 backdrop-blur-sm',
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<Tab key={item.key} title={item.title}>
|
||||
<EmptySection isEmpty={!item.items.length} />
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 justify-start items-stretch gap-x-2 gap-y-4'>
|
||||
{item.items}
|
||||
</div>
|
||||
</Tab>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
171
packages/napcat-webui-frontend/src/pages/dashboard/terminal.tsx
Normal file
171
packages/napcat-webui-frontend/src/pages/dashboard/terminal.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Button } from '@heroui/button';
|
||||
import { useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoAdd, IoClose } from 'react-icons/io5';
|
||||
|
||||
import { TabList, TabPanel, Tabs } from '@/components/tabs';
|
||||
import { SortableTab } from '@/components/tabs/sortable_tab.tsx';
|
||||
import { TerminalInstance } from '@/components/terminal/terminal-instance';
|
||||
|
||||
import terminalManager from '@/controllers/terminal_manager';
|
||||
|
||||
interface TerminalTab {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function TerminalPage () {
|
||||
const [tabs, setTabs] = useState<TerminalTab[]>([]);
|
||||
const [selectedTab, setSelectedTab] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
// 获取已存在的终端列表
|
||||
terminalManager.getTerminalList().then((terminals) => {
|
||||
if (terminals.length === 0) return;
|
||||
|
||||
const newTabs = terminals.map((terminal) => ({
|
||||
id: terminal.id,
|
||||
title: terminal.id,
|
||||
}));
|
||||
|
||||
setTabs(newTabs);
|
||||
setSelectedTab(newTabs[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const createNewTerminal = async () => {
|
||||
try {
|
||||
const { id } = await terminalManager.createTerminal(80, 24);
|
||||
const newTab = {
|
||||
id,
|
||||
title: id,
|
||||
};
|
||||
|
||||
setTabs((prev) => [...prev, newTab]);
|
||||
setSelectedTab(id);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to create terminal:', error);
|
||||
toast.error((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const closeTerminal = async (id: string) => {
|
||||
try {
|
||||
await terminalManager.closeTerminal(id);
|
||||
terminalManager.removeTerminal(id);
|
||||
if (selectedTab === id) {
|
||||
const previousIndex = tabs.findIndex((tab) => tab.id === id) - 1;
|
||||
if (previousIndex >= 0) {
|
||||
setSelectedTab(tabs[previousIndex].id);
|
||||
} else {
|
||||
setSelectedTab(tabs[0]?.id || '');
|
||||
}
|
||||
}
|
||||
setTabs((prev) => prev.filter((tab) => tab.id !== id));
|
||||
} catch (_error) {
|
||||
toast.error('关闭终端失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
setTabs((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over?.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-2 p-4 h-[calc(100vh-6rem)] md:h-[calc(100vh-4rem)]'>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={selectedTab}
|
||||
onChange={setSelectedTab}
|
||||
className='h-full overflow-hidden'
|
||||
>
|
||||
<div className='flex items-center gap-2 flex-shrink-0 flex-grow-0'>
|
||||
<TabList className='flex-1 !overflow-x-auto w-full hide-scrollbar'>
|
||||
<SortableContext
|
||||
items={tabs}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
id={tab.id}
|
||||
value={tab.id}
|
||||
isSelected={selectedTab === tab.id}
|
||||
className='flex gap-2 items-center flex-shrink-0'
|
||||
>
|
||||
{tab.title}
|
||||
<Button
|
||||
isIconOnly
|
||||
radius='full'
|
||||
variant='flat'
|
||||
size='sm'
|
||||
className='min-w-0 w-4 h-4 flex-shrink-0'
|
||||
onPress={() => closeTerminal(tab.id)}
|
||||
color={selectedTab === tab.id ? 'primary' : 'default'}
|
||||
>
|
||||
<IoClose />
|
||||
</Button>
|
||||
</SortableTab>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TabList>
|
||||
<Button
|
||||
isIconOnly
|
||||
color='primary'
|
||||
size='sm'
|
||||
variant='flat'
|
||||
onPress={createNewTerminal}
|
||||
startContent={<IoAdd />}
|
||||
className='text-xl'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-grow overflow-hidden'>
|
||||
{tabs.length === 0 && (
|
||||
<div className='flex flex-col gap-2 items-center justify-center h-full text-gray-500 py-5'>
|
||||
<IoAdd className='text-4xl' />
|
||||
<div className='text-sm'>点击右上角按钮创建终端</div>
|
||||
</div>
|
||||
)}
|
||||
{tabs.map((tab) => (
|
||||
<TabPanel key={tab.id} value={tab.id} className='h-full'>
|
||||
<TerminalInstance id={tab.id} />
|
||||
</TabPanel>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
packages/napcat-webui-frontend/src/pages/index.tsx
Normal file
36
packages/napcat-webui-frontend/src/pages/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Spinner } from '@heroui/spinner';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Suspense } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import DefaultLayout from '@/layouts/default';
|
||||
|
||||
export default function IndexPage () {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='flex justify-center px-10'>
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnimatePresence mode='wait'>
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
type: 'tween',
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</Suspense>
|
||||
</DefaultLayout>
|
||||
);
|
||||
}
|
||||
176
packages/napcat-webui-frontend/src/pages/qq_login.tsx
Normal file
176
packages/napcat-webui-frontend/src/pages/qq_login.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { CardBody, CardHeader } from '@heroui/card';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import HoverEffectCard from '@/components/effect_card';
|
||||
import { title } from '@/components/primitives';
|
||||
import QrCodeLogin from '@/components/qr_code_login';
|
||||
import QuickLogin from '@/components/quick_login';
|
||||
import type { QQItem } from '@/components/quick_login';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import QQManager from '@/controllers/qq_manager';
|
||||
import PureLayout from '@/layouts/pure';
|
||||
|
||||
export default function QQLoginPage () {
|
||||
const navigate = useNavigate();
|
||||
const [uinValue, setUinValue] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [qrcode, setQrcode] = useState<string>('');
|
||||
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
|
||||
const [refresh, setRefresh] = useState<boolean>(false);
|
||||
const firstLoad = useRef<boolean>(true);
|
||||
const onSubmit = async () => {
|
||||
if (!uinValue) {
|
||||
toast.error('请选择快捷登录的QQ');
|
||||
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await QQManager.setQuickLogin(uinValue);
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
|
||||
toast.error(`快速登录QQ失败: ${msg}`);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateQrCode = async () => {
|
||||
if (firstLoad.current) setIsLoading(true);
|
||||
try {
|
||||
const data = await QQManager.checkQQLoginStatusWithQrcode();
|
||||
|
||||
if (firstLoad.current) {
|
||||
setIsLoading(false);
|
||||
firstLoad.current = false;
|
||||
}
|
||||
if (data.isLogin) {
|
||||
toast.success('QQ登录成功');
|
||||
navigate('/', { replace: true });
|
||||
} else {
|
||||
setQrcode(data.qrcodeurl);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message;
|
||||
|
||||
toast.error(`获取二维码失败: ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateQQList = async () => {
|
||||
setRefresh(true);
|
||||
try {
|
||||
const data = await QQManager.getQQQuickLoginListNew();
|
||||
setQQList(data);
|
||||
} catch (_error) {
|
||||
try {
|
||||
const data = await QQManager.getQQQuickLoginList();
|
||||
|
||||
const qqList = data.map((item) => ({
|
||||
uin: item,
|
||||
}));
|
||||
|
||||
setQQList(qqList);
|
||||
} catch (_error) {
|
||||
const msg = (_error as Error).message;
|
||||
|
||||
toast.error(`获取QQ列表失败: ${msg}`);
|
||||
}
|
||||
} finally {
|
||||
setRefresh(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement> = (
|
||||
e
|
||||
) => {
|
||||
setUinValue(e.target.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
onUpdateQrCode();
|
||||
}, 3000);
|
||||
|
||||
onUpdateQrCode();
|
||||
onUpdateQQList();
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>QQ登录 - NapCat WebUI</title>
|
||||
<PureLayout>
|
||||
<div className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'>
|
||||
<HoverEffectCard
|
||||
className='items-center gap-4 pt-0 pb-6 bg-default-50'
|
||||
maxXRotation={3}
|
||||
maxYRotation={3}
|
||||
>
|
||||
<CardHeader className='inline-block max-w-lg text-center justify-center'>
|
||||
<div className='flex items-center justify-center w-full gap-2 pt-10'>
|
||||
<Image alt='logo' height='7em' src={logo} />
|
||||
<div>
|
||||
<span className={title()}>Web </span>
|
||||
<span className={title({ color: 'violet' })}>
|
||||
Login
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeSwitch className='absolute right-4 top-4' />
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className='flex gap-5 p-10 pt-0'>
|
||||
<Tabs
|
||||
fullWidth
|
||||
classNames={{
|
||||
tabList: 'shadow-sm dark:shadow-none',
|
||||
}}
|
||||
isDisabled={isLoading}
|
||||
size='lg'
|
||||
>
|
||||
<Tab key='shortcut' title='快速登录'>
|
||||
<QuickLogin
|
||||
handleSelectionChange={handleSelectionChange}
|
||||
isLoading={isLoading}
|
||||
qqList={qqList}
|
||||
refresh={refresh}
|
||||
selectedQQ={uinValue}
|
||||
onSubmit={onSubmit}
|
||||
onUpdateQQList={onUpdateQQList}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab key='qrcode' title='扫码登录'>
|
||||
<QrCodeLogin qrcode={qrcode} />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Button
|
||||
className='w-fit mx-auto'
|
||||
variant='light'
|
||||
color='primary'
|
||||
onPress={() => {
|
||||
navigate('/web_login', {
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
返回 Web Login
|
||||
</Button>
|
||||
</CardBody>
|
||||
</HoverEffectCard>
|
||||
</div>
|
||||
</PureLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
181
packages/napcat-webui-frontend/src/pages/web_login.tsx
Normal file
181
packages/napcat-webui-frontend/src/pages/web_login.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { Button } from '@heroui/button';
|
||||
import { CardBody, CardHeader } from '@heroui/card';
|
||||
import { Image } from '@heroui/image';
|
||||
import { Input } from '@heroui/input';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { IoKeyOutline } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import key from '@/const/key';
|
||||
|
||||
import HoverEffectCard from '@/components/effect_card';
|
||||
import { title } from '@/components/primitives';
|
||||
import { ThemeSwitch } from '@/components/theme-switch';
|
||||
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import WebUIManager from '@/controllers/webui_manager';
|
||||
import PureLayout from '@/layouts/pure';
|
||||
|
||||
export default function WebLoginPage () {
|
||||
const urlSearchParams = new URLSearchParams(window.location.search);
|
||||
const token = urlSearchParams.get('token');
|
||||
const navigate = useNavigate();
|
||||
const [tokenValue, setTokenValue] = useState<string>(token || '');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [, setLocalToken] = useLocalStorage<string>(key.token, '');
|
||||
const onSubmit = async () => {
|
||||
if (!tokenValue) {
|
||||
toast.error('请输入token');
|
||||
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await WebUIManager.loginWithToken(tokenValue);
|
||||
|
||||
if (data) {
|
||||
setLocalToken(data);
|
||||
navigate('/qq_login', { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理全局键盘事件
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isLoading) {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [tokenValue, isLoading]); // 依赖项包含用于登录的状态
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
onSubmit();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>WebUI登录 - NapCat WebUI</title>
|
||||
<PureLayout>
|
||||
<div className='w-[608px] max-w-full py-8 px-2 md:px-8 overflow-hidden'>
|
||||
<HoverEffectCard
|
||||
className='items-center gap-4 pt-0 pb-6 bg-default-50'
|
||||
maxXRotation={3}
|
||||
maxYRotation={3}
|
||||
>
|
||||
<CardHeader className='inline-block max-w-lg text-center justify-center'>
|
||||
<div className='flex items-center justify-center w-full gap-2 pt-10'>
|
||||
<Image alt='logo' height='7em' src={logo} />
|
||||
<div>
|
||||
<span className={title()}>Web </span>
|
||||
<span className={title({ color: 'violet' })}>
|
||||
Login
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ThemeSwitch className='absolute right-4 top-4' />
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className='flex gap-5 py-5 px-5 md:px-10'>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
{/* 隐藏的用户名字段,帮助浏览器识别登录表单 */}
|
||||
<input
|
||||
type='text'
|
||||
name='username'
|
||||
value='napcat-webui'
|
||||
autoComplete='username'
|
||||
className='absolute -left-[9999px] opacity-0 pointer-events-none'
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-label='Username'
|
||||
/>
|
||||
<Input
|
||||
isClearable
|
||||
type='password'
|
||||
name='password'
|
||||
autoComplete='current-password'
|
||||
classNames={{
|
||||
label: 'text-black/50 dark:text-white/90',
|
||||
input: [
|
||||
'bg-transparent',
|
||||
'text-black/90 dark:text-white/90',
|
||||
'placeholder:text-default-700/50 dark:placeholder:text-white/60',
|
||||
],
|
||||
innerWrapper: 'bg-transparent',
|
||||
inputWrapper: [
|
||||
'shadow-xl',
|
||||
'bg-default-100/70',
|
||||
'dark:bg-default/60',
|
||||
'backdrop-blur-xl',
|
||||
'backdrop-saturate-200',
|
||||
'hover:bg-default-0/70',
|
||||
'dark:hover:bg-default/70',
|
||||
'group-data-[focus=true]:bg-default-100/50',
|
||||
'dark:group-data-[focus=true]:bg-default/60',
|
||||
'!cursor-text',
|
||||
],
|
||||
}}
|
||||
isDisabled={isLoading}
|
||||
label='Token'
|
||||
placeholder='请输入token'
|
||||
radius='lg'
|
||||
size='lg'
|
||||
startContent={
|
||||
<IoKeyOutline className='text-black/50 mb-0.5 dark:text-white/90 text-slate-400 pointer-events-none flex-shrink-0' />
|
||||
}
|
||||
value={tokenValue}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
onClear={() => setTokenValue('')}
|
||||
/>
|
||||
</form>
|
||||
<div className='text-center text-small text-default-600 dark:text-default-400 px-2'>
|
||||
💡 提示:请从 NapCat 启动日志中查看登录密钥
|
||||
</div>
|
||||
<Button
|
||||
className='mx-10 mt-10 text-lg py-7'
|
||||
color='primary'
|
||||
isLoading={isLoading}
|
||||
radius='full'
|
||||
size='lg'
|
||||
variant='shadow'
|
||||
onPress={onSubmit}
|
||||
>
|
||||
{!isLoading && (
|
||||
<Image
|
||||
alt='logo'
|
||||
classNames={{
|
||||
wrapper: '-ml-8',
|
||||
}}
|
||||
height='2em'
|
||||
src={logo}
|
||||
/>
|
||||
)}
|
||||
登录
|
||||
</Button>
|
||||
</CardBody>
|
||||
</HoverEffectCard>
|
||||
</div>
|
||||
</PureLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user