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:
手瓜一十雪
2025-11-13 15:39:42 +08:00
committed by GitHub
parent c3d1892545
commit ed19c52f25
778 changed files with 2356 additions and 26391 deletions

View 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>
</>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
</>
);
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from 'react-router-dom';
export default function DebugPage () {
return <Outlet />;
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View 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;

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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&nbsp;</span>
<span className={title({ color: 'violet' })}>
Login&nbsp;
</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>
</>
);
}

View 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&nbsp;</span>
<span className={title({ color: 'violet' })}>
Login&nbsp;
</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>
</>
);
}