SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37

26 包 monorepo(TS 全源码):
- snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包
- snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线
- snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI)
- snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports)
- snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时
- 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏)
- 工具件: 解包器/启动器/便携制作器/doctor

GitHub 关联已全量指向 DandanLLab/SnowCatQQ
This commit is contained in:
天天的鸟蛋蛋 committed 2026-11-12 06:27:36 +08:00
commit b9655a5ab1
1048 files changed
+130851

No files matched your search

@@ -0,0 +1,250 @@
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Chip } from '@heroui/chip';
import { Divider } from '@heroui/divider';
import { Image } from '@heroui/image';
import { Link } from '@heroui/link';
import { Spinner } from '@heroui/spinner';
import { Tab, Tabs } from '@heroui/tabs';
import { useRequest } from 'ahooks';
import {
BsCodeSlash,
BsCpu,
BsGithub,
BsGlobe,
BsPlugin,
BsTerminal,
} from 'react-icons/bs';
import { IoDocument, IoHeartCircleOutline, IoRocketSharp } from 'react-icons/io5';
import { LuPlugZap, LuServer } from 'react-icons/lu';
import logo from '@/assets/images/logo.png';
import WebUIManager from '@/controllers/webui_manager';
function VersionInfo () {
const { data, loading, error } = useRequest(WebUIManager.GetSnowCatVersion);
return (
<div className='flex items-center gap-2'>
{error
? (
<Chip color='danger' variant='flat' size='sm'>{error.message}</Chip>
)
: loading
? (
<Spinner size='sm' color='default' />
)
: (
<div className='flex items-center gap-2'>
<Chip size='sm' color='default' variant='flat' className='text-default-500'>WebUI Next</Chip>
<Chip size='sm' color='primary' variant='flat'>Core {data?.version}</Chip>
</div>
)}
</div>
);
}
export default function AboutPage () {
const features = [
{
icon: <LuPlugZap size={20} />,
title: '接入方式全',
desc: 'WS 正反连、HTTP 上报、HTTP SSE、MCP 四种接入方式,按需取用。',
className: 'bg-primary-50 text-primary',
},
{
icon: <LuServer size={20} />,
title: '社区自持主干',
desc: '上游 Ending 后由社区接力维护,Fork 是遗愿,我们续写。',
className: 'bg-success-50 text-success',
},
{
icon: <BsCodeSlash size={20} />,
title: 'OneBot 11 全量',
desc: '181 上游动作 + SnowLuma 采石场 29+ 动作,持续扩充。',
className: 'bg-warning-50 text-warning',
},
{
icon: <IoRocketSharp size={20} />,
title: '稳定线内建',
desc: '掉线指数退避重连、真人节奏抖动、小时预算、连发熔断。',
className: 'bg-secondary-50 text-secondary',
},
{
icon: <BsGlobe size={20} />,
title: '全平台零硬改',
desc: '自动发现已装 QQ、自动读实况版本、一键注入;亦有便携制作器。',
className: 'bg-default-50 text-default-600',
},
{
icon: <BsCpu size={20} />,
title: '本地运行时',
desc: '一切动作都在本地执行,代码全开源白盒可查,无任何外发。',
className: 'bg-danger-50 text-danger',
},
];
const links = [
{ icon: <BsGithub />, name: 'GitHub · DandanLLab/SnowCatQQ', href: 'https://github.com/DandanLLab/SnowCatQQ' },
{ icon: <IoDocument />, name: '文档 · README 与接入指南', href: 'https://github.com/DandanLLab/SnowCatQQ#readme' },
{ icon: <BsTerminal />, name: 'API 文档 · OneBot v11 动作全表', href: 'https://github.com/DandanLLab/SnowCatQQ/tree/main/docs/api' },
{ icon: <BsPlugin />, name: 'MCP 接入 · Network 配置 mcpServers', href: 'https://github.com/DandanLLab/SnowCatQQ#mcp' },
];
const upstreams = [
{ name: 'NapCat(NapNeko/NapCatQQ)', role: '底座 · 上游已官宣 Ending,Fork 接力' },
{ name: 'SnowLuma(继任 TomyJan/SnowLuma)', role: '能力采石场 · qzone 全家桶/群相册/闪照等' },
];
const cardStyle = 'bg-white/55 dark:bg-white/[0.04] backdrop-blur-xl border border-white/60 dark:border-white/10 shadow-sm rounded-2xl';
return (
<div className='sc-page flex flex-col gap-5'>
<title>关于 - SnowCat WebUI · 雪猫控制台</title>
{/* 头部:品牌横幅 */}
<div
className='rounded-2xl px-5 py-6 bg-gradient-to-r from-primary-100/70 via-primary-50/40 to-transparent dark:from-primary-900/30 dark:via-primary-900/10 dark:to-transparent border border-primary-100/60 dark:border-primary-900/30 relative overflow-hidden'
>
<div className='absolute -right-10 -top-10 w-40 h-40 rounded-full bg-primary-300/30 blur-3xl pointer-events-none' />
<div className='flex items-center gap-4 flex-wrap relative'>
<Image src={logo} alt='SnowCat Logo(雪猫)' width={44} height={44} className='rounded-xl' />
<div>
<h1 className='text-2xl font-bold text-default-900 dark:text-white tracking-tight'>
SnowCat · 雪猫
</h1>
<p className='text-small text-default-500 mt-0.5'>
社区自持的 QQ 融合客户端 · 本地运行时 · 白盒可查
</p>
</div>
<div className='ml-auto flex items-center gap-3'>
<VersionInfo />
</div>
</div>
</div>
<Divider className='opacity-50' />
{/* 主内容:双栏 */}
<div className='grid grid-cols-1 lg:grid-cols-3 gap-5 flex-grow'>
{/* 左栏:特性 + 接入方式 */}
<div className='lg:col-span-2 space-y-5'>
<Card shadow='sm' className={cardStyle}>
<CardHeader className='pb-0 pt-4 px-4 flex-col items-start'>
<h2 className='text-lg font-bold'>为什么有雪猫</h2>
</CardHeader>
<CardBody className='py-4 text-default-600 leading-relaxed space-y-2'>
<p>
2026-11 上游 NapCat 官宣 Ending,SnowLuma 原库消失。Fork 是遗愿,
接力棒掉在地上我们捡——雪猫由此而生。
</p>
<p>
我们不与官方对着干:官方走工作流,雪猫走个人开发者的娱乐与陪伴路线,
两赛道互不干涉,社区共享。运行时全部在本地,所有动作本地执行、代码白盒可查。
</p>
</CardBody>
</Card>
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
{features.map((item, index) => (
<Card key={index} shadow='sm' className={cardStyle}>
<CardBody className='flex flex-row items-start gap-4 p-4'>
<div className={`p-3 rounded-lg ${item.className}`}>
{item.icon}
</div>
<div>
<h3 className='font-semibold text-default-900'>{item.title}</h3>
<p className='text-small text-default-500 mt-1'>{item.desc}</p>
</div>
</CardBody>
</Card>
))}
</div>
{/* 接入方式 */}
<Card shadow='sm' className={cardStyle}>
<CardHeader className='pb-0 pt-4 px-4'>
<h2 className='text-lg font-bold'>接入方式</h2>
</CardHeader>
<CardBody className='py-4'>
<Tabs
aria-label='接入方式'
size='sm'
classNames={{ tabList: 'bg-default-100/50' }}
>
<Tab key='ws' title='WebSocket'>
<div className='text-small text-default-600 space-y-2 pt-2'>
<p>正向 WS:雪猫开服务端口,客户端连进来(网络配置 → WS服务器)。</p>
<p>反向 WS:雪猫作为客户端主动连你的服务(网络配置 → WS客户端,dsh 生态即此模式)。</p>
<p className='text-tiny text-default-400'>协议:OneBot v11,事件上报 + 动作调用双通道。</p>
</div>
</Tab>
<Tab key='http' title='HTTP'>
<div className='text-small text-default-600 space-y-2 pt-2'>
<p>HTTP 服务器:本地开端口收动作调用(网络配置 → HTTP服务器)。</p>
<p>HTTP 客户端:事件主动 POST 上报到你的回调地址。</p>
<p>HTTP SSE:服务器推送事件流,适合浏览器内直连。</p>
</div>
</Tab>
<Tab key='mcp' title='MCP'>
<div className='text-small text-default-600 space-y-2 pt-2'>
<p>Model Context Protocol:把雪猫的全部动作以 MCP 工具形式暴露。</p>
<p>在网络配置 → MCP 服务器中添加条目,AI 宿主(Claude / dsh 等)即可直连调用。</p>
<p className='text-tiny text-default-400'>传输:Streamable HTTP,兼容 MCP 2025-03-26 规范。</p>
</div>
</Tab>
</Tabs>
</CardBody>
</Card>
</div>
{/* 右栏:链接 + 入群 + 上游致谢 */}
<div className='space-y-5'>
<Card shadow='sm' className={cardStyle}>
<CardHeader className='pb-0 pt-4 px-4'>
<h2 className='text-lg font-bold'>开源与文档</h2>
</CardHeader>
<CardBody className='py-4 space-y-2'>
<div className='flex flex-col gap-1'>
{links.map((link, idx) => (
<Link
key={idx}
isExternal
href={link.href}
className='flex items-center justify-between p-2.5 rounded-xl hover:bg-default-100/50 transition-colors text-default-600 text-small'
>
<span className='flex items-center gap-3 min-w-0'>
{link.icon}
<span className='truncate'>{link.name}</span>
</span>
<span className='text-tiny text-default-400 flex-shrink-0'>→</span>
</Link>
))}
</div>
</CardBody>
</Card>
<Card shadow='sm' className={cardStyle}>
<CardHeader className='pb-0 pt-4 px-4'>
<h2 className='text-lg font-bold flex items-center gap-2'>
<IoHeartCircleOutline className='text-danger' /> 上游致谢
</h2>
</CardHeader>
<CardBody className='py-4 space-y-3'>
{upstreams.map((u) => (
<div key={u.name}>
<p className='text-small font-medium text-default-700'>{u.name}</p>
<p className='text-tiny text-default-400 mt-0.5'>{u.role}</p>
</div>
))}
<Divider className='opacity-40' />
<p className='text-tiny text-default-400'>
MIT License © {new Date().getFullYear()} SnowCat Team · Made with ❤️ by the community
</p>
</CardBody>
</Card>
</div>
</div>
</div>
);
}
@@ -0,0 +1,125 @@
import { Button } from '@heroui/button';
import toast from 'react-hot-toast';
import { LuDownload, LuUpload } from 'react-icons/lu';
import { requestServerWithFetch } from '@/utils/request';
// 导入配置
const handleImportConfig = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// 检查文件类型
if (!file.name.endsWith('.zip')) {
toast.error('请选择zip格式的配置文件');
return;
}
try {
const formData = new FormData();
formData.append('configFile', file);
const response = await requestServerWithFetch('/OB11Config/ImportConfig', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || '导入配置失败');
}
const result = await response.json();
// 检查是否成功导入
if (result.code === 0) {
toast.success(result.data?.message || '配置导入成功。');
} else {
toast.error(`配置导入失败: ${result.data?.message || '未知错误'}`);
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`导入配置失败: ${msg}`);
} finally {
// 重置文件输入
event.target.value = '';
}
};
// 导出配置
const handleExportConfig = async () => {
try {
const response = await requestServerWithFetch('/OB11Config/ExportConfig', {
method: 'GET',
});
if (!response.ok) {
throw new Error('导出配置失败');
}
// 创建下载链接
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const fileName = response.headers.get('Content-Disposition')?.split('=')[1]?.replace(/"/g, '') || 'config_backup.zip';
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('配置导出成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`导出配置失败: ${msg}`);
}
};
const BackupConfigCard: React.FC = () => {
return (
<div className='space-y-5'>
<div>
<h3 className='text-lg font-semibold text-default-800 dark:text-white mb-1.5'>备份与恢复</h3>
<p className='text-sm text-default-500 mb-5 leading-relaxed'>
导出当前全部配置为 zip 备份,或从备份文件恢复。适合换机迁移与升级前留档。
</p>
<div className='flex flex-wrap gap-3'>
<Button
className='bg-gradient-to-r from-primary-400 to-primary-600 text-white font-semibold shadow-md shadow-primary-500/25'
radius='full'
startContent={<LuDownload size={17} />}
onPress={handleExportConfig}
>
导出配置
</Button>
<label className='cursor-pointer'>
<input
type='file'
accept='.zip'
onChange={handleImportConfig}
className='hidden'
/>
<Button
className='bg-white/60 dark:bg-white/[0.06] text-default-700 dark:text-default-200 border border-white/60 dark:border-white/10 font-medium'
radius='full'
variant='flat'
as='span'
startContent={<LuUpload size={17} />}
>
导入配置
</Button>
</label>
</div>
<div className='mt-5 p-3.5 bg-warning-50/70 dark:bg-warning-900/20 border border-warning-200/60 dark:border-warning-800/40 rounded-2xl'>
<p className='text-xs text-warning-700 dark:text-warning-400 leading-relaxed'>
导入配置会覆盖当前所有设置,请谨慎操作。导入前建议先导出当前配置作为备份。
</p>
</div>
</div>
</div>
);
};
export default BackupConfigCard;
@@ -0,0 +1,180 @@
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 QQManager from '@/controllers/qq_manager';
interface BypassFormData {
hook: boolean;
window: boolean;
module: boolean;
process: boolean;
container: boolean;
js: boolean;
o3HookMode: boolean;
}
const BypassConfigCard = () => {
const [loading, setLoading] = useState(true);
const {
control,
handleSubmit,
formState: { isSubmitting },
setValue,
} = useForm<BypassFormData>();
const loadConfig = async (showTip = false) => {
try {
setLoading(true);
const config = await QQManager.getSnowCatConfig();
const bypass = config.bypass ?? {} as Partial<BypassOptions>;
setValue('hook', bypass.hook ?? false);
setValue('window', bypass.window ?? false);
setValue('module', bypass.module ?? false);
setValue('process', bypass.process ?? false);
setValue('container', bypass.container ?? false);
setValue('js', bypass.js ?? false);
setValue('o3HookMode', config.o3HookMode === 1);
if (showTip) toast.success('刷新成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`获取配置失败: ${msg}`);
} finally {
setLoading(false);
}
};
const onSubmit = handleSubmit(async (data) => {
try {
const { o3HookMode, ...bypass } = data;
await QQManager.setSnowCatConfig({ bypass, o3HookMode: o3HookMode ? 1 : 0 });
toast.success('保存成功,重启后生效');
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存失败: ${msg}`);
}
});
const onReset = () => {
loadConfig();
};
const onRefresh = async () => {
await loadConfig(true);
};
useEffect(() => {
loadConfig();
}, []);
if (loading) {
return (
<div className='relative w-full min-h-[220px]'>
<PageLoading loading />
</div>
);
}
return (
<>
<title>反检测配置 - SnowCat WebUI</title>
<div className='flex flex-col gap-1 mb-4'>
<h3 className='text-base font-semibold text-default-800 dark:text-white'>反检测开关配置</h3>
<p className='text-xs text-default-500 leading-relaxed'>
控制 Napi2Native 模块的各项反检测功能,修改后需重启生效。
</p>
</div>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
<Controller
control={control}
name='hook'
render={({ field }) => (
<SwitchCard
{...field}
label='Hook'
description='hook特征隐藏'
/>
)}
/>
<Controller
control={control}
name='window'
render={({ field }) => (
<SwitchCard
{...field}
label='Window'
description='窗口伪造'
/>
)}
/>
<Controller
control={control}
name='module'
render={({ field }) => (
<SwitchCard
{...field}
label='Module'
description='加载模块隐藏'
/>
)}
/>
<Controller
control={control}
name='process'
render={({ field }) => (
<SwitchCard
{...field}
label='Process'
description='进程反检测'
/>
)}
/>
<Controller
control={control}
name='container'
render={({ field }) => (
<SwitchCard
{...field}
label='Container'
description='容器反检测'
/>
)}
/>
<Controller
control={control}
name='js'
render={({ field }) => (
<SwitchCard
{...field}
label='JS'
description='JS反检测'
/>
)}
/>
<Controller
control={control}
name='o3HookMode'
render={({ field }) => (
<SwitchCard
{...field}
label='o3HookMode'
description='O3 Hook 模式'
/>
)}
/>
</div>
<SaveButtons
onSubmit={onSubmit}
reset={onReset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</>
);
};
export default BypassConfigCard;
@@ -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>修改密码 - SnowCat 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,126 @@
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 QQManager from '@/controllers/qq_manager';
interface CoreFormData {
fileLog: boolean;
consoleLog: boolean;
autoTimeSync: boolean;
}
const CoreConfigCard = () => {
const [loading, setLoading] = useState(true);
const {
control,
handleSubmit,
formState: { isSubmitting },
setValue,
} = useForm<CoreFormData>();
const loadConfig = async (showTip = false) => {
try {
setLoading(true);
const config = await QQManager.getSnowCatUinConfig();
setValue('fileLog', config.fileLog ?? false);
setValue('consoleLog', config.consoleLog ?? true);
setValue('autoTimeSync', config.autoTimeSync ?? true);
if (showTip) toast.success('刷新成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`获取配置失败: ${msg}`);
} finally {
setLoading(false);
}
};
const onSubmit = handleSubmit(async (data) => {
try {
await QQManager.setSnowCatUinConfig(data);
toast.success('保存成功,重启后生效');
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存失败: ${msg}`);
}
});
const onReset = () => {
loadConfig();
};
const onRefresh = async () => {
await loadConfig(true);
};
useEffect(() => {
loadConfig();
}, []);
if (loading) {
return (
<div className='relative w-full min-h-[220px]'>
<PageLoading loading />
</div>
);
}
return (
<>
<title>核心配置 - SnowCat WebUI</title>
<div className='flex flex-col gap-1 mb-4'>
<h3 className='text-base font-semibold text-default-800 dark:text-white'>SnowCat 核心配置</h3>
<p className='text-xs text-default-500 leading-relaxed'>
控制 SnowCat 框架底层的核心行为设定,修改后需重启生效。
</p>
</div>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
<Controller
control={control}
name='autoTimeSync'
render={({ field }) => (
<SwitchCard
{...field}
label='自动对时'
description='自动校验并矫正系统时间偏差'
/>
)}
/>
<Controller
control={control}
name='fileLog'
render={({ field }) => (
<SwitchCard
{...field}
label='文件日志'
description='是否将登录后日志写入到本地文件'
/>
)}
/>
<Controller
control={control}
name='consoleLog'
render={({ field }) => (
<SwitchCard
{...field}
label='控制台日志'
description='是否在终端标准输出显示日志'
/>
)}
/>
</div>
<SaveButtons
onSubmit={onSubmit}
reset={onReset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</>
);
};
export default CoreConfigCard;
@@ -0,0 +1,139 @@
import { Card, CardBody } from '@heroui/card';
import { Tab, Tabs } from '@heroui/tabs';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useNavigate, useSearchParams } from 'react-router-dom';
import key from '@/const/key';
// 把修改密码移动到WebUI栏目中
// import ChangePasswordCard from './change_password';
import LoginConfigCard from './login';
import OneBotConfigCard from './onebot';
import ServerConfigCard from './server';
import SSLConfigCard from './ssl';
import ThemeConfigCard from './theme';
import WebUIConfigCard from './webui';
import BackupConfigCard from './backup';
import BypassConfigCard from './bypass';
import CoreConfigCard from './core';
export interface ConfigPageProps {
children?: React.ReactNode;
size?: 'sm' | 'md' | 'lg';
}
const ConfigPageItem: React.FC<ConfigPageProps> = ({
children,
size = 'md',
}) => {
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return (
<Card className={clsx(
'w-full mx-auto backdrop-blur-xl border border-white/60 dark:border-white/10 rounded-2xl transition-all shadow-sm',
hasBackground ? 'bg-black/20' : 'bg-white/65 dark:bg-white/[0.04]',
{
'max-w-xl': size === 'sm',
'max-w-3xl': size === 'md',
'max-w-[1400px]': size === 'lg',
}
)}
>
<CardBody className='py-6 px-4 md:py-8 md:px-12'>
<div className='w-full flex flex-col gap-5'>
{children}
</div>
</CardBody>
</Card>
);
};
export default function ConfigPage () {
const navigate = useNavigate();
const search = useSearchParams({
tab: 'onebot',
})[0];
const tab = search.get('tab') ?? 'onebot';
return (
<section className='sc-page'>
<title>系统配置 - SnowCat WebUI</title>
<Tabs
aria-label='config tab'
fullWidth={false}
className='w-full'
selectedKey={tab}
onSelectionChange={(key) => {
navigate(`/config?tab=${key}`);
}}
classNames={{
base: 'w-full flex-col items-center',
tabList: 'sc-tablist mb-4 md:mb-6 w-full md:w-fit mx-auto overflow-x-auto hide-scrollbar',
cursor: 'sc-tab-cursor',
tab: 'h-9 px-4 md:px-6',
tabContent: 'text-default-600 dark:text-default-300 font-medium group-data-[selected=true]:text-primary',
panel: 'w-full relative p-0',
}}
>
<Tab title='OneBot配置' key='onebot'>
<ConfigPageItem>
<OneBotConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='服务器配置' key='server'>
<ConfigPageItem>
<ServerConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='SSL配置' key='ssl'>
<ConfigPageItem size='sm'>
<SSLConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='WebUI配置' key='webui'>
<ConfigPageItem>
<WebUIConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='登录配置' key='login'>
<ConfigPageItem>
<LoginConfigCard />
</ConfigPageItem>
</Tab>
{/* 把修改密码移动到WebUI栏目中 */}
{
/*
<Tab title='修改密码' key='token'>
<ConfigPageItem size='sm'>
<ChangePasswordCard />
</ConfigPageItem>
</Tab>
*/
}
<Tab title='主题配置' key='theme'>
<ConfigPageItem size='lg'>
<ThemeConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='备份与恢复' key='backup'>
<ConfigPageItem>
<BackupConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='核心配置' key='core'>
<ConfigPageItem>
<CoreConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='反检测' key='bypass'>
<ConfigPageItem>
<BypassConfigCard />
</ConfigPageItem>
</Tab>
</Tabs>
</section>
);
}
@@ -0,0 +1,238 @@
import { Input } from '@heroui/input';
import { Button } from '@heroui/button';
import { Divider } from '@heroui/divider';
import { Avatar } from '@heroui/avatar';
import { useRequest } from 'ahooks';
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 GUIDManager from '@/components/guid_manager';
import PageLoading from '@/components/page_loading';
import QQManager from '@/controllers/qq_manager';
import ProcessManager from '@/controllers/process_manager';
import { waitForBackendReady } from '@/utils/process_utils';
const LoginConfigCard = () => {
const [isRestarting, setIsRestarting] = useState(false);
const [loginList, setLoginList] = useState<LoginListItem[]>([]);
const [loginListLoading, setLoginListLoading] = useState(false);
const {
data: quickLoginData,
loading: quickLoginLoading,
error: quickLoginError,
refreshAsync: refreshQuickLogin,
} = useRequest(QQManager.getQuickLoginQQ);
const {
control,
handleSubmit: handleOnebotSubmit,
formState: { isSubmitting },
setValue: setOnebotValue,
watch,
} = useForm<{
quickLoginQQ: string;
}>({
defaultValues: {
quickLoginQQ: '',
},
});
const currentQQ = watch('quickLoginQQ');
const reset = () => {
setOnebotValue('quickLoginQQ', quickLoginData ?? '');
};
const fetchLoginList = async () => {
try {
setLoginListLoading(true);
const list = await QQManager.getQQQuickLoginListNew();
setLoginList(list ?? []);
} catch (error) {
const msg = (error as Error).message;
toast.error(`获取账号列表失败: ${msg}`);
} finally {
setLoginListLoading(false);
}
};
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}`);
}
};
const onRestartProcess = async () => {
setIsRestarting(true);
try {
const result = await ProcessManager.restartProcess();
toast.success(result.message || '进程重启请求已发送');
// 轮询探测后端是否恢复
const isReady = await waitForBackendReady(
30000, // 30秒超时
() => {
setIsRestarting(false);
toast.success('进程重启完成');
},
() => {
setIsRestarting(false);
toast.error('后端在 30 秒内未响应,请检查 SnowCat 运行日志');
}
);
if (!isReady) {
setIsRestarting(false);
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`进程重启失败: ${msg}`);
setIsRestarting(false);
}
};
useEffect(() => {
reset();
}, [quickLoginData]);
if (quickLoginLoading) {
return (
<div className='relative w-full min-h-[220px]'>
<PageLoading loading />
</div>
);
}
return (
<>
<title>登录配置 - SnowCat WebUI</title>
<div className='flex-shrink-0 w-full font-semibold text-[11px] uppercase tracking-[0.12em] text-default-400 border-l-2 border-primary-400/50 pl-2.5'>快速登录QQ</div>
<Controller
control={control}
name='quickLoginQQ'
render={({ field }) => (
<Input
{...field}
label='快速登录QQ'
placeholder='请输入QQ号'
isDisabled={!!quickLoginError}
errorMessage={quickLoginError ? '获取快速登录QQ失败' : undefined}
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/>
)}
/>
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<Button
size='sm'
color='primary'
variant='flat'
onPress={fetchLoginList}
isLoading={loginListLoading}
>
获取已登录账号列表
</Button>
{loginList.length > 0 && (
<span className='text-xs text-default-400'>
共 {loginList.length} 个账号,点击选择
</span>
)}
</div>
{loginList.length > 0 && (
<div className='grid grid-cols-1 sm:grid-cols-2 gap-2'>
{loginList.filter(item => item.isQuickLogin).map((item) => (
<button
key={item.uin}
type='button'
onClick={() => setOnebotValue('quickLoginQQ', item.uin)}
className={`flex items-center gap-3 p-3 rounded-xl border transition-all cursor-pointer text-left
${currentQQ === item.uin
? 'border-primary bg-primary/10 dark:bg-primary/20 shadow-sm'
: 'border-default-200 dark:border-default-100/10 bg-default-100/50 dark:bg-white/5 hover:bg-default-200/50 dark:hover:bg-white/10'
}`}
>
<Avatar
src={item.faceUrl}
name={item.nickName?.charAt(0) || item.uin.charAt(0)}
size='sm'
className='flex-shrink-0'
/>
<div className='flex flex-col min-w-0'>
<span className='text-sm font-medium text-default-700 truncate'>
{item.nickName || '未知昵称'}
</span>
<span className='text-xs text-default-400 truncate'>
{item.uin}
</span>
</div>
{currentQQ === item.uin && (
<span className='ml-auto text-xs text-primary font-medium flex-shrink-0'>已选择</span>
)}
</button>
))}
{loginList.filter(item => !item.isQuickLogin).length > 0 && (
<div className='col-span-full text-xs text-default-400 mt-1'>
以下账号不支持快速登录:
{loginList.filter(item => !item.isQuickLogin).map(item => item.uin).join('、')}
</div>
)}
</div>
)}
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting || quickLoginLoading}
refresh={onRefresh}
/>
<div className='flex-shrink-0 w-full mt-6 pt-6 border-t border-divider'>
<div className='mb-3 text-sm text-default-600'>进程管理</div>
<Button
color='warning'
variant='flat'
onPress={onRestartProcess}
isLoading={isRestarting}
isDisabled={isRestarting}
fullWidth
>
{isRestarting ? '正在重启进程...' : '重启进程'}
</Button>
<div className='mt-2 text-xs text-default-500'>
重启进程将关闭当前 Worker 进程,等待 3 秒后启动新进程
</div>
</div>
<Divider className='mt-6' />
<div className='flex-shrink-0 w-full mt-4'>
<div className='mb-3 text-sm text-default-600'>设备 GUID 管理</div>
<div className='text-xs text-default-400 mb-3'>
GUID 是设备登录唯一识别码,存储在 Registry20 文件中。修改后需重启生效。
</div>
<GUIDManager showRestart={false} />
</div>
</>
);
};
export default LoginConfigCard;
@@ -0,0 +1,229 @@
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,
imageDownloadProxy: '',
timeout: {
baseTimeout: 10000,
uploadSpeedKBps: 256,
downloadSpeedKBps: 256,
maxTimeout: 1800000,
},
},
});
const reset = () => {
setOnebotValue('musicSignUrl', config.musicSignUrl);
setOnebotValue('enableLocalFile2Url', config.enableLocalFile2Url);
setOnebotValue('parseMultMsg', config.parseMultMsg);
setOnebotValue('imageDownloadProxy', config.imageDownloadProxy);
setOnebotValue('timeout', config.timeout ?? {
baseTimeout: 10000,
uploadSpeedKBps: 256,
downloadSpeedKBps: 1000,
maxTimeout: 1800000,
});
};
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 (
<div className='relative w-full min-h-[220px]'>
<PageLoading loading />
</div>
);
}
return (
<>
<title>OneBot配置 - SnowCat WebUI</title>
<Controller
control={control}
name='musicSignUrl'
render={({ field }) => (
<Input
{...field}
label='音乐签名地址'
placeholder='请输入音乐签名地址'
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/>
)}
/>
<Controller
control={control}
name='enableLocalFile2Url'
render={({ field }) => (
<SwitchCard
{...field}
description='启用本地文件到URL'
label='启用本地文件到URL'
/>
)}
/>
<Controller
control={control}
name='parseMultMsg'
render={({ field }) => (
<SwitchCard
{...field}
description='启用上报解析合并消息'
label='启用上报解析合并消息'
/>
)}
/>
<Controller
control={control}
name='imageDownloadProxy'
render={({ field }) => (
<Input
{...field}
label='图片下载代理'
placeholder='请输入代理地址,如 http://127.0.0.1:7890'
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/>
)}
/>
<Controller
control={control}
name='timeout.baseTimeout'
render={({ field }) => (
<Input
{...field}
type='number'
label='基础超时时间(毫秒)'
placeholder='10000'
value={field.value?.toString() ?? ''}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/>
)}
/>
<Controller
control={control}
name='timeout.uploadSpeedKBps'
render={({ field }) => (
<Input
{...field}
type='number'
label='预估上传速度(KB/s)'
placeholder='256'
value={field.value?.toString() ?? ''}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/>
)}
/>
<Controller
control={control}
name='timeout.downloadSpeedKBps'
render={({ field }) => (
<Input
{...field}
type='number'
label='预估下载速度(KB/s)'
placeholder='1000'
value={field.value?.toString() ?? ''}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/>
)}
/>
<Controller
control={control}
name='timeout.maxTimeout'
render={({ field }) => (
<Input
{...field}
type='number'
label='最大超时时间(毫秒)'
placeholder='1800000'
value={field.value?.toString() ?? ''}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/>
)}
/>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
refresh={onRefresh}
/>
</>
);
};
export default OneBotConfigCard;
@@ -0,0 +1,333 @@
import { Input } from '@heroui/input';
import { useRequest } from 'ahooks';
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { Select, SelectItem } from '@heroui/select';
import SaveButtons from '@/components/button/save_buttons';
import PageLoading from '@/components/page_loading';
import SwitchCard from '@/components/switch_card';
import WebUIManager from '@/controllers/webui_manager';
const ServerConfigCard = () => {
const {
data: configData,
loading: configLoading,
error: configError,
refreshAsync: refreshConfig,
} = useRequest(WebUIManager.getWebUIConfig);
const [ipListText, setIpListText] = useState('');
const {
control,
handleSubmit: handleConfigSubmit,
formState: { isSubmitting },
setValue: setConfigValue,
watch,
} = useForm<{
host: string;
port: number;
loginRate: number;
disableWebUI: boolean;
accessControlMode: 'none' | 'whitelist' | 'blacklist';
ipWhitelist: string[];
ipBlacklist: string[];
enableXForwardedFor: boolean;
}>({
defaultValues: {
host: '0.0.0.0',
port: 6099,
loginRate: 10,
disableWebUI: false,
accessControlMode: 'none',
ipWhitelist: [],
ipBlacklist: [],
enableXForwardedFor: false,
},
});
const accessControlMode = watch('accessControlMode');
const reset = () => {
if (configData) {
setConfigValue('host', configData.host);
setConfigValue('port', configData.port);
setConfigValue('loginRate', configData.loginRate);
setConfigValue('disableWebUI', configData.disableWebUI);
setConfigValue('accessControlMode', configData.accessControlMode || 'none');
setConfigValue('ipWhitelist', configData.ipWhitelist || []);
setConfigValue('ipBlacklist', configData.ipBlacklist || []);
setConfigValue('enableXForwardedFor', configData.enableXForwardedFor || false);
// 更新IP列表文本
if (configData.accessControlMode === 'whitelist') {
setIpListText((configData.ipWhitelist || []).join('\n'));
} else if (configData.accessControlMode === 'blacklist') {
setIpListText((configData.ipBlacklist || []).join('\n'));
}
}
};
const onSubmit = handleConfigSubmit(async (data) => {
try {
// 解析IP列表
const ipList = ipListText
.split('\n')
.map(ip => ip.trim())
.filter(ip => ip.length > 0);
const submitData = {
...data,
ipWhitelist: data.accessControlMode === 'whitelist' ? ipList : [],
ipBlacklist: data.accessControlMode === 'blacklist' ? ipList : [],
};
await WebUIManager.updateWebUIConfig(submitData);
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]);
useEffect(() => {
// 当模式切换时,更新IP列表文本
const handleModeChange = async () => {
if (configData) {
if (accessControlMode === 'whitelist') {
const currentList = configData.ipWhitelist || [];
// 如果白名单为空,自动获取当前IP并填入
if (currentList.length === 0) {
try {
const clientIPData = await WebUIManager.getClientIP();
if (clientIPData?.ip) {
setIpListText(clientIPData.ip);
} else {
setIpListText('');
}
} catch (error) {
console.error('获取客户端IP失败:', error);
setIpListText('');
}
} else {
setIpListText(currentList.join('\n'));
}
} else if (accessControlMode === 'blacklist') {
setIpListText((configData.ipBlacklist || []).join('\n'));
} else {
setIpListText('');
}
}
};
handleModeChange();
}, [accessControlMode, configData]);
if (configLoading) {
return (
<div className='relative w-full min-h-[220px]'>
<PageLoading loading />
</div>
);
}
return (
<>
<title>服务器配置 - SnowCat WebUI</title>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-3'>
<div className='flex-shrink-0 w-full font-semibold text-[11px] uppercase tracking-[0.12em] text-default-400 border-l-2 border-primary-400/50 pl-2.5'>服务器配置</div>
<Controller
control={control}
name='host'
render={({ field }) => (
<Input
{...field}
label='监听地址'
placeholder='请输入监听地址'
description='服务器监听的IP地址,0.0.0.0表示监听所有网卡'
isDisabled={!!configError}
errorMessage={configError ? '获取配置失败' : undefined}
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-default-700 placeholder:text-default-400',
}}
/>
)}
/>
<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)}
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-gray-800 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500',
}}
/>
)}
/>
<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)}
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-gray-800 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500',
}}
/>
)}
/>
</div>
<div className='flex flex-col gap-3'>
<div className='flex-shrink-0 w-full font-semibold text-[11px] uppercase tracking-[0.12em] text-default-400 border-l-2 border-primary-400/50 pl-2.5'>安全配置</div>
<Controller
control={control}
name='disableWebUI'
render={({ field }) => (
<SwitchCard
value={field.value}
onValueChange={(value: boolean) => field.onChange(value)}
disabled={!!configError}
label='禁用WebUI'
description='启用后将完全禁用WebUI服务,需要重启生效'
/>
)}
/>
<div className='flex flex-col gap-3 mt-2'>
<div className='text-sm font-medium text-default-700 dark:text-default-300 px-1'>网络访问控制</div>
<Controller
control={control}
name='accessControlMode'
render={({ field }) => (
<Select
{...field}
label='访问控制模式'
placeholder='选择访问控制模式'
description='选择如何控制网络访问'
selectedKeys={[field.value]}
onSelectionChange={(keys) => {
const value = Array.from(keys)[0] as 'none' | 'whitelist' | 'blacklist';
field.onChange(value);
}}
isDisabled={!!configError}
classNames={{
trigger:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
}}
>
<SelectItem key='none' value='none'>
不限制
</SelectItem>
<SelectItem key='whitelist' value='whitelist'>
白名单模式
</SelectItem>
<SelectItem key='blacklist' value='blacklist'>
黑名单模式
</SelectItem>
</Select>
)}
/>
{accessControlMode !== 'none' && (
<div className='flex flex-col gap-2'>
<div className='flex flex-col gap-1'>
<label className='text-sm font-medium text-default-700 dark:text-default-300'>
{accessControlMode === 'whitelist' ? 'IP白名单' : 'IP黑名单'}
</label>
<textarea
value={ipListText}
onChange={(e) => setIpListText(e.target.value)}
placeholder={'每行一个IP地址或规则\n支持格式:\nIPv4:\n- 精确IP: 192.168.1.100\n- 通配符: 192.168.1.*\n- CIDR: 192.168.1.0/24\nIPv6:\n- 精确IP: 2001:db8::1\n- CIDR: 2001:db8::/32'}
disabled={!!configError}
rows={10}
className='w-full px-3 py-2 bg-default-100/50 dark:bg-white/5 backdrop-blur-md border border-transparent hover:bg-default-200/50 dark:hover:bg-white/10 transition-all shadow-sm hover:border-default-300 rounded-lg text-default-700 dark:text-default-300 placeholder:text-default-400 font-mono text-sm resize-y focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
/>
<p className='text-xs text-default-500'>
{accessControlMode === 'whitelist'
? '只有列表中的IP可以访问'
: '列表中的IP将被拒绝访问'}
</p>
</div>
<div className='text-xs text-default-500 px-1'>
<div className='font-medium mb-1'>示例规则:</div>
<div className='space-y-0.5 font-mono'>
<div>• 127.0.0.1 (IPv4 本地回环)</div>
<div>• 192.168.1.* (IPv4 通配符)</div>
<div>• 10.0.0.0/8 (IPv4 CIDR)</div>
<div>• ::1 (IPv6 本地回环)</div>
<div>• 2001:db8::/32 (IPv6 CIDR)</div>
</div>
</div>
</div>
)}
<Controller
control={control}
name='enableXForwardedFor'
render={({ field }) => (
<SwitchCard
value={field.value}
onValueChange={(value: boolean) => field.onChange(value)}
disabled={!!configError}
label='启用 X-Forwarded-For'
description='启用后将从 X-Forwarded-For 头部获取真实IP地址(适用于反向代理场景)'
/>
)}
/>
</div>
</div>
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting || configLoading}
refresh={onRefresh}
/>
</>
);
};
export default ServerConfigCard;
@@ -0,0 +1,172 @@
import { useRequest } from 'ahooks';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { Button } from '@heroui/button';
import { Textarea } from '@heroui/input';
import PageLoading from '@/components/page_loading';
import WebUIManager from '@/controllers/webui_manager';
const SSLConfigCard = () => {
const {
data: sslData,
loading: sslLoading,
refreshAsync: refreshSSL,
} = useRequest(WebUIManager.getSSLStatus);
const [sslCert, setSslCert] = useState('');
const [sslKey, setSslKey] = useState('');
const [sslSaving, setSslSaving] = useState(false);
useEffect(() => {
if (sslData) {
setSslCert(sslData.certContent || '');
setSslKey(sslData.keyContent || '');
}
}, [sslData]);
const handleSaveSSL = async () => {
if (!sslCert.trim() || !sslKey.trim()) {
toast.error('证书和私钥内容不能为空');
return;
}
setSslSaving(true);
try {
const result = await WebUIManager.saveSSLCert(sslCert, sslKey);
toast.success(result.message || 'SSL证书保存成功');
await refreshSSL();
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存SSL证书失败: ${msg}`);
} finally {
setSslSaving(false);
}
};
const handleDeleteSSL = async () => {
setSslSaving(true);
try {
const result = await WebUIManager.deleteSSLCert();
toast.success(result.message || 'SSL证书已删除');
setSslCert('');
setSslKey('');
await refreshSSL();
} catch (error) {
const msg = (error as Error).message;
toast.error(`删除SSL证书失败: ${msg}`);
} finally {
setSslSaving(false);
}
};
const handleRefresh = async () => {
try {
await refreshSSL();
toast.success('刷新成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`刷新失败: ${msg}`);
}
};
if (sslLoading) {
return (
<div className='relative w-full min-h-[220px]'>
<PageLoading loading />
</div>
);
}
return (
<>
<title>SSL配置 - SnowCat WebUI</title>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2'>
<div className='flex-shrink-0 w-full font-semibold text-[11px] uppercase tracking-[0.12em] text-default-400 border-l-2 border-primary-400/50 pl-2.5'>SSL/HTTPS 配置</div>
{sslData?.enabled && (
<span className='px-2 py-0.5 text-xs bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-400 rounded-full whitespace-nowrap'>
已启用
</span>
)}
</div>
<p className='text-sm text-default-500 px-1'>
配置SSL证书后重启即可启用HTTPS。将证书(cert.pem)和私钥(key.pem)的内容粘贴到下方文本框中。
</p>
<div className='p-3 bg-warning-50/70 dark:bg-warning-900/20 rounded-xl border border-warning-200/70 dark:border-warning-800/40'>
<p className='text-xs leading-relaxed text-warning-700 dark:text-warning-400'>
<strong>注意:</strong>保存证书后需要重启服务才能生效。删除证书后同样需要重启才能切换回HTTP模式。
</p>
</div>
</div>
<div className='flex flex-col gap-4'>
<Textarea
label='证书内容 (cert.pem)'
placeholder={'-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'}
value={sslCert}
onValueChange={setSslCert}
minRows={6}
maxRows={12}
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-default-700 placeholder:text-default-400 font-mono text-sm',
}}
/>
<Textarea
label='私钥内容 (key.pem)'
placeholder={'-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'}
value={sslKey}
onValueChange={setSslKey}
minRows={6}
maxRows={12}
classNames={{
inputWrapper:
'bg-white/80 dark:bg-white/[0.06] backdrop-blur-md border border-black/[0.08] dark:border-white/12 hover:bg-white/95 dark:hover:bg-white/[0.09] transition-all shadow-sm data-[hover=true]:border-primary/35',
input: 'bg-transparent text-default-700 placeholder:text-default-400 font-mono text-sm',
}}
/>
</div>
<div className='flex gap-2 justify-end pt-4 border-t border-white/50 dark:border-white/[0.06]'>
<Button
variant='flat'
radius='full'
className='bg-white/60 dark:bg-white/[0.06] text-default-600 border border-white/50 dark:border-white/10'
isLoading={sslSaving || sslLoading}
onPress={handleRefresh}
size='sm'
>
刷新
</Button>
{sslData?.enabled && (
<Button
color='danger'
variant='flat'
radius='full'
isLoading={sslSaving || sslLoading}
onPress={handleDeleteSSL}
size='sm'
>
删除SSL证书
</Button>
)}
<Button
color='primary'
radius='full'
className='font-semibold bg-gradient-to-r from-primary-400 to-primary-600 shadow-md shadow-primary-500/25 px-5'
isLoading={sslSaving || sslLoading}
onPress={handleSaveSSL}
size='sm'
>
保存SSL证书
</Button>
</div>
</div>
</>
);
};
export default SSLConfigCard;
@@ -0,0 +1,586 @@
import { Button } from '@heroui/button';
import { Card, CardBody, CardHeader } from '@heroui/card';
import { Select, SelectItem } from '@heroui/select';
import { Chip } from '@heroui/chip';
import { Tab, Tabs } from '@heroui/tabs';
import { useRequest } from 'ahooks';
import clsx from 'clsx';
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import toast from 'react-hot-toast';
import { FaFont, FaUserAstronaut, FaCheck } from 'react-icons/fa';
import { FaPaintbrush } from 'react-icons/fa6';
import { IoIosColorPalette, IoMdRefresh } from 'react-icons/io';
import { MdDarkMode, MdLightMode } from 'react-icons/md';
import themes from '@/const/themes';
import ColorPicker from '@/components/ColorPicker';
import FileInput from '@/components/input/file_input';
import PageLoading from '@/components/page_loading';
import FileManager from '@/controllers/file_manager';
import { applyFont, colorKeys, generateTheme, loadTheme, updateFontCache } from '@/utils/theme';
import WebUIManager from '@/controllers/webui_manager';
export type PreviewThemeCardProps = {
theme: ThemeInfo;
onPreview: () => void;
isSelected?: boolean;
};
const values = [
'',
'-50',
'-100',
'-200',
'-300',
'-400',
'-500',
'-600',
'-700',
'-800',
'-900',
];
const colors = [
'primary',
'secondary',
'success',
'danger',
'warning',
'default',
];
function PreviewThemeCard ({ theme, onPreview, isSelected }: 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 relative transition-all',
theme.name,
isSelected && 'ring-2 ring-primary ring-offset-2'
)}
>
{isSelected && (
<div className='absolute top-1 right-1 z-10'>
<Chip size='sm' color='primary' variant='solid'>
<FaCheck size={10} />
</Chip>
</div>
)}
<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 whitespace-nowrap overflow-hidden text-ellipsis w-full'>{theme.description}</div>
</CardHeader>
<CardBody>
<div className='flex flex-col gap-1'>
{colors.map((color) => (
<div className='flex gap-1 items-center flex-nowrap' key={color}>
<div className='text-xs w-4 text-right flex-shrink-0'>
{color[0].toUpperCase()}
</div>
{values.map((value) => (
<div
key={value}
className={clsx(
'w-2 h-2 rounded-full shadow-small flex-shrink-0',
`bg-${color}${value}`
)}
/>
))}
</div>
))}
</div>
</CardBody>
</Card>
);
}
// 比较两个主题配置是否相同(不比较 fontMode)
const isThemeColorsEqual = (a: ThemeConfig, b: ThemeConfig): boolean => {
if (!a || !b) return false;
const aKeys = [...Object.keys(a.light || {}), ...Object.keys(a.dark || {})];
const bKeys = [...Object.keys(b.light || {}), ...Object.keys(b.dark || {})];
if (aKeys.length !== bKeys.length) return false;
for (const key of Object.keys(a.light || {})) {
if (a.light?.[key as keyof ThemeConfigItem] !== b.light?.[key as keyof ThemeConfigItem]) return false;
}
for (const key of Object.keys(a.dark || {})) {
if (a.dark?.[key as keyof ThemeConfigItem] !== b.dark?.[key as keyof ThemeConfigItem]) return false;
}
return true;
};
// 字体模式显示名称映射
const fontModeNames: Record<string, string> = {
aacute: 'Aa 偷吃可爱长大的',
system: '系统默认',
custom: '自定义字体',
};
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: {},
fontMode: 'aacute',
},
},
});
const [dataLoaded, setDataLoaded] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [customFontExists, setCustomFontExists] = useState(false);
// 使用 useRef 存储 style 标签引用和状态
const styleTagRef = useRef<HTMLStyleElement | null>(null);
const originalDataRef = useRef<ThemeConfig | null>(null);
const hasUnsavedChangesRef = useRef<boolean>(false);
// 同步 hasUnsavedChanges 到 ref,供 cleanup 函数使用
useEffect(() => {
hasUnsavedChangesRef.current = hasUnsavedChanges;
}, [hasUnsavedChanges]);
// 在组件挂载时创建 style 标签,并在卸载时清理
// 同时在卸载时恢复字体到已保存的状态(避免"伪自动保存"问题)
useEffect(() => {
const styleTag = document.createElement('style');
document.head.appendChild(styleTag);
styleTagRef.current = styleTag;
return () => {
// 组件卸载时,只有在有未保存更改时才恢复到已保存的字体设置
// 避免在刷新页面后字体被意外重置
if (hasUnsavedChangesRef.current && originalDataRef.current?.fontMode) {
applyFont(originalDataRef.current.fontMode);
}
if (styleTagRef.current) {
document.head.removeChild(styleTagRef.current);
}
};
}, []);
const theme = useWatch({ control, name: 'theme' });
// 检测是否有未保存的更改
useEffect(() => {
if (originalDataRef.current && dataLoaded) {
const colorsChanged = !isThemeColorsEqual(theme, originalDataRef.current);
const fontChanged = theme.fontMode !== originalDataRef.current.fontMode;
setHasUnsavedChanges(colorsChanged || fontChanged);
}
}, [theme, dataLoaded]);
const reset = useCallback(() => {
if (data) {
setOnebotValue('theme', data);
originalDataRef.current = data;
// 应用已保存的字体设置
if (data.fontMode) {
applyFont(data.fontMode);
}
}
setDataLoaded(true);
setHasUnsavedChanges(false);
// 检查自定义字体是否存在
FileManager.checkWebUIFontExists().then(exists => {
setCustomFontExists(exists);
}).catch(err => console.error('Failed to check custom font:', err));
}, [data, setOnebotValue]);
// 实时应用字体预设(预览)
useEffect(() => {
if (dataLoaded && theme.fontMode) {
applyFont(theme.fontMode);
}
}, [theme.fontMode, dataLoaded]);
const onSubmit = handleOnebotSubmit(async (formData) => {
try {
await WebUIManager.setThemeConfig(formData.theme);
// 更新原始数据引用
originalDataRef.current = formData.theme;
// 更新字体缓存
if (formData.theme.fontMode) {
updateFontCache(formData.theme.fontMode);
}
setHasUnsavedChanges(false);
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, reset]);
useEffect(() => {
if (theme && styleTagRef.current) {
const css = generateTheme(theme);
styleTagRef.current.innerHTML = css;
}
}, [theme]);
// 找到当前选中的主题(预览中的)
const selectedThemeName = useMemo(() => {
return themes.find(t => isThemeColorsEqual(t.theme, theme))?.name;
}, [theme]);
// 找到已保存的主题名称
const savedThemeName = useMemo(() => {
const savedData = originalDataRef.current || data;
if (!savedData) return null;
return themes.find(t => isThemeColorsEqual(t.theme, savedData))?.name || '自定义';
}, [data, dataLoaded, hasUnsavedChanges]);
// 已保存的字体模式显示名称
const savedFontModeDisplayName = useMemo(() => {
const savedData = originalDataRef.current || data;
const mode = savedData?.fontMode || 'aacute';
return fontModeNames[mode] || mode;
}, [data, dataLoaded, hasUnsavedChanges]);
if (loading) {
return (
<div className='relative w-full min-h-[220px]'>
<PageLoading loading />
</div>
);
}
if (error) {
return (
<div className='py-24 text-danger-500 text-center'>{error.message}</div>
);
}
return (
<>
<title>主题配置 - SnowCat WebUI</title>
{/* 顶部操作栏 */}
<div className='w-full pb-2'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='flex flex-col gap-1'>
<h1 className='text-base font-semibold text-default-800 dark:text-white tracking-tight'>外观设置</h1>
<div className='flex items-center gap-3 text-tiny text-default-500'>
<div className='flex items-center gap-1.5'>
<IoIosColorPalette className='text-primary' size={16} />
<span className='font-medium text-default-700'>{savedThemeName || '加载中...'}</span>
</div>
<div className='w-px h-2.5 bg-default-300' />
<div className='flex items-center gap-1.5'>
<FaFont className='text-secondary' size={12} />
<span className='font-medium text-default-700'>{savedFontModeDisplayName}</span>
</div>
{hasUnsavedChanges && (
<>
<div className='w-px h-2.5 bg-default-300' />
<div className='flex items-center gap-1'>
<div className='w-1.5 h-1.5 rounded-full bg-warning animate-pulse' />
<span className='text-warning font-semibold'>待保存</span>
</div>
</>
)}
</div>
</div>
<div className='flex items-center gap-3'>
<Button
size='sm'
variant='flat'
color='default'
className='font-medium bg-white/60 dark:bg-white/[0.06] border border-white/50 dark:border-white/10 h-9'
onPress={() => {
reset();
toast.success('已重置');
}}
isDisabled={!hasUnsavedChanges}
>
重置
</Button>
<Button
size='sm'
color='primary'
className='font-semibold bg-gradient-to-r from-primary-400 to-primary-600 shadow-md shadow-primary-500/25 px-6 h-9'
isLoading={isSubmitting}
onPress={() => onSubmit()}
isDisabled={!hasUnsavedChanges}
>
保存应用
</Button>
<div className='w-px h-6 bg-divider mx-1 hidden sm:block' />
<Button
size='sm'
isIconOnly
variant='light'
className='text-default-500 hover:text-default-900 hidden sm:flex'
onPress={onRefresh}
>
<IoMdRefresh size={20} />
</Button>
</div>
</div>
</div>
<div className='w-full pb-4'>
<Tabs
aria-label='Theme Config Options'
color='primary'
variant='underlined'
disableAnimation
classNames={{
tabList: 'gap-8 w-full relative rounded-none p-0 border-b border-divider overflow-x-auto no-scrollbar',
cursor: 'w-full bg-primary h-[3px] -bottom-[1.5px]',
tab: 'max-w-fit px-0 h-12 hover:opacity-100 opacity-70 data-[selected=true]:opacity-100',
tabContent: 'font-semibold py-2',
panel: 'py-4',
}}
>
<Tab
key='font'
title={
<div className='flex items-center space-x-2'>
<FaFont />
<span>字体设置</span>
</div>
}
>
<Card className='shadow-sm border border-white/60 dark:border-white/10 bg-white/50 dark:bg-white/[0.03] backdrop-blur-xl w-full rounded-2xl'>
<CardBody className='p-5 md:p-6'>
<div className='flex flex-col gap-6 w-full'>
<div>
<h3 className='text-base font-semibold mb-1'>WebUI 字体</h3>
<p className='text-xs text-default-500 mb-4'>自定义界面显示的字体风格</p>
<Controller
control={control}
name='theme.fontMode'
render={({ field }) => (
<Select
label='选择字体'
variant='bordered'
selectedKeys={field.value ? [field.value] : ['aacute']}
onChange={(e) => field.onChange(e.target.value)}
className='max-w-xs'
disallowEmptySelection
>
<SelectItem key='aacute'>Aa 偷吃可爱长大的</SelectItem>
<SelectItem key='system'>系统默认</SelectItem>
<SelectItem key='custom'>自定义字体</SelectItem>
</Select>
)}
/>
</div>
{theme.fontMode === 'custom' && (
<div className='p-4 rounded-xl bg-default-50 border border-default-100'>
<div className='flex items-center justify-between mb-4'>
<div className='text-sm font-medium'>自定义字体文件</div>
{customFontExists && (
<Chip size='sm' color='success' variant='flat' startContent={<FaCheck size={10} />}>
已上传
</Chip>
)}
</div>
<FileInput
label='上传字体文件'
placeholder='拖拽或点击上传 (.woff/.woff2/.ttf/.otf)'
accept='.ttf,.otf,.woff,.woff2'
onChange={async (file) => {
try {
if (customFontExists) {
try {
await FileManager.deleteWebUIFont();
} catch (e) {
console.warn('Failed to delete existing font before upload:', e);
}
}
await FileManager.uploadWebUIFont(file);
// 先自动把选择的字体模式设置为 custom,并保存配置,避免刷新后又变成了旧的/没保存的模式
const updatedTheme = { ...theme, fontMode: 'custom' };
await WebUIManager.setThemeConfig(updatedTheme);
originalDataRef.current = updatedTheme;
updateFontCache('custom');
setHasUnsavedChanges(false);
loadTheme();
toast.success('上传并保存成功,即将刷新页面');
setTimeout(() => window.location.reload(), 1000);
} catch (error) {
toast.error('上传或保存失败: ' + (error as Error).message);
}
}}
onDelete={async () => {
try {
await FileManager.deleteWebUIFont();
// 删除字体后,若当前选中的是自定义字体,则将其重置为系统默认(或其它)并保存,以防页面报错
if (theme.fontMode === 'custom') {
const updatedTheme = { ...theme, fontMode: 'system' };
await WebUIManager.setThemeConfig(updatedTheme);
originalDataRef.current = updatedTheme;
updateFontCache('system');
setHasUnsavedChanges(false);
loadTheme();
}
toast.success('删除成功,即将刷新页面');
setTimeout(() => window.location.reload(), 1000);
} catch (error) {
toast.error('删除失败: ' + (error as Error).message);
}
}}
/>
<p className='text-xs text-default-400 mt-2'>
注意:上传新字体会覆盖旧字体文件,更改后需要刷新页面生效。
</p>
</div>
)}
</div>
</CardBody>
</Card>
</Tab>
<Tab
key='theme'
title={
<div className='flex items-center space-x-2'>
<IoIosColorPalette size={18} />
<span>选择主题</span>
</div>
}
>
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4'>
{themes.map((t) => (
<PreviewThemeCard
key={t.name}
theme={t}
isSelected={selectedThemeName === t.name}
onPreview={() => {
setOnebotValue('theme', { ...t.theme, fontMode: theme.fontMode });
}}
/>
))}
</div>
</Tab>
<Tab
key='custom-color'
title={
<div className='flex items-center space-x-2'>
<FaPaintbrush />
<span>自定义配色</span>
</div>
}
>
<div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
{(['light', 'dark'] as const).map((mode) => (
<Card key={mode} className={clsx('border shadow-sm', mode === 'dark' ? 'bg-[#18181b] border-zinc-800' : 'bg-white border-zinc-200')}>
<CardHeader className='pb-0 pt-4 px-4 flex-col items-start'>
<div className='flex items-center gap-2 mb-1'>
{mode === 'dark' ? <MdDarkMode className='text-zinc-400' size={20} /> : <MdLightMode className='text-orange-400' size={20} />}
<h4 className={clsx('font-bold text-large', mode === 'dark' ? 'text-white' : 'text-black')}>
{mode === 'dark' ? '深色模式' : '浅色模式'}
</h4>
</div>
<p className={clsx('text-tiny', mode === 'dark' ? 'text-zinc-400' : 'text-zinc-500')}>
调整{mode === 'dark' ? '深色' : '浅色'}主题下的颜色变量
</p>
</CardHeader>
<CardBody className='p-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
{colorKeys.map((colorKey) => (
<div
key={colorKey}
className={clsx(
'flex items-center gap-3 p-2 rounded-lg border transition-colors',
mode === 'dark' ? 'bg-zinc-900/50 border-zinc-800 hover:bg-zinc-900' : 'bg-zinc-50 border-zinc-100 hover:bg-zinc-100'
)}
>
<Controller
control={control}
name={`theme.${mode}.${colorKey}`}
render={({ field: { value, onChange } }) => {
const hslArray = value?.split(' ') ?? [0, 0, 0];
const color = `hsl(${hslArray[0]}, ${hslArray[1]}, ${hslArray[2]})`;
return (
<ColorPicker
color={color}
onChange={(hslString) => {
const match = hslString.match(/hsl\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)%,\s*(\d+(?:\.\d+)?)%\)/);
if (match) {
onChange(`${match[1]} ${match[2]}% ${match[3]}%`);
}
}}
/>
);
}}
/>
<div className='flex flex-col overflow-hidden'>
<span className={clsx('text-xs font-medium truncate', mode === 'dark' ? 'text-zinc-300' : 'text-zinc-700')}>
{colorKey.replace('--heroui-', '')}
</span>
<span className={clsx('text-[10px] truncate', mode === 'dark' ? 'text-zinc-500' : 'text-zinc-400')}>
Variable
</span>
</div>
</div>
))}
</div>
</CardBody>
</Card>
))}
</div>
</Tab>
</Tabs>
</div>
</>
);
};
export default ThemeConfigCard;
@@ -0,0 +1,576 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { useNavigate } from 'react-router-dom';
import { IoQrCode, IoShieldCheckmark } from 'react-icons/io5';
import { QRCodeSVG } from 'qrcode.react';
import key from '@/const/key';
import SaveButtons from '@/components/button/save_buttons';
import ImageInput from '@/components/input/image_input';
import { siteConfig } from '@/config/site';
import WebUIManager from '@/controllers/webui_manager';
// Base64URL to Uint8Array converter
function base64UrlToUint8Array (base64Url: string): Uint8Array {
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Uint8Array to Base64URL converter
function uint8ArrayToBase64Url (uint8Array: Uint8Array): string {
const base64 = window.btoa(String.fromCharCode(...uint8Array));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
const WebUIConfigCard = () => {
const {
control,
handleSubmit: handleWebuiSubmit,
formState: { isSubmitting },
setValue: setWebuiValue,
} = useForm({
defaultValues: {
background: '',
customIcons: {} as Record<string, string>,
},
});
const [b64img, setB64img] = useLocalStorage(key.backgroundImage, '');
const [customIcons, setCustomIcons] = useLocalStorage<Record<string, string>>(
key.customIcons,
{}
);
const [registrationOptions, setRegistrationOptions] = useState<any>(null);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
// 2FA 相关状态
const [twoFAStatus, setTwoFAStatus] = useState<{ enable2FA: boolean; hasSecret: boolean }>({ enable2FA: false, hasSecret: false });
const [twoFASecret, setTwoFASecret] = useState<string>('');
const [twoFAQrCodeUrl, setTwoFAQrCodeUrl] = useState<string>('');
const [twoFATotpCode, setTwoFATotpCode] = useState<string>('');
const [disableTotpCode, setDisableTotpCode] = useState<string>('');
const [isGeneratingSecret, setIsGeneratingSecret] = useState(false);
const [isEnabling2FA, setIsEnabling2FA] = useState(false);
const [isDisabling2FA, setIsDisabling2FA] = useState(false);
const loadTwoFAStatus = async () => {
try {
const status = await WebUIManager.get2FAStatus();
setTwoFAStatus(status);
} catch (error) {
console.error('获取2FA状态失败:', error);
}
};
const generateTwoFASecret = async () => {
setIsGeneratingSecret(true);
try {
const data = await WebUIManager.generate2FASecret();
setTwoFASecret(data.secret);
setTwoFAQrCodeUrl(data.qrCodeUrl);
toast.success('密钥已生成,请使用Authenticator应用扫码');
} catch (error) {
toast.error(`生成密钥失败: ${(error as Error).message}`);
} finally {
setIsGeneratingSecret(false);
}
};
const enableTwoFA = async () => {
if (!twoFASecret || !twoFATotpCode) {
toast.error('请先生成密钥并输入验证码');
return;
}
if (twoFATotpCode.length !== 6) {
toast.error('验证码必须是6位数字');
return;
}
setIsEnabling2FA(true);
try {
await WebUIManager.enable2FA(twoFASecret, twoFATotpCode);
await loadTwoFAStatus();
setTwoFASecret('');
setTwoFAQrCodeUrl('');
setTwoFATotpCode('');
toast.success('双因素认证已启用');
} catch (error) {
toast.error(`启用失败: ${(error as Error).message}`);
} finally {
setIsEnabling2FA(false);
}
};
const disableTwoFA = async () => {
if (!disableTotpCode || disableTotpCode.length !== 6) {
toast.error('请输入6位验证码');
return;
}
setIsDisabling2FA(true);
try {
await WebUIManager.disable2FA(disableTotpCode);
await loadTwoFAStatus();
setDisableTotpCode('');
toast.success('双因素认证已禁用');
} catch (error) {
toast.error(`禁用失败: ${(error as Error).message}`);
} finally {
setIsDisabling2FA(false);
}
};
useEffect(() => {
loadTwoFAStatus();
}, []);
// 修改密码独立表单
const navigate = useNavigate();
const [, setToken] = useLocalStorage(key.token, '');
const {
control: pwdControl,
handleSubmit: handlePwdSubmit,
formState: { isSubmitting: isPwdSubmitting, errors: pwdErrors },
watch,
} = useForm<{
oldToken: string;
newToken: string;
}>({
defaultValues: {
oldToken: '',
newToken: '',
},
});
const oldTokenValue = watch('oldToken');
// 修复类型错误:显式包装为无参函数,满足 onPress 的类型要求
const onPasswordSubmit = handlePwdSubmit(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}`);
}
});
// 预先获取注册选项(可以在任何时候调用)
const preloadRegistrationOptions = async () => {
setIsLoadingOptions(true);
try {
console.log('预先获取注册选项...');
const options = await WebUIManager.generatePasskeyRegistrationOptions();
setRegistrationOptions(options);
console.log('✅ 注册选项已获取并存储');
toast.success('注册选项已准备就绪,请点击注册按钮');
} catch (error) {
console.error('❌ 获取注册选项失败:', error);
toast.error('获取注册选项失败,请重试');
} finally {
setIsLoadingOptions(false);
}
};
const reset = () => {
setWebuiValue('customIcons', customIcons);
setWebuiValue('background', b64img);
};
const onSubmit = handleWebuiSubmit((data) => {
try {
setCustomIcons(data.customIcons);
setB64img(data.background);
toast.success('保存成功');
} catch (error) {
const msg = (error as Error).message;
toast.error(`保存失败: ${msg}`);
}
});
useEffect(() => {
reset();
}, [customIcons, b64img]);
return (
<>
<title>WebUI配置 - SnowCat WebUI</title>
{/* 修改密码区块 - 移到顶部 */}
<div className='flex flex-col gap-4'>
<div className='flex-shrink-0 w-full font-semibold text-[11px] uppercase tracking-[0.12em] text-default-400 border-l-2 border-primary-400/50 pl-2.5'>修改密码</div>
<Controller
control={pwdControl}
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={!!pwdErrors.oldToken}
errorMessage={pwdErrors.oldToken?.message}
/>
)}
/>
<Controller
control={pwdControl}
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={!!pwdErrors.newToken}
errorMessage={pwdErrors.newToken?.message}
/>
)}
/>
{/* 自定义确认按钮,无取消按钮;onPress 调用 onPasswordSubmit(已通过 handlePwdSubmit 包装,类型兼容) */}
<div className='flex justify-end'>
<Button
color='primary'
radius='full'
className='font-semibold bg-gradient-to-r from-primary-400 to-primary-600 shadow-md shadow-primary-500/25 px-6'
onPress={() => { onPasswordSubmit(); }}
isLoading={isPwdSubmitting}
>
确认修改密码
</Button>
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full font-semibold text-[11px] uppercase tracking-[0.12em] text-default-400 border-l-2 border-primary-400/50 pl-2.5'>背景图</div>
<Controller
control={control}
name='background'
render={({ field }) => (
<ImageInput
{...field}
/>
)}
/>
</div>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full font-semibold text-[12px] uppercase tracking-[0.1em] text-default-500 border-l-2 border-primary-400/60 pl-2.5'>自定义图标</div>
<div className='grid grid-cols-1 xl:grid-cols-2 gap-2'>
{siteConfig.navItems.map((item) => (
<Controller
key={item.label}
control={control}
name={`customIcons.${item.label}`}
render={({ field }) => (
<ImageInput
{...field}
label={item.label}
/>
)}
/>
))}
</div>
</div>
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full font-semibold text-[11px] uppercase tracking-[0.12em] text-default-400 border-l-2 border-primary-400/50 pl-2.5'>Passkey认证</div>
{!window.isSecureContext
? (
<div className='text-xs leading-relaxed text-warning-700 dark:text-warning-400 bg-warning-50/70 dark:bg-warning-900/20 p-3 rounded-xl border border-warning-200/70 dark:border-warning-800/40'>
⚠️ 当前处于非安全环境(即既非 HTTPS 也非 localhost),浏览器已禁用 WebAuthn 功能。<br />
如果您是通过局域网 IP 访问 WebUI,将无法注册和使用 Passkey。
</div>
)
: (
<>
<div className='text-sm text-default-400 mb-2'>
注册Passkey后,您可以更便捷地登录WebUI,无需每次输入token
</div>
<div className='flex gap-2'>
<Button
color='secondary'
variant='flat'
onPress={preloadRegistrationOptions}
isLoading={isLoadingOptions}
className='w-fit'
>
{!isLoadingOptions}
准备选项
</Button>
<Button
color='primary'
variant='flat'
onPress={() => {
// 必须在用户手势的同步上下文中立即调用WebAuthn API
if (!registrationOptions) {
toast.error('请先点击"准备选项"按钮获取注册选项');
return;
}
console.log('开始Passkey注册...');
console.log('使用预先获取的选项:', registrationOptions);
if (!navigator.credentials || !navigator.credentials.create) {
toast.error('当前浏览器环境不支持 Passkey 注册。');
return;
}
// 立即调用WebAuthn API,不要用async/await
navigator.credentials.create({
publicKey: {
challenge: base64UrlToUint8Array(registrationOptions.challenge) as BufferSource,
rp: {
name: registrationOptions.rp.name,
id: registrationOptions.rp.id,
},
user: {
id: base64UrlToUint8Array(registrationOptions.user.id) as BufferSource,
name: registrationOptions.user.name,
displayName: registrationOptions.user.displayName,
},
pubKeyCredParams: registrationOptions.pubKeyCredParams,
timeout: 30000,
excludeCredentials: registrationOptions.excludeCredentials?.map((cred: any) => ({
id: base64UrlToUint8Array(cred.id) as BufferSource,
type: cred.type,
transports: cred.transports,
})) || [],
attestation: registrationOptions.attestation,
},
}).then(async (credential) => {
console.log('✅ 注册成功!凭据已创建');
console.log('凭据ID:', (credential as PublicKeyCredential).id);
console.log('凭据类型:', (credential as PublicKeyCredential).type);
// Prepare response for verification - convert to expected format
const cred = credential as PublicKeyCredential;
const response = {
id: cred.id, // 保持为base64url字符串
rawId: uint8ArrayToBase64Url(new Uint8Array(cred.rawId)), // 转换为base64url字符串
response: {
attestationObject: uint8ArrayToBase64Url(new Uint8Array((cred.response as AuthenticatorAttestationResponse).attestationObject)), // 转换为base64url字符串
clientDataJSON: uint8ArrayToBase64Url(new Uint8Array(cred.response.clientDataJSON)), // 转换为base64url字符串
transports: (cred.response as AuthenticatorAttestationResponse).getTransports?.() || [],
},
type: cred.type,
};
console.log('准备验证响应:', response);
try {
// Verify registration
const result = await WebUIManager.verifyPasskeyRegistration(response);
if (result.verified) {
toast.success('Passkey注册成功!现在您可以使用Passkey自动登录');
setRegistrationOptions(null); // 清除已使用的选项
} else {
throw new Error('Passkey registration failed');
}
} catch (verifyError) {
console.error('❌ 验证失败:', verifyError);
const err = verifyError as Error;
toast.error(`Passkey验证失败: ${err.message}`);
}
}).catch((error) => {
console.error('❌ 注册失败:', error);
const err = error as Error;
console.error('错误名称:', err.name);
console.error('错误信息:', err.message);
// Provide more specific error messages
if (err.name === 'NotAllowedError') {
toast.error('Passkey注册被拒绝。请确保您允许了生物识别认证权限。');
} else if (err.name === 'NotSupportedError') {
toast.error('您的浏览器不支持Passkey功能。');
} else if (err.name === 'SecurityError') {
toast.error('安全错误:请确保使用HTTPS或localhost环境。');
} else {
toast.error(`Passkey注册失败: ${err.message}`);
}
});
}}
disabled={!registrationOptions}
className='w-fit'
>
注册Passkey
</Button>
</div>
{registrationOptions && (
<div className='text-xs text-green-600'>
注册选项已准备就绪,可以开始注册
</div>
)}
</>
)}
</div>
{/* 双因素认证 (2FA) */}
<div className='flex flex-col gap-2'>
<div className='flex-shrink-0 w-full flex items-center gap-2 border-l-2 border-primary-400/50 pl-2.5'>
<span className='font-semibold text-[11px] uppercase tracking-[0.12em] text-default-400'>
双因素认证 (2FA)
</span>
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium ${twoFAStatus.enable2FA ? 'bg-success-100 text-success-700 dark:bg-success-900/30 dark:text-success-300' : 'bg-default-100 text-default-500 dark:bg-white/10'}`}>
{twoFAStatus.enable2FA ? '已启用' : '未启用'}
</span>
</div>
{twoFAStatus.enable2FA
? (
<div className='bg-success-50/60 dark:bg-success-900/15 border border-success-200/70 dark:border-success-800/40 rounded-2xl p-4'>
<div className='flex items-center gap-2 text-sm font-medium text-success-700 dark:text-success-300 mb-4'>
<IoShieldCheckmark className='text-base' />
双因素认证已启用
</div>
<div className='mb-4'>
<div className='text-xs text-default-500 mb-2'>输入验证码以禁用</div>
<input
type='text'
inputMode='numeric'
pattern='[0-9]*'
maxLength={6}
value={disableTotpCode}
onChange={(e) => setDisableTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder='000000'
className='w-36 px-3 py-2.5 bg-white/80 dark:bg-white/[0.06] border border-white/70 dark:border-white/10 rounded-xl text-center text-lg font-mono tracking-[0.3em] outline-none focus:border-primary/50 transition-colors'
/>
</div>
<Button
color='danger'
variant='flat'
radius='full'
onPress={disableTwoFA}
isLoading={isDisabling2FA}
disabled={!disableTotpCode || disableTotpCode.length !== 6}
className='w-fit font-medium'
>
禁用双因素认证
</Button>
</div>
)
: (
<>
<div className='text-xs text-default-400 mb-2 leading-relaxed'>
启用后登录需额外输入 Authenticator 应用生成的 6 位验证码。
</div>
<div className='bg-white/45 dark:bg-white/[0.03] border border-white/60 dark:border-white/10 rounded-2xl p-4'>
<Button
variant='flat'
radius='full'
onPress={generateTwoFASecret}
isLoading={isGeneratingSecret}
className='w-fit mb-4 font-medium bg-white/70 dark:bg-white/[0.06] text-default-700 dark:text-default-200 border border-white/60 dark:border-white/10'
>
<IoQrCode className='mr-2' />
生成密钥
</Button>
{twoFASecret && (
<>
<div className='mb-4'>
<div className='text-xs text-default-500 mb-2'>使用 Authenticator 应用扫描下方二维码</div>
<div className='flex flex-wrap items-center gap-4'>
<div className='bg-white rounded-2xl p-3 shadow-sm ring-1 ring-black/5'>
<QRCodeSVG
value={twoFAQrCodeUrl}
size={128}
level='M'
includeMargin={false}
/>
</div>
<div>
<div className='text-[11px] text-default-400 mb-1'>密钥(手动输入用)</div>
<div className='font-mono text-xs bg-white/70 dark:bg-white/[0.06] border border-white/60 dark:border-white/10 px-3 py-2 rounded-xl break-all max-w-[220px] select-all'>
{twoFASecret}
</div>
</div>
</div>
</div>
<div className='mb-4'>
<div className='text-xs text-default-500 mb-2'>输入 Authenticator 验证码</div>
<input
type='text'
inputMode='numeric'
pattern='[0-9]*'
maxLength={6}
value={twoFATotpCode}
onChange={(e) => setTwoFATotpCode(e.target.value.replace(/\D/g, ''))}
placeholder='000000'
className='w-36 px-3 py-2.5 bg-white/80 dark:bg-white/[0.06] border border-white/70 dark:border-white/10 rounded-xl text-center text-lg font-mono tracking-[0.3em] outline-none focus:border-primary/50 transition-colors'
/>
</div>
<Button
color='primary'
radius='full'
onPress={enableTwoFA}
isLoading={isEnabling2FA}
disabled={!twoFATotpCode || twoFATotpCode.length !== 6}
className='w-fit font-semibold bg-gradient-to-r from-primary-400 to-primary-600 shadow-md shadow-primary-500/25'
>
启用双因素认证
</Button>
</>
)}
</div>
</>
)}
</div>
<SaveButtons
onSubmit={onSubmit}
reset={reset}
isSubmitting={isSubmitting}
/>
</>
);
};
export default WebUIConfigCard;
@@ -0,0 +1,284 @@
import { Button } from '@heroui/button';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useMemo, useRef, useState } from 'react';
import { IoClose } from 'react-icons/io5';
import { TbSearch } from 'react-icons/tb';
import key from '@/const/key';
import { fetchOneBotHttpApi, OneBotHttpApiPath } from '@/const/ob_api';
import type { OneBotHttpApi } from '@/const/ob_api';
import OneBotApiDebug from '@/components/onebot/api/debug';
import CommandPalette from '@/components/command_palette';
import type { CommandPaletteCommand, CommandPaletteExecuteMode } from '@/components/command_palette';
import { generateDefaultFromTypeBox } from '@/utils/typebox';
import type { OneBotApiDebugRef } from '@/components/onebot/api/debug';
export default function HttpDebug () {
const [activeApi, setActiveApi] = useState<OneBotHttpApiPath | null>(null);
const [openApis, setOpenApis] = useState<OneBotHttpApiPath[]>([]);
const [oneBotHttpApi, setOneBotHttpApi] = useState<OneBotHttpApi>({});
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const [adapterName, setAdapterName] = useState<string>('');
const [paletteOpen, setPaletteOpen] = useState(false);
const debugRefs = useRef(new Map<string, OneBotApiDebugRef>());
const [pendingRun, setPendingRun] = useState<{ path: OneBotHttpApiPath; body: string; } | null>(null);
// Ctrl/Cmd + K 打开命令面板
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setPaletteOpen(true);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// Initialize Debug Adapter and fetch schemas
useEffect(() => {
let currentAdapterName = '';
const init = async () => {
try {
const [apiData] = await Promise.all([
fetchOneBotHttpApi(),
fetch('/api/Debug/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
}).then(res => res.json()).then(data => {
if (data.code === 0) {
currentAdapterName = data.data.adapterName;
setAdapterName(currentAdapterName);
}
}),
]);
setOneBotHttpApi(apiData);
} catch (error) {
console.error('Failed to initialize debug:', error);
}
};
init();
return () => {
// 不再主动关闭 adapter,由后端自动管理活跃状态
};
}, []);
const handleSelectApi = (api: OneBotHttpApiPath) => {
if (!openApis.includes(api)) {
setOpenApis([...openApis, api]);
}
setActiveApi(api);
};
// 等对应 Debug 组件挂载后再触发发送
useEffect(() => {
if (!pendingRun) return;
if (activeApi !== pendingRun.path) return;
const ref = debugRefs.current.get(pendingRun.path);
if (!ref) return;
ref.sendWithBody(pendingRun.body);
setPendingRun(null);
}, [activeApi, pendingRun]);
const commands: CommandPaletteCommand[] = useMemo(() => {
return Object.keys(oneBotHttpApi).map((p) => {
const path = p as OneBotHttpApiPath;
const item = oneBotHttpApi[path];
const displayPath = '/' + path;
// 简单分组:按描述里已有分类不可靠,这里只用 path 前缀推断
const group = path.startsWith('get_') ? 'GET' : (path.startsWith('set_') ? 'SET' : 'API');
return {
id: path,
title: item?.description || displayPath,
subtitle: item?.payload ? '回车发送 · Shift+Enter 仅打开' : undefined,
group,
};
});
}, [oneBotHttpApi]);
const executeCommand = (commandId: string, mode: CommandPaletteExecuteMode) => {
const api = commandId as OneBotHttpApiPath;
const item = oneBotHttpApi[api];
let body = '{}';
if (item?.payloadExample) {
body = JSON.stringify(item.payloadExample, null, 2);
} else if (item?.payload) {
try {
body = JSON.stringify(generateDefaultFromTypeBox(item.payload), null, 2);
} catch (e) {
console.error('Error generating default:', e);
body = '{}';
}
}
handleSelectApi(api);
// 确保请求参数可见
const ref = debugRefs.current.get(api);
if (ref) {
if (mode === 'send') ref.sendWithBody(body);
else ref.setRequestBody(body);
return;
}
// 若还没挂载,延迟执行
if (mode === 'send') setPendingRun({ path: api, body });
};
const handleCloseTab = (e: React.MouseEvent, apiToRemove: OneBotHttpApiPath) => {
e.stopPropagation();
const newOpenApis = openApis.filter((api) => api !== apiToRemove);
setOpenApis(newOpenApis);
if (activeApi === apiToRemove) {
if (newOpenApis.length > 0) {
setActiveApi(newOpenApis[newOpenApis.length - 1]);
} else {
setActiveApi(null);
}
}
};
return (
<>
<title>HTTP调试 - SnowCat WebUI</title>
<div className='h-[calc(100vh-3.5rem)] pt-3 px-3 md:px-6'>
<div className={clsx(
'h-full flex flex-col overflow-hidden transition-all relative',
// 'rounded-none md:rounded-2xl border', // Removing the main border/radius
// hasBackground
// ? 'bg-white/5 dark:bg-black/5 backdrop-blur-sm border-white/10'
// : 'bg-white/40 dark:bg-black/20 backdrop-blur-md shadow-sm border-white/40 dark:border-white/10'
'bg-transparent'
)}
>
<div className='flex-1 flex flex-col overflow-hidden relative'>
<div className={clsx(
'flex items-center w-full flex-shrink-0 pr-2 md:pl-4 py-1 relative z-20 rounded-md',
hasBackground
? 'bg-white/5'
: 'bg-white/30 dark:bg-white/5'
)}
>
{/* Tab List */}
<div className='flex-1 overflow-x-auto no-scrollbar flex items-center'>
{openApis.map((api) => {
const isActive = api === activeApi;
const item = oneBotHttpApi[api];
return (
<div
key={api}
onClick={() => setActiveApi(api)}
className={clsx(
'group flex items-center gap-2 px-3 h-8 my-1 mr-1 rounded-md cursor-pointer border select-none transition-all min-w-[120px] max-w-[260px]',
hasBackground ? 'border-transparent hover:bg-white/10' : 'border-transparent hover:bg-white/10 dark:hover:bg-white/5',
isActive
? (hasBackground
? 'bg-white/15 text-white border-white/20'
: 'bg-default-100 dark:bg-white/15 text-foreground dark:text-white font-medium shadow-sm border-default-200 dark:border-white/10')
: (hasBackground ? 'text-white/70 hover:text-white' : 'text-default-600 dark:text-white/70 hover:text-default-900 dark:hover:text-white')
)}
>
<span className={clsx(
'text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-sm',
isActive
? 'bg-success/20 text-success'
: 'opacity-60 bg-default-200/50 dark:bg-white/10'
)}
>POST
</span>
<span className='text-xs truncate flex-1'>{item?.description || api}</span>
<div
className={clsx(
'p-0.5 rounded-sm hover:bg-black/10 dark:hover:bg-white/20 transition-opacity',
isActive ? 'opacity-40 hover:opacity-100' : 'opacity-0 group-hover:opacity-40'
)}
onClick={(e) => handleCloseTab(e, api)}
>
<IoClose size={12} />
</div>
</div>
);
})}
</div>
{/* Actions */}
<div className='flex items-center gap-2 pl-2 border-l border-white/5 flex-shrink-0'>
<Button
isIconOnly
size='sm'
radius='sm'
variant='light'
className='text-default-500 hover:text-primary w-10 h-10 min-w-10'
onClick={() => setPaletteOpen(true)}
onPress={() => setPaletteOpen(true)}
>
<TbSearch size={18} />
</Button>
</div>
</div>
{/* Content Panels */}
<div className='flex-1 relative overflow-hidden'>
{activeApi === null && (
<div className='h-full flex flex-col items-center justify-center gap-2.5 text-default-400 select-none'>
<div className='w-14 h-14 rounded-2xl bg-white/60 dark:bg-white/[0.06] border border-white/60 dark:border-white/10 flex items-center justify-center text-primary-400 mb-1'>
<TbSearch size={24} />
</div>
<p className='text-sm font-semibold text-default-600 dark:text-default-300'>还没有打开任何接口</p>
<p className='text-xs'>
按
<kbd className='mx-1 px-1.5 py-0.5 rounded-md bg-white/70 dark:bg-white/10 border border-white/60 dark:border-white/10 font-mono text-[10px]'>
Ctrl / ⌘ + K
</kbd>
搜索并打开一个 OneBot 接口
</p>
</div>
)}
{openApis.map((api) => (
<div
key={api}
className={clsx(
'h-full w-full absolute top-0 left-0 transition-opacity duration-200',
api === activeApi ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
)}
>
<OneBotApiDebug
ref={(node) => {
if (!node) {
debugRefs.current.delete(api);
return;
}
debugRefs.current.set(api, node);
}}
path={api}
data={oneBotHttpApi[api]}
adapterName={adapterName}
/>
</div>
))}
</div>
</div>
</div>
</div>
<CommandPalette
isOpen={paletteOpen}
onOpenChange={setPaletteOpen}
commands={commands}
onExecute={executeCommand}
/>
</>
);
}
@@ -0,0 +1,5 @@
import { Outlet } from 'react-router-dom';
export default function DebugPage () {
return <Outlet />;
}
@@ -0,0 +1,237 @@
import { Button } from '@heroui/button';
import { Card, CardBody } from '@heroui/card';
import { Input } from '@heroui/input';
import { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useCallback, useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { IoFlash, IoFlashOff, IoRefresh } from 'react-icons/io5';
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 [shouldConnect, setShouldConnect] = useState(false);
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
const { sendMessage, readyState, FilterMessagesType, filteredMessages, clearMessages } =
useWebSocketDebug(socketConfig.url, socketConfig.token, shouldConnect);
// Auto fetch adapter and set URL
useEffect(() => {
// 检查是否应该覆盖 URL
const isDefaultUrl = socketConfig.url === defaultWsUrl || socketConfig.url === '';
const isWebDebugUrl = socketConfig.url && socketConfig.url.includes('/api/Debug/ws');
if (!isDefaultUrl && !isWebDebugUrl) {
setInputUrl(socketConfig.url);
setInputToken(socketConfig.token);
return; // 已经有自定义/有效的配置,跳过自动创建
}
const initAdapter = async () => {
try {
const response = await fetch('/api/Debug/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
const data = await response.json();
if (data.code === 0) {
// const adapterName = data.data.adapterName;
const token = data.data.token;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
if (token) {
// URL 中不再包含 Token,Token 单独放入输入框
const wsUrl = `${protocol}//${window.location.host}/api/Debug/ws`;
setSocketConfig({
url: wsUrl,
token,
});
setInputUrl(wsUrl);
setInputToken(token);
}
}
} catch (error) {
console.error('Failed to create debug adapter:', error);
}
};
initAdapter();
}, []);
const handleConnect = useCallback(() => {
// 允许以 / 开头的相对路径(如代理情况),以及标准的 ws/wss
let finalUrl = inputUrl;
if (finalUrl.startsWith('/')) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
finalUrl = `${protocol}//${window.location.host}${finalUrl}`;
}
if (!finalUrl.startsWith('ws://') && !finalUrl.startsWith('wss://')) {
toast.error('WebSocket URL 不合法');
return;
}
setSocketConfig({
url: finalUrl,
token: inputToken,
});
setShouldConnect(true);
}, [inputUrl, inputToken, setSocketConfig]);
const handleDisconnect = useCallback(() => {
setShouldConnect(false);
}, []);
const handleResetConfig = useCallback(() => {
setSocketConfig({ url: '', token: '' });
// 刷新页面以重新触发初始逻辑
window.location.reload();
}, [setSocketConfig]);
return (
<>
<title>Websocket调试 - SnowCat WebUI</title>
<div className='h-[calc(100vh-3.5rem)] overflow-hidden flex flex-col px-3 md:px-6 py-4 md:py-6 gap-3 md:gap-4'>
{/* Config Card */}
<Card className={clsx(
'flex-shrink-0 backdrop-blur-xl border shadow-sm rounded-2xl',
hasBackground
? 'bg-black/25 dark:bg-black/30 border-white/20 dark:border-white/10'
: 'bg-white/60 dark:bg-white/[0.04] border-white/60 dark:border-white/10'
)}
>
<CardBody className='gap-3 p-3 md:p-4'>
{/* Connection Config */}
<div className='grid gap-3 items-end md:grid-cols-[1fr_1fr_auto]'>
<Input
label='WebSocket URL'
type='text'
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
placeholder='输入 WebSocket URL'
size='sm'
variant='bordered'
classNames={{
inputWrapper: clsx(
'backdrop-blur-sm border',
hasBackground
? 'bg-white/10 border-white/20'
: 'bg-white/80 dark:bg-white/[0.06] border-black/[0.08] dark:border-white/12'
),
label: hasBackground ? 'text-white/80' : '',
input: hasBackground ? 'text-white placeholder:text-white/50' : '',
}}
/>
<Input
label='Token'
type='text'
value={inputToken}
onChange={(e) => setInputToken(e.target.value)}
placeholder='输入 Token (可选)'
size='sm'
variant='bordered'
classNames={{
inputWrapper: clsx(
'backdrop-blur-sm border',
hasBackground
? 'bg-white/10 border-white/20'
: 'bg-white/80 dark:bg-white/[0.06] border-black/[0.08] dark:border-white/12'
),
label: hasBackground ? 'text-white/80' : '',
input: hasBackground ? 'text-white placeholder:text-white/50' : '',
}}
/>
<div className='flex gap-2'>
<Button
isIconOnly
size='md'
radius='full'
color='warning'
variant='flat'
onPress={handleResetConfig}
title='重置配置'
>
<IoRefresh className='text-xl' />
</Button>
<Button
onPress={shouldConnect ? handleDisconnect : handleConnect}
size='md'
radius='full'
color={shouldConnect ? 'danger' : 'primary'}
className='font-bold shadow-lg min-w-[100px] flex-1'
startContent={shouldConnect ? <IoFlashOff /> : <IoFlash />}
>
{shouldConnect ? '断开' : '连接'}
</Button>
</div>
</div>
{/* Status Bar */}
<div className={clsx(
'p-2.5 rounded-xl border transition-colors flex flex-col md:flex-row gap-3 md:items-center md:justify-between',
hasBackground
? 'bg-white/10 border-white/20'
: 'bg-white/50 dark:bg-white/5 border-white/20'
)}
>
<div className='flex items-center gap-3 w-full md:w-auto'>
<div className='flex-shrink-0'>
<WSStatus state={readyState} />
</div>
<div className='flex-1 md:w-56 overflow-hidden'>
{FilterMessagesType}
</div>
</div>
<div className='flex gap-2 justify-end w-full md:w-auto pt-1 md:pt-0 border-t border-white/5 md:border-t-0'>
<Button
size='sm'
color='danger'
variant='flat'
radius='full'
className='font-medium'
onPress={clearMessages}
>
清空日志
</Button>
<OneBotSendModal sendMessage={sendMessage} />
</div>
</div>
</CardBody>
</Card>
{/* Message List */}
<div className={clsx(
'flex-1 overflow-hidden rounded-2xl border backdrop-blur-xl',
hasBackground
? 'bg-black/25 dark:bg-black/30 border-white/20 dark:border-white/10'
: 'bg-white/60 dark:bg-white/[0.04] border-white/60 dark:border-white/10'
)}
>
<OneBotMessageList messages={filteredMessages} />
</div>
</div>
</>
);
}
@@ -0,0 +1,232 @@
import { Tab, Tabs } from '@heroui/tabs';
import { Button } from '@heroui/button';
import { Spinner } from '@heroui/spinner';
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io';
import { MdExtension } from 'react-icons/md';
import EmptyState from '@/components/empty_state';
import PageHeader from '@/components/page_header';
import PageLoading from '@/components/page_loading';
import pluginManager from '@/controllers/plugin_manager';
interface ExtensionPage {
pluginId: string;
pluginName: string;
path: string;
title: string;
icon?: string;
description?: string;
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export default function ExtensionPage () {
const [loading, setLoading] = useState(true);
const [extensionPages, setExtensionPages] = useState<ExtensionPage[]>([]);
const [selectedTab, setSelectedTab] = useState<string>('');
const [iframeLoading, setIframeLoading] = useState(false);
const fetchExtensionPages = async () => {
setLoading(true);
try {
const result = await pluginManager.getPluginList();
if (result.pluginManagerNotFound) {
setExtensionPages([]);
} else {
setExtensionPages(result.extensionPages || []);
// 默认选中第一个
if (result.extensionPages?.length > 0 && !selectedTab) {
setSelectedTab(`${result.extensionPages[0].pluginId}:${result.extensionPages[0].path}`);
}
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`获取扩展页面失败: ${msg}`);
} finally {
setLoading(false);
}
};
const refresh = async () => {
await fetchExtensionPages();
};
// 生成 tabs
const tabs = useMemo(() => {
return extensionPages.map(page => ({
key: `${page.pluginId}:${page.path}`,
title: page.title,
pluginId: page.pluginId,
pluginName: page.pluginName,
path: page.path,
icon: page.icon,
description: page.description,
}));
}, [extensionPages]);
// 获取当前选中页面的 iframe URL
// 新路由格式不需要鉴权: /plugin/:pluginId/page/:pagePath
const currentPageUrl = useMemo(() => {
if (!selectedTab) return '';
const [pluginId, ...pathParts] = selectedTab.split(':');
const path = pathParts.join(':').replace(/^\//, '');
return `/plugin/${pluginId}/page/${path}`;
}, [selectedTab]);
useEffect(() => {
fetchExtensionPages();
}, []);
useEffect(() => {
if (currentPageUrl) {
setIframeLoading(true);
}
}, [currentPageUrl]);
const handleIframeLoad = () => {
setIframeLoading(false);
};
// 在新窗口打开页面(新路由不需要鉴权)
const openInNewWindow = (pluginId: string, path: string) => {
const cleanPath = path.replace(/^\//, '');
const url = `/plugin/${pluginId}/page/${cleanPath}`;
window.open(url, '_blank');
};
// 拖拽滚动支持(鼠标 + 触摸)
const scrollRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const startX = useRef(0);
const scrollLeft = useRef(0);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
const el = scrollRef.current;
if (!el) return;
isDragging.current = true;
startX.current = e.clientX;
scrollLeft.current = el.scrollLeft;
el.setPointerCapture(e.pointerId);
el.style.cursor = 'grabbing';
el.style.userSelect = 'none';
}, []);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!isDragging.current || !scrollRef.current) return;
const dx = e.clientX - startX.current;
scrollRef.current.scrollLeft = scrollLeft.current - dx;
}, []);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!isDragging.current || !scrollRef.current) return;
isDragging.current = false;
scrollRef.current.releasePointerCapture(e.pointerId);
scrollRef.current.style.cursor = 'grab';
scrollRef.current.style.userSelect = '';
}, []);
return (
<>
<title>扩展页面 - SnowCat WebUI</title>
<div className='sc-page h-[calc(100vh-3.5rem)] flex flex-col'>
<PageLoading loading={loading} />
<PageHeader
icon={<MdExtension size={20} />}
title='扩展页面'
description='插件注册的扩展界面,可直接在控制台内打开'
accent='secondary'
actions={
<Button
isIconOnly
className='bg-white/60 dark:bg-white/10 text-default-600 dark:text-default-300 backdrop-blur-md hover:bg-white/80 dark:hover:bg-white/20'
radius='full'
onPress={refresh}
>
<IoMdRefresh size={22} />
</Button>
}
/>
{extensionPages.length > 0 && (
<div
ref={scrollRef}
className='overflow-x-auto min-w-0 w-full mb-3 scrollbar-thin scrollbar-thumb-default-300 scrollbar-track-transparent cursor-grab touch-pan-x'
style={{ WebkitOverflowScrolling: 'touch' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<Tabs
aria-label='Extension Pages'
className='w-max min-w-full'
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(key as string)}
classNames={{
tabList: 'sc-tablist flex-nowrap',
cursor: 'sc-tab-cursor',
panel: 'hidden',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={
<div className='flex items-center gap-2 whitespace-nowrap'>
{tab.icon && <span>{tab.icon}</span>}
<span
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none'
title={`插件:${tab.pluginName}\n点击在新窗口打开`}
onClick={(e) => {
e.stopPropagation();
openInNewWindow(tab.pluginId, tab.path);
}}
>
{tab.title}
</span>
<span className='text-xs text-default-400 hidden md:inline'>({tab.pluginName})</span>
</div>
}
/>
))}
</Tabs>
</div>
)}
{extensionPages.length === 0 && !loading
? (
<EmptyState
icon={<MdExtension size={22} />}
title='暂无插件扩展页面'
description='插件可以通过注册页面来扩展 WebUI 功能,注册后会在这里出现。'
className='flex-1'
/>
)
: (
<div className='flex-1 min-h-0 sc-panel overflow-hidden relative'>
{iframeLoading && (
<div className='absolute inset-0 flex items-center justify-center bg-white/40 dark:bg-black/30 backdrop-blur-sm z-10'>
<Spinner size='lg' />
</div>
)}
{/*
插件页面由本站 /plugin/:id/page/* 同源提供,本身就运行在同一 origin 下。
此前 sandbox 同时给了 allow-scripts + allow-same-origin,
等价于沙箱可被自身解除,浏览器会持续告警且毫无隔离收益;
这里直接不设 sandbox,让插件页面正常使用同源存储与接口。
*/}
<iframe
src={currentPageUrl}
className='w-full h-full border-0'
onLoad={handleIframeLoad}
title='extension-page'
allow='clipboard-read; clipboard-write; fullscreen'
/>
</div>
)}
</div>
</>
);
}
@@ -0,0 +1,590 @@
/* eslint-disable @stylistic/indent */
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 { useLocalStorage } from '@uidotdev/usehooks';
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 key from '@/const/key';
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, open } = useDropzone({
onDrop,
noClick: true, // 禁用自动点击,使用 open() 手动触发
onDragOver: (e) => {
e.preventDefault();
e.stopPropagation();
},
useFsAccessApi: false, // 添加此选项以避免某些浏览器的文件系统API问题
});
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return (
<div className='h-full flex flex-col relative gap-4 w-full px-3 md:px-6 py-4 md:py-6'>
<div className={clsx(
'mb-2 flex flex-col md:flex-row items-stretch md:items-center gap-3 sticky top-14 z-10 backdrop-blur-xl py-2.5 px-3 md:px-4 rounded-2xl transition-colors border border-white/60 dark:border-white/10 shadow-sm',
hasBackground
? 'bg-black/25 dark:bg-black/30'
: 'bg-white/65 dark:bg-white/[0.05]'
)}
>
<div className='flex items-center gap-2 overflow-x-auto hide-scrollbar pb-1 md:pb-0'>
<Button
radius='sm'
color='primary'
size='sm'
isIconOnly
variant='flat'
onPress={() => handleDirectoryClick('..')}
className='text-lg min-w-8'
>
<TiArrowBack />
</Button>
<Button
radius='sm'
color='primary'
size='sm'
isIconOnly
variant='flat'
onPress={() => setIsCreateModalOpen(true)}
className='text-lg min-w-8'
>
<FiPlus />
</Button>
<Button
radius='sm'
color='primary'
isLoading={loading}
size='sm'
isIconOnly
variant='flat'
onPress={loadFiles}
className='text-lg min-w-8'
>
<MdRefresh />
</Button>
<Button
radius='sm'
color='primary'
size='sm'
isIconOnly
variant='flat'
onPress={() => setShowUpload((prev) => !prev)}
className='text-lg min-w-8'
>
<FiUpload />
</Button>
{((selectedFiles instanceof Set && selectedFiles.size > 0) ||
selectedFiles === 'all') && (
<>
<Button
radius='sm'
color='primary'
size='sm'
variant='flat'
onPress={handleBatchDelete}
className='text-sm px-2 min-w-fit'
startContent={<TbTrash className='text-lg' />}
>
(
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button>
<Button
radius='sm'
color='primary'
size='sm'
variant='flat'
onPress={() => {
setMoveTargetPath('');
setIsMoveModalOpen(true);
}}
className='text-sm px-2 min-w-fit'
startContent={<FiMove className='text-lg' />}
>
(
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button>
<Button
radius='sm'
color='primary'
size='sm'
variant='flat'
onPress={handleBatchDownload}
className='text-sm px-2 min-w-fit'
startContent={<FiDownload className='text-lg' />}
>
(
{selectedFiles instanceof Set ? selectedFiles.size : files.length}
)
</Button>
</>
)}
</div>
<div className='flex flex-col md:flex-row flex-1 gap-2 overflow-hidden items-stretch md:items-center'>
<Breadcrumbs
radius='sm'
className='flex-1 bg-white/50 dark:bg-white/[0.05] backdrop-blur-md shadow-sm border border-white/60 dark:border-white/10 px-2 py-2 rounded-xl overflow-x-auto hide-scrollbar whitespace-nowrap'
classNames={{ list: 'items-center' }}
>
{(currentPath.split('/').filter(Boolean).length > 0
? currentPath.split('/').filter(Boolean)
: ['根目录']
).map((part, index, parts) => (
<BreadcrumbItem
key={`${part}-${index}`}
isCurrent={index === parts.length - 1}
onPress={() => {
if (parts[0] === '根目录') return;
const newPath = parts.slice(0, index + 1).join('/');
navigate(`/file_manager#${encodeURIComponent(newPath)}`);
}}
>
{part}
</BreadcrumbItem>
))}
</Breadcrumbs>
<Input
radius='sm'
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='w-full md:w-64'
classNames={{
inputWrapper: 'bg-white/70 dark:bg-white/[0.05] backdrop-blur-md border border-black/[0.08] dark:border-white/12',
}}
/>
</div>
</div>
<motion.div
initial={{ height: 0 }}
animate={{ height: showUpload ? 'auto' : 0 }}
transition={{ duration: 0.2 }}
className={clsx(
'rounded-2xl text-center overflow-hidden transition-colors',
isDragActive
? 'border-primary/60 bg-primary/[0.08]'
: 'border-default-200/80 dark:border-white/10 bg-white/30 dark:bg-white/[0.02]',
showUpload ? 'mb-4 border-2 border-dashed' : 'border-0'
)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<div {...getRootProps()} className='w-full h-full p-5 cursor-pointer hover:bg-white/50 dark:hover:bg-white/[0.05] transition-colors'>
<input {...getInputProps()} multiple />
<div className='flex flex-col items-center gap-2.5'>
<div className='w-11 h-11 rounded-2xl bg-primary-500/10 text-primary-500 flex items-center justify-center'>
<FiUpload className='text-xl' />
</div>
<p className='text-sm text-default-600'>拖拽文件到此处上传</p>
<Button radius='full' color='primary' size='sm' variant='flat' onPress={open}>
点击选择文件
</Button>
</div>
</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>
);
}
@@ -0,0 +1,174 @@
import { Card, CardBody } from '@heroui/card';
import { Chip } from '@heroui/chip';
import { Divider } from '@heroui/divider';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks';
import clsx from 'clsx';
import { useCallback, useEffect, useRef } from 'react';
import key from '@/const/key';
import toast from 'react-hot-toast';
import { HiOutlineBolt } from 'react-icons/hi2';
import { LuSignal } from 'react-icons/lu';
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';
import { getNetworkList } from '@/utils/config';
const Networks: React.FC = () => {
const { config, refreshConfig } = useConfig();
const httpServers = getNetworkList(config, 'httpServers');
const httpClients = getNetworkList(config, 'httpClients');
const websocketServers = getNetworkList(config, 'websocketServers');
const websocketClients = getNetworkList(config, 'websocketClients');
const httpSseServers = getNetworkList(config, 'httpSseServers');
const mcpServers = getNetworkList(config, 'mcpServers');
const allNetWorkConfigLength =
httpClients.length +
websocketClients.length +
websocketServers.length +
httpServers.length +
httpSseServers.length +
mcpServers.length;
useEffect(() => {
refreshConfig();
}, []);
return (
<Card
className={clsx(
'backdrop-blur-md border border-white/40 dark:border-white/10 shadow-sm',
'bg-white/60 dark:bg-white/[0.04]'
)}
shadow='none'
>
<CardBody className='p-4 gap-3'>
<div className='flex items-center gap-2'>
<LuSignal className='text-primary' size={16} />
<span className='text-small font-semibold text-default-700 dark:text-default-200'>接入点总览</span>
<Chip size='sm' variant='flat' color='primary' className='h-6'>
{allNetWorkConfigLength} 条
</Chip>
<Divider className='flex-1 opacity-40' />
</div>
<div className='grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3'>
<NetworkItemDisplay count={allNetWorkConfigLength} label='全部接入点' accent />
<NetworkItemDisplay count={httpServers.length} label='HTTP 服务器' />
<NetworkItemDisplay count={httpClients.length} label='HTTP 客户端' />
<NetworkItemDisplay count={websocketServers.length} label='WS 服务器' />
<NetworkItemDisplay count={websocketClients.length} label='WS 客户端' />
<NetworkItemDisplay count={mcpServers.length} label='MCP 服务器' />
</div>
</CardBody>
</Card>
);
};
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] = useLocalStorage<SystemStatus | undefined>('snowcat_system_status_cache', undefined);
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] = useLocalStorage<string | undefined>('snowcat_arch_info_cache', undefined);
// @ts-ignore
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return (
<>
<title>总览 - SnowCat WebUI · 雪猫控制台</title>
<section className='w-full px-3 md:px-6 py-4 md:py-6 overflow-hidden'>
{/* 页首横幅:雪猫品牌带(全宽) */}
<div
className={clsx(
'mb-4 rounded-2xl px-5 py-4 flex flex-wrap items-center justify-between gap-4',
'bg-gradient-to-r from-primary-100/70 via-primary-50/40 to-transparent',
'dark:from-primary-900/30 dark:via-primary-900/10 dark:to-transparent',
'border border-primary-100/50 dark:border-primary-900/30'
)}
>
<div className='min-w-0'>
<h1 className='text-xl font-bold text-default-900 dark:text-white flex items-center gap-2'>
<HiOutlineBolt className='text-primary' />
雪猫运行总览
</h1>
<p className='text-tiny text-default-500 mt-1'>
账号 · 系统 · 接入点 一屏尽览(NapCat + SnowLuma 融合自持版)
</p>
</div>
<Chip variant='flat' color='secondary' size='sm' className='flex-shrink-0'>
SnowCat.Onebot
</Chip>
</div>
{/* 主体:三段式全宽栅格 */}
<div className='grid grid-cols-1 lg:grid-cols-12 gap-4 items-stretch'>
<div className='lg:col-span-4 xl:col-span-3 flex flex-col gap-4 min-w-0'>
<QQInfo />
<SystemInfo archInfo={archInfo} />
</div>
<div className='lg:col-span-8 xl:col-span-9 flex flex-col gap-4 min-w-0'>
<SystemStatusCard setArchInfo={setArchInfo} />
<Networks />
</div>
</div>
<Card
className={clsx(
'mt-4 backdrop-blur-sm border border-white/40 dark:border-white/10 shadow-sm transition-all overflow-hidden',
hasBackground ? 'bg-black/25 dark:bg-black/30' : 'bg-white/60 dark:bg-white/[0.04]'
)}
>
<CardBody className='overflow-hidden'>
<Hitokoto />
</CardBody>
</Card>
</section>
</>
);
};
export default DashboardIndexPage;
@@ -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-3.5rem)] flex flex-col gap-3 items-center pt-4 px-3 md:px-6'>
<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: 'sc-tablist',
cursor: 'sc-tab-cursor',
}}
>
<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>
);
}
@@ -0,0 +1,453 @@
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 McpServerDisplayCard from '@/components/display_card/mcp_server';
import WebsocketClientDisplayCard from '@/components/display_card/ws_client';
import WebsocketServerDisplayCard from '@/components/display_card/ws_server';
import EmptyState from '@/components/empty_state';
import NetworkFormModal from '@/components/network_edit/modal';
import PageHeader from '@/components/page_header';
import PageLoading from '@/components/page_loading';
import { LuSignal } from 'react-icons/lu';
import useConfig from '@/hooks/use-config';
import useDialog from '@/hooks/use-dialog';
import { getNetworkList } from '@/utils/config';
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(isEmpty ? 'block' : 'hidden')}>
<EmptyState
compact
icon={<LuSignal size={20} />}
title='这一栏还是空的'
description='点击右上角「新增」添加第一个接入点'
/>
</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 = [],
mcpServers = [],
} = {},
} = config;
const [loading, setLoading] = useState(false);
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const dialog = useDialog();
const activeData = useMemo(() => {
const findData = getNetworkList(config, 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 'mcpServers':
return (
<McpServerDisplayCard
key={item.name}
showType={showType}
data={item as OneBotConfig['network']['mcpServers'][0]}
onDelete={async () => {
await onDelete('mcpServers', item.name);
}}
onEdit={() => {
onEdit('mcpServers', item.name);
}}
onEnable={async () => {
await onEnable('mcpServers', item.name);
}}
onEnableDebug={async () => {
await onEnableDebug('mcpServers', 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,
...mcpServers,
]
.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
);
}
if (mcpServers.find((i) => i.name === item.name)) {
return renderCard(
'mcpServers',
item as OneBotConfig['network']['mcpServers'][0],
true
);
}
return null;
}),
},
{
key: 'mcpServers',
title: 'MCP服务器',
items: mcpServers.map((item) => renderCard('mcpServers', item)),
},
{
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>网络配置 - SnowCat WebUI</title>
<div className='sc-page'>
<NetworkFormModal
data={activeData}
field={activeField}
isOpen={isOpen}
onOpenChange={onOpenChange}
/>
<PageLoading loading={loading} />
<PageHeader
icon={<LuSignal size={20} />}
title='网络配置'
description='WS / HTTP / SSE / MCP 四类接入点,一处统一管理'
actions={
<>
<AddButton onOpen={handleClickCreate} />
<Button
isIconOnly
className='bg-white/60 dark:bg-white/10 text-default-600 dark:text-default-300 backdrop-blur-md hover:bg-white/80 dark:hover:bg-white/20'
radius='full'
onPress={refresh}
>
<IoMdRefresh size={22} />
</Button>
</>
}
/>
<Tabs
aria-label='Network Configs'
className='max-w-full'
items={tabs}
classNames={{
tabList: 'sc-tablist',
cursor: 'sc-tab-cursor',
}}
>
{(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>
</>
);
}
@@ -0,0 +1,233 @@
import { Button } from '@heroui/button';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh } from 'react-icons/io';
// import { FiUpload } from 'react-icons/fi';
import { useDisclosure } from '@heroui/modal';
import { LuPackage } from 'react-icons/lu';
import EmptyState from '@/components/empty_state';
import PageHeader from '@/components/page_header';
import PageLoading from '@/components/page_loading';
import PluginDisplayCard from '@/components/display_card/plugin_card';
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
import useDialog from '@/hooks/use-dialog';
import PluginConfigModal from '@/pages/dashboard/plugin_config_modal';
export default function PluginPage () {
const [plugins, setPlugins] = useState<PluginItem[]>([]);
const [loading, setLoading] = useState(false);
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
const dialog = useDialog();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [currentPluginId, setCurrentPluginId] = useState<string>('');
// const fileInputRef = useRef<HTMLInputElement>(null);
const loadPlugins = async () => {
setLoading(true);
setPluginManagerNotFound(false);
try {
const listResult = await PluginManager.getPluginList();
if (listResult.pluginManagerNotFound) {
setPluginManagerNotFound(true);
setPlugins([]);
} else {
setPlugins(listResult.plugins);
}
} catch (e: any) {
toast.error(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPlugins();
}, []);
const handleToggle = async (plugin: PluginItem) => {
const isEnable = plugin.status !== 'active';
const actionText = isEnable ? '启用' : '禁用';
const loadingToast = toast.loading(`${actionText}中...`);
try {
await PluginManager.setPluginStatus(plugin.id, isEnable);
toast.success(`${actionText}成功`, { id: loadingToast });
loadPlugins();
} catch (e: any) {
toast.error(e.message, { id: loadingToast });
}
};
const handleUninstall = async (plugin: PluginItem) => {
return new Promise<void>((resolve, reject) => {
let cleanData = false;
dialog.confirm({
title: '卸载插件',
content: (
<div className='flex flex-col gap-2'>
<p className='text-base text-default-800'>确定要卸载插件「<span className='font-semibold text-danger'>{plugin.name}</span>」吗? 此操作不可恢复。</p>
<div className='mt-2 bg-default-100 dark:bg-default-50/10 p-3 rounded-lg flex flex-col gap-1'>
<label className='flex items-center gap-2 cursor-pointer w-fit'>
<input
type='checkbox'
onChange={(e) => { cleanData = e.target.checked; }}
className='w-4 h-4 cursor-pointer accent-danger'
/>
<span className='text-small font-medium text-default-700'>同时删除其配置文件</span>
</label>
<p className='text-xs text-default-500 pl-6 break-all w-full'>配置目录: config/plugins/{plugin.id}</p>
</div>
</div>
),
confirmText: '确定卸载',
cancelText: '取消',
onConfirm: async () => {
const loadingToast = toast.loading('卸载中...');
try {
await PluginManager.uninstallPlugin(plugin.id, cleanData);
toast.success('卸载成功', { id: loadingToast });
loadPlugins();
resolve();
} catch (e: any) {
toast.error(e.message, { id: loadingToast });
reject(e);
}
},
onCancel: () => {
resolve();
},
});
});
};
const handleConfig = (plugin: PluginItem) => {
setCurrentPluginId(plugin.id);
onOpen();
};
// const handleImportClick = () => {
// if (pluginManagerNotFound) {
// dialog.confirm({
// title: '插件管理器未加载',
// content: (
// <div className="space-y-2">
// <p className="text-sm text-default-600">
// 插件管理器尚未加载,无法导入插件。
// </p>
// <p className="text-sm text-default-600">
// 是否立即注册插件管理器?
// </p>
// </div>
// ),
// confirmText: '注册插件管理器',
// cancelText: '取消',
// onConfirm: async () => {
// try {
// await PluginManager.registerPluginManager();
// toast.success('插件管理器注册成功');
// setPluginManagerNotFound(false);
// // 注册成功后打开文件选择器
// fileInputRef.current?.click();
// } catch (e: any) {
// toast.error('注册失败: ' + e.message);
// }
// },
// });
// return;
// }
// fileInputRef.current?.click();
// };
// const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
// const file = e.target.files?.[0];
// if (!file) return;
// // 重置 input,允许重复选择同一文件
// e.target.value = '';
// if (!file.name.endsWith('.zip')) {
// toast.error('请选择 .zip 格式的插件包');
// return;
// }
// const loadingToast = toast.loading('正在导入插件...');
// try {
// const result = await PluginManager.importLocalPlugin(file);
// toast.success(result.message, { id: loadingToast });
// loadPlugins();
// } catch (err: any) {
// toast.error(err.message || '导入失败', { id: loadingToast });
// }
// };
return (
<>
<title>插件管理 - SnowCat WebUI</title>
<div className='sc-page'>
<PageLoading loading={loading} />
<PluginConfigModal
isOpen={isOpen}
onOpenChange={onOpenChange}
pluginId={currentPluginId}
/>
<PageHeader
icon={<LuPackage size={20} />}
title='插件管理'
description='加载、启停与配置本地插件'
actions={
<Button
isIconOnly
className='bg-white/60 dark:bg-white/10 text-default-600 dark:text-default-300 backdrop-blur-md hover:bg-white/80 dark:hover:bg-white/20'
radius='full'
onPress={loadPlugins}
>
<IoMdRefresh size={22} />
</Button>
}
/>
{pluginManagerNotFound
? (
<EmptyState
icon={<LuPackage size={22} />}
title='插件管理器未加载'
description='没有找到 plugins 目录,请确认 SnowCat 的插件目录是否存在且可读。'
/>
)
: plugins.length === 0
? (
<EmptyState
icon={<LuPackage size={22} />}
title='暂时还没有安装插件'
description='把插件放进 plugins 目录后刷新,或到「插件商店」挑一个装上。'
/>
)
: (
<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-3 md:gap-4'>
{plugins.map(plugin => (
<PluginDisplayCard
key={plugin.id}
data={plugin}
onToggleStatus={() => handleToggle(plugin)}
onUninstall={() => handleUninstall(plugin)}
onConfig={() => {
if (plugin.status !== 'active') {
toast.error('未启用插件,无法配置插件');
} else if (plugin.hasConfig) {
handleConfig(plugin);
} else {
toast.error('此插件没有配置哦');
}
}}
hasConfig
/>
))}
</div>
)}
</div>
</>
);
}
@@ -0,0 +1,392 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Select, SelectItem } from '@heroui/select';
import { Switch } from '@heroui/switch';
import { useEffect, useState, useRef, useCallback } from 'react';
import toast from '@/utils/toast';
import { EventSourcePolyfill } from 'event-source-polyfill';
import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager';
import key from '@/const/key';
interface Props {
isOpen: boolean;
onOpenChange: () => void;
/** 插件包名 (id) */
pluginId: string;
}
/** Schema 更新事件类型 */
interface SchemaUpdateEvent {
type: 'full' | 'updateField' | 'removeField' | 'addField' | 'showField' | 'hideField';
schema?: PluginConfigSchemaItem[];
key?: string;
field?: Partial<PluginConfigSchemaItem>;
afterKey?: string;
}
export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: Props) {
const [loading, setLoading] = useState(false);
const [schema, setSchema] = useState<PluginConfigSchemaItem[]>([]);
const [config, setConfig] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const [supportReactive, setSupportReactive] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
// SSE 连接引用
const eventSourceRef = useRef<EventSourcePolyfill | null>(null);
// 当前配置引用(用于 SSE 回调)
const configRef = useRef<Record<string, unknown>>({});
// 同步 config 到 ref
useEffect(() => {
configRef.current = config;
}, [config]);
/** 处理 schema 更新事件 */
const handleSchemaUpdate = useCallback((event: SchemaUpdateEvent) => {
switch (event.type) {
case 'full':
if (event.schema) {
setSchema(event.schema);
}
break;
case 'updateField':
if (event.key && event.field) {
setSchema(prev => prev.map(item =>
item.key === event.key ? { ...item, ...event.field } : item
));
}
break;
case 'removeField':
if (event.key) {
setSchema(prev => prev.filter(item => item.key !== event.key));
}
break;
case 'addField':
if (event.field) {
setSchema(prev => {
const newField = event.field as PluginConfigSchemaItem;
// 检查字段是否已存在,如果存在则更新
const existingIndex = prev.findIndex(item => item.key === newField.key);
if (existingIndex !== -1) {
// 字段已存在,更新它
const newSchema = [...prev];
newSchema[existingIndex] = { ...newSchema[existingIndex], ...newField };
return newSchema;
}
// 字段不存在,添加新字段
if (event.afterKey) {
const index = prev.findIndex(item => item.key === event.afterKey);
if (index !== -1) {
const newSchema = [...prev];
newSchema.splice(index + 1, 0, newField);
return newSchema;
}
}
return [...prev, newField];
});
}
break;
case 'showField':
if (event.key) {
setSchema(prev => prev.map(item =>
item.key === event.key ? { ...item, hidden: false } : item
));
}
break;
case 'hideField':
if (event.key) {
setSchema(prev => prev.map(item =>
item.key === event.key ? { ...item, hidden: true } : item
));
}
break;
}
}, []);
/** 建立 SSE 连接 */
const connectSSE = useCallback((initialConfig: Record<string, unknown>) => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
const token = localStorage.getItem(key.token);
if (!token) {
console.warn('未登录,无法建立 SSE 连接');
return;
}
const _token = JSON.parse(token);
const url = PluginManager.getConfigSSEUrl(pluginId, initialConfig);
const es = new EventSourcePolyfill(url, {
headers: {
Authorization: `Bearer ${_token}`,
Accept: 'text/event-stream',
},
withCredentials: true,
});
eventSourceRef.current = es;
es.addEventListener('connected', (e) => {
const data = JSON.parse((e as MessageEvent).data);
setSessionId(data.sessionId);
setConnected(true);
});
es.addEventListener('schema', (e) => {
const data = JSON.parse((e as MessageEvent).data);
handleSchemaUpdate(data);
});
es.addEventListener('error', (e) => {
try {
const data = JSON.parse((e as MessageEvent).data);
toast.error('插件错误: ' + data.message);
} catch {
// SSE 连接错误
setConnected(false);
}
});
es.onerror = () => {
setConnected(false);
};
}, [pluginId, handleSchemaUpdate]);
/** 关闭 SSE 连接 */
const disconnectSSE = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setSessionId(null);
setConnected(false);
}, []);
useEffect(() => {
if (isOpen && pluginId) {
loadConfig();
}
return () => {
disconnectSSE();
};
}, [isOpen, pluginId, disconnectSSE]);
/** 初始加载配置 */
const loadConfig = async () => {
setLoading(true);
setSchema([]);
setConfig({});
setSupportReactive(false);
disconnectSSE();
try {
const data = await PluginManager.getPluginConfig(pluginId);
setSchema(data.schema || []);
setConfig(data.config || {});
setSupportReactive(!!data.supportReactive);
// 如果支持响应式,建立 SSE 连接
if (data.supportReactive) {
connectSSE(data.config || {});
}
} catch (e: any) {
toast.error('加载配置失败: ' + e.message);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
try {
await PluginManager.setPluginConfig(pluginId, config);
toast.success('Configuration saved');
onOpenChange();
} catch (e: any) {
toast.error('Save failed: ' + e.message);
} finally {
setSaving(false);
}
};
/** 更新配置 */
const updateConfig = useCallback((key: string, value: any) => {
setConfig((prev) => {
const newConfig = { ...prev, [key]: value };
// 如果是响应式字段且已连接 SSE,通知后端
const field = schema.find(item => item.key === key);
if (field?.reactive && sessionId && connected) {
PluginManager.notifyConfigChange(pluginId, sessionId, key, value, newConfig)
.catch(e => console.error('通知配置变化失败:', e));
}
return newConfig;
});
}, [schema, sessionId, connected, pluginId]);
const renderField = (item: PluginConfigSchemaItem) => {
const value = config[item.key] ?? item.default;
switch (item.type) {
case 'string':
return (
<Input
key={item.key}
label={item.label}
placeholder={item.placeholder || item.description}
value={value || ''}
onValueChange={(val) => updateConfig(item.key, val)}
description={item.description}
className='mb-4'
/>
);
case 'number':
return (
<Input
key={item.key}
type='number'
label={item.label}
placeholder={item.placeholder || item.description}
value={String(value ?? 0)}
onValueChange={(val) => updateConfig(item.key, Number(val))}
description={item.description}
className='mb-4'
/>
);
case 'boolean':
return (
<div key={item.key} className='flex justify-between items-center mb-4 p-2 bg-default-100 rounded-lg'>
<div className='flex flex-col'>
<span className='text-small'>{item.label}</span>
{item.description && <span className='text-tiny text-default-500'>{item.description}</span>}
</div>
<Switch
isSelected={!!value}
onValueChange={(val) => updateConfig(item.key, val)}
/>
</div>
);
case 'select': {
const selectedValue = value !== undefined ? String(value) : undefined;
const options = item.options || [];
return (
<Select
key={item.key}
label={item.label}
placeholder={item.placeholder || 'Select an option'}
selectedKeys={selectedValue ? [selectedValue] : []}
onSelectionChange={(keys) => {
const val = Array.from(keys)[0];
const opt = options.find(o => String(o.value) === val);
updateConfig(item.key, opt ? opt.value : val);
}}
description={item.description}
className='mb-4'
>
{options.map((opt) => (
<SelectItem key={String(opt.value)} textValue={opt.label}>
{opt.label}
</SelectItem>
))}
</Select>
);
}
case 'multi-select': {
const selectedKeys = Array.isArray(value) ? value.map(String) : [];
const options = item.options || [];
return (
<Select
key={item.key}
label={item.label}
placeholder={item.placeholder || 'Select options'}
selectionMode='multiple'
selectedKeys={new Set(selectedKeys)}
onSelectionChange={(keys) => {
const selected = Array.from(keys).map(k => {
const opt = options.find(o => String(o.value) === k);
return opt ? opt.value : k;
});
updateConfig(item.key, selected);
}}
description={item.description}
className='mb-4'
>
{options.map((opt) => (
<SelectItem key={String(opt.value)} textValue={opt.label}>
{opt.label}
</SelectItem>
))}
</Select>
);
}
case 'html':
return (
<div key={item.key} className='mb-4'>
{item.label && <h4 className='text-small font-bold mb-1'>{item.label}</h4>}
<div dangerouslySetInnerHTML={{ __html: item.default || '' }} className='prose dark:prose-invert max-w-none' />
{item.description && <p className='text-tiny text-default-500 mt-1'>{item.description}</p>}
</div>
);
case 'text':
return (
<div key={item.key} className='mb-4'>
{item.label && <h4 className='text-small font-bold mb-1'>{item.label}</h4>}
<div className='whitespace-pre-wrap text-default-700'>{item.default || ''}</div>
{item.description && <p className='text-tiny text-default-500 mt-1'>{item.description}</p>}
</div>
);
default:
return null;
}
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size='2xl' scrollBehavior='inside'>
<ModalContent className='rounded-3xl border border-white/70 dark:border-white/10 bg-white/92 dark:bg-zinc-900/92 backdrop-blur-2xl shadow-2xl shadow-primary-900/15'>
{(onClose) => (
<>
<ModalHeader className='flex flex-col gap-1'>
<div className='flex items-center gap-2'>
插件配置: {pluginId}
{supportReactive && (
<span className={`text-tiny px-2 py-0.5 rounded ${connected ? 'bg-success-100 text-success-600' : 'bg-warning-100 text-warning-600'}`}>
{connected ? '已连接' : '未连接'}
</span>
)}
</div>
</ModalHeader>
<ModalBody>
{loading
? (
<div className='flex justify-center p-8'>Loading configuration...</div>
)
: (
<div className='flex flex-col gap-2'>
{schema.length === 0
? (
<div className='text-center text-default-500'>No configuration schema available.</div>
)
: (
schema.filter(item => !item.hidden).map(renderField)
)}
</div>
)}
</ModalBody>
<ModalFooter>
<Button color='danger' variant='light' onPress={onClose}>
关闭
</Button>
<Button color='primary' onPress={handleSave} isLoading={saving}>
保存
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,390 @@
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal';
import { Button } from '@heroui/button';
import { Chip } from '@heroui/chip';
import { Avatar } from '@heroui/avatar';
import { Tooltip } from '@heroui/tooltip';
import { Spinner } from '@heroui/spinner';
import { IoMdCheckmarkCircle, IoMdOpen, IoMdDownload } from 'react-icons/io';
import { MdUpdate } from 'react-icons/md';
import { useState, useEffect } from 'react';
import { PluginStoreItem } from '@/types/plugin-store';
import { InstallStatus } from '@/components/display_card/plugin_store_card';
import TailwindMarkdown from '@/components/tailwind_markdown';
interface PluginDetailModalProps {
isOpen: boolean;
onClose: () => void;
plugin: PluginStoreItem | null;
installStatus?: InstallStatus;
installedVersion?: string;
onInstall?: () => void;
}
/** 提取作者头像 URL */
function getAuthorAvatar (homepage?: string, downloadUrl?: string): string | undefined {
// 1. 尝试从 downloadUrl 提取 GitHub 用户名
if (downloadUrl) {
try {
const url = new URL(downloadUrl);
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length >= 1) {
return `https://github.com/${parts[0]}.png`;
}
}
} catch {
// 忽略解析错误
}
}
// 2. 尝试从 homepage 提取
if (homepage) {
try {
const url = new URL(homepage);
if (url.hostname === 'github.com' || url.hostname === 'www.github.com') {
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length >= 1) {
return `https://github.com/${parts[0]}.png`;
}
} else {
return `https://api.iowen.cn/favicon/${url.hostname}.png`;
}
} catch {
// 忽略解析错误
}
}
return undefined;
}
/** 提取 GitHub 仓库信息 */
function extractGitHubRepo (url?: string): { owner: string; repo: string; } | null {
if (!url) return null;
try {
const urlObj = new URL(url);
if (urlObj.hostname === 'github.com' || urlObj.hostname === 'www.github.com') {
const parts = urlObj.pathname.split('/').filter(Boolean);
if (parts.length >= 2) {
return { owner: parts[0], repo: parts[1] };
}
}
} catch {
// 忽略解析错误
}
return null;
}
/** 从 GitHub API 获取 README */
async function fetchGitHubReadme (owner: string, repo: string): Promise<string> {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/readme`, {
headers: {
Accept: 'application/vnd.github.v3.raw',
},
});
if (!response.ok) {
throw new Error('Failed to fetch README');
}
return response.text();
}
/** 清理 README 中的 HTML 标签,保留 Markdown */
function cleanReadmeHtml (content: string): string {
// 移除 HTML 注释
let cleaned = content.replace(/<!--[\s\S]*?-->/g, '');
// 保留常见的 Markdown 友好的 HTML 标签(img, br),其他的移除标签但保留内容
// 移除 style 和 script 标签及其内容
cleaned = cleaned.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
cleaned = cleaned.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
// 将其他 HTML 标签替换为空格或换行(保留内容)
// 保留 img 标签(转为 markdown)- 尝试提取 alt 和 src 属性
cleaned = cleaned.replace(/<img[^>]*\\bsrc=["']([^"']+)["'][^>]*\\balt=["']([^"']+)["'][^>]*>/gi, '![$2]($1)');
cleaned = cleaned.replace(/<img[^>]*\\balt=["']([^"']+)["'][^>]*\\bsrc=["']([^"']+)["'][^>]*>/gi, '![$1]($2)');
cleaned = cleaned.replace(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi, '![]($1)');
// 移除其他 HTML 标签,但保留内容
cleaned = cleaned.replace(/<\/?[^>]+(>|$)/g, '');
// 清理多余的空行(超过2个连续换行)
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
return cleaned.trim();
}
export default function PluginDetailModal ({
isOpen,
onClose,
plugin,
installStatus = 'not-installed',
installedVersion,
onInstall,
}: PluginDetailModalProps) {
const [readme, setReadme] = useState<string>('');
const [readmeLoading, setReadmeLoading] = useState(false);
const [readmeError, setReadmeError] = useState(false);
// 获取 GitHub 仓库信息(需要在 hooks 之前计算)
const githubRepo = plugin ? extractGitHubRepo(plugin.homepage) : null;
// 当模态框打开且有 GitHub 链接时,获取 README
useEffect(() => {
if (!isOpen || !githubRepo) {
setReadme('');
setReadmeError(false);
return;
}
const loadReadme = async () => {
setReadmeLoading(true);
setReadmeError(false);
try {
const content = await fetchGitHubReadme(githubRepo.owner, githubRepo.repo);
// 清理 HTML 标签后再设置
setReadme(cleanReadmeHtml(content));
} catch (error) {
console.error('Failed to fetch README:', error);
setReadmeError(true);
} finally {
setReadmeLoading(false);
}
};
loadReadme();
}, [isOpen, githubRepo?.owner, githubRepo?.repo]);
if (!plugin) return null;
const { name, version, author, description, tags, homepage, downloadUrl, minVersion } = plugin;
const avatarUrl = getAuthorAvatar(homepage, downloadUrl) || `https://avatar.vercel.sh/${encodeURIComponent(name)}`;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size='4xl'
scrollBehavior='inside'
classNames={{
backdrop: 'z-[200]',
wrapper: 'z-[200]',
}}
>
<ModalContent className='rounded-3xl border border-white/70 dark:border-white/10 bg-white/92 dark:bg-zinc-900/92 backdrop-blur-2xl shadow-2xl shadow-primary-900/15'>
{(onModalClose) => (
<>
<ModalHeader className='flex flex-col gap-3 pb-2'>
{/* 插件头部信息 */}
<div className='flex items-start gap-4'>
<Avatar
src={avatarUrl}
name={author || '?'}
size='lg'
isBordered
color='primary'
radius='lg'
className='flex-shrink-0'
/>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2'>
<h2 className='text-2xl font-bold text-default-900'>{name}</h2>
{homepage && (
<Tooltip content='访问项目主页'>
<Button
isIconOnly
size='sm'
variant='flat'
color='primary'
as='a'
href={homepage}
target='_blank'
rel='noreferrer'
>
<IoMdOpen size={18} />
</Button>
</Tooltip>
)}
</div>
<p className='text-sm text-default-500 mt-1'>
by <span className='font-medium'>{author || '未知作者'}</span>
</p>
{/* 标签和版本信息 */}
<div className='flex items-center gap-2 mt-2 flex-wrap'>
<Chip size='sm' color='primary' variant='flat'>
v{version}
</Chip>
{tags?.map((tag) => (
<Chip
key={tag}
size='sm'
variant='flat'
className='bg-default-100 text-default-600'
>
{tag}
</Chip>
))}
{installStatus === 'update-available' && installedVersion && (
<Chip
size='sm'
color='warning'
variant='shadow'
className='animate-pulse'
>
可更新
</Chip>
)}
{installStatus === 'installed' && (
<Chip
size='sm'
color='success'
variant='flat'
startContent={<IoMdCheckmarkCircle size={14} />}
>
已安装
</Chip>
)}
</div>
</div>
</div>
</ModalHeader>
<ModalBody className='gap-4'>
{/* 插件描述 */}
<div>
<h3 className='text-sm font-semibold text-default-700 mb-2'>插件描述</h3>
<p className='text-sm text-default-600 leading-relaxed'>
{description || '暂无描述'}
</p>
</div>
{/* 插件信息 */}
<div>
<h3 className='text-sm font-semibold text-default-700 mb-3'>插件信息</h3>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm'>
<div className='flex justify-between items-center'>
<span className='text-default-500'>最新版本:</span>
<span className='font-medium text-default-900'>v{version}</span>
</div>
{installedVersion && (
<div className='flex justify-between items-center'>
<span className='text-default-500'>已安装版本:</span>
<span className='font-medium text-default-900'>v{installedVersion}</span>
</div>
)}
{minVersion && (
<div className='flex justify-between items-center'>
<span className='text-default-500'>最低要求版本:</span>
<span className='font-medium text-default-900'>v{minVersion}</span>
</div>
)}
<div className='flex justify-between items-center'>
<span className='text-default-500'>插件 ID:</span>
<span className='font-mono text-xs text-default-900'>{plugin.id}</span>
</div>
{downloadUrl && (
<div className='flex justify-between items-center'>
<span className='text-default-500'>下载地址:</span>
<Button
size='sm'
variant='flat'
color='primary'
as='a'
href={downloadUrl}
target='_blank'
rel='noreferrer'
startContent={<IoMdDownload size={14} />}
>
下载插件
</Button>
</div>
)}
</div>
</div>
{/* GitHub README 显示 */}
{githubRepo && (
<>
<div className='mt-2'>
<h3 className='text-sm font-semibold text-default-700 mb-3'>详情</h3>
{readmeLoading && (
<div className='flex justify-center items-center py-12'>
<Spinner size='lg' />
</div>
)}
{readmeError && (
<div className='text-center py-8'>
<p className='text-sm text-default-500 mb-3'>
无法加载 README
</p>
<Button
color='primary'
variant='flat'
as='a'
href={homepage}
target='_blank'
rel='noreferrer'
startContent={<IoMdOpen />}
>
在 GitHub 查看
</Button>
</div>
)}
{!readmeLoading && !readmeError && readme && (
<div className='rounded-lg border border-default-200 p-4 bg-default-50'>
<TailwindMarkdown content={readme} />
</div>
)}
</div>
</>
)}
</ModalBody>
<ModalFooter>
<Button variant='light' onPress={onModalClose}>
关闭
</Button>
{installStatus === 'installed'
? (
<Button
color='success'
variant='flat'
startContent={<IoMdCheckmarkCircle size={18} />}
isDisabled
>
已安装
</Button>
)
: installStatus === 'update-available'
? (
<Button
color='warning'
variant='shadow'
startContent={<MdUpdate size={18} />}
onPress={() => {
onInstall?.();
onModalClose();
}}
>
更新到 v{version}
</Button>
)
: (
<Button
color='primary'
variant='shadow'
startContent={<IoMdDownload size={18} />}
onPress={() => {
onInstall?.();
onModalClose();
}}
>
立即安装
</Button>
)}
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,600 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Tab, Tabs } from '@heroui/tabs';
import { Tooltip } from '@heroui/tooltip';
import { Spinner } from '@heroui/spinner';
import { Pagination } from '@heroui/pagination';
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch, IoMdSettings } from 'react-icons/io';
import { LuStore } from 'react-icons/lu';
import clsx from 'clsx';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { useLocalStorage } from '@uidotdev/usehooks';
import { useSearchParams } from 'react-router-dom';
import PluginStoreCard, { InstallStatus } from '@/components/display_card/plugin_store_card';
import EmptyState from '@/components/empty_state';
import PluginManager, { PluginItem } from '@/controllers/plugin_manager';
import MirrorSelectorModal from '@/components/mirror_selector_modal';
import PluginDetailModal from '@/pages/dashboard/plugin_detail_modal';
import { PluginStoreItem } from '@/types/plugin-store';
import useDialog from '@/hooks/use-dialog';
import key from '@/const/key';
/** Fisher-Yates 洗牌算法,返回新数组 */
function shuffleArray<T> (arr: T[]): T[] {
const shuffled = [...arr];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
interface EmptySectionProps {
isEmpty: boolean;
}
const EmptySection: React.FC<EmptySectionProps> = ({ isEmpty }) => {
if (!isEmpty) return null;
return (
<EmptyState
compact
icon={<LuStore size={20} />}
title='暂时没有可用的插件'
description='换个分类看看,或者检查右上角的商店列表源是否可访问。'
/>
);
};
export default function PluginStorePage () {
const [plugins, setPlugins] = useState<PluginStoreItem[]>([]);
const [installedPlugins, setInstalledPlugins] = useState<PluginItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<string>('all');
const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false);
const dialog = useDialog();
const searchInputRef = useRef<HTMLInputElement>(null);
const [searchParams, setSearchParams] = useSearchParams();
// 快捷键支持: Ctrl+F 聚焦搜索框
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
searchInputRef.current?.focus();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// 进度条相关状态
const [installProgress, setInstallProgress] = useState<{
show: boolean;
message: string;
progress: number;
speedStr?: string;
eta?: number;
downloadedStr?: string;
totalStr?: string;
}>({
show: false,
message: '',
progress: 0,
});
// 商店列表源相关状态
const [storeSourceModalOpen, setStoreSourceModalOpen] = useState(false);
const [currentStoreSource, setCurrentStoreSource] = useState<string | undefined>(undefined);
// 下载镜像弹窗状态(安装时使用)
const [downloadMirrorModalOpen, setDownloadMirrorModalOpen] = useState(false);
const [pendingInstallPlugin, setPendingInstallPlugin] = useState<PluginStoreItem | null>(null);
const [selectedDownloadMirror, setSelectedDownloadMirror] = useState<string | undefined>(undefined);
// 插件详情弹窗状态
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<PluginStoreItem | null>(null);
// 分页状态
const ITEMS_PER_PAGE = 20;
const [currentPage, setCurrentPage] = useState(1);
const loadPlugins = async (forceRefresh: boolean = false) => {
setLoading(true);
try {
const data = await PluginManager.getPluginStoreList(forceRefresh);
setPlugins(data.plugins);
// 检查插件管理器是否已加载
const listResult = await PluginManager.getPluginList();
setPluginManagerNotFound(listResult.pluginManagerNotFound);
setInstalledPlugins(listResult.plugins || []);
} catch (e: any) {
toast.error(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPlugins();
}, [currentStoreSource]);
// 处理 URL 参数中的插件 ID,自动打开详情
useEffect(() => {
const pluginId = searchParams.get('pluginId');
if (pluginId && plugins.length > 0 && !detailModalOpen) {
// 查找对应的插件
const targetPlugin = plugins.find(p => p.id === pluginId);
if (targetPlugin) {
setSelectedPlugin(targetPlugin);
setDetailModalOpen(true);
// 移除 URL 参数(可选)
// searchParams.delete('pluginId');
// setSearchParams(searchParams);
}
}
}, [plugins, searchParams, detailModalOpen]);
// 按标签分类和搜索
const categorizedPlugins = useMemo(() => {
let filtered = plugins;
// 搜索过滤
if (searchQuery) {
filtered = filtered.filter(
(p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.author.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.tags?.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
}
// 定义主要分类
const categories: Record<string, PluginStoreItem[]> = {
all: filtered,
official: filtered.filter(p => p.tags?.includes('官方')),
tools: filtered.filter(p => p.tags?.includes('工具')),
entertainment: filtered.filter(p => p.tags?.includes('娱乐')),
other: filtered.filter(p => !p.tags?.some(t => ['官方', '工具', '娱乐'].includes(t))),
random: shuffleArray(filtered),
};
return categories;
}, [plugins, searchQuery]);
// 获取插件的安装状态和已安装版本
const getPluginInstallInfo = (plugin: PluginStoreItem): { status: InstallStatus; installedVersion?: string; } => {
// 通过 id (包名) 或 name 匹配已安装的插件
// 优先匹配 ID,如果 ID 匹配失败尝试匹配名称(兼容某些 ID 不一致的情况)
const installed = installedPlugins.find(p => p.id === plugin.id || p.name === plugin.name);
if (!installed) {
return { status: 'not-installed' };
}
// 使用不等于判断:版本不同就显示更新
if (installed.version !== plugin.version) {
return { status: 'update-available', installedVersion: installed.version };
}
return { status: 'installed', installedVersion: installed.version };
};
const tabs = useMemo(() => {
return [
{ key: 'all', title: '全部', count: categorizedPlugins.all?.length || 0 },
{ key: 'official', title: '官方', count: categorizedPlugins.official?.length || 0 },
{ key: 'tools', title: '工具', count: categorizedPlugins.tools?.length || 0 },
{ key: 'entertainment', title: '娱乐', count: categorizedPlugins.entertainment?.length || 0 },
{ key: 'other', title: '其它', count: categorizedPlugins.other?.length || 0 },
{ key: 'random', title: '随机', count: categorizedPlugins.random?.length || 0 },
];
}, [categorizedPlugins]);
// 当前分类的总数和分页数据
const currentCategoryPlugins = useMemo(() => categorizedPlugins[activeTab] || [], [categorizedPlugins, activeTab]);
const totalPages = useMemo(() => Math.max(1, Math.ceil(currentCategoryPlugins.length / ITEMS_PER_PAGE)), [currentCategoryPlugins.length]);
const paginatedPlugins = useMemo(() => {
const start = (currentPage - 1) * ITEMS_PER_PAGE;
return currentCategoryPlugins.slice(start, start + ITEMS_PER_PAGE);
}, [currentCategoryPlugins, currentPage]);
// 切换分类或搜索时重置页码
const handleTabChange = useCallback((key: string) => {
setActiveTab(key);
setCurrentPage(1);
}, []);
// 搜索变化时重置页码
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
setCurrentPage(1);
}, []);
const handleInstall = async (plugin: PluginStoreItem) => {
// 弹窗选择下载镜像
setPendingInstallPlugin(plugin);
setDownloadMirrorModalOpen(true);
};
const installPluginWithSSE = async (pluginId: string, mirror?: string) => {
const loadingToast = toast.loading('正在准备安装...');
try {
// 获取认证 token
const token = localStorage.getItem(key.token);
if (!token) {
toast.error('未登录,请先登录', { id: loadingToast });
return;
}
const _token = JSON.parse(token);
const params = new URLSearchParams({ id: pluginId });
if (mirror) {
params.append('mirror', mirror);
}
const eventSource = new EventSourcePolyfill(
`/api/Plugin/Store/Install/SSE?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${_token}`,
Accept: 'text/event-stream',
},
withCredentials: true,
}
);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.error) {
toast.error(`安装失败: ${data.error}`, { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
} else if (data.success) {
toast.success('插件安装成功!', { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
// 刷新插件列表
loadPlugins();
// 安装成功后检查插件管理器状态
if (pluginManagerNotFound) {
dialog.confirm({
title: '插件管理器未加载',
content: (
<div className='space-y-2'>
<p className='text-sm text-default-600'>
插件已安装成功,但插件管理器尚未加载。
</p>
<p className='text-sm text-default-600'>
是否立即注册插件管理器?注册后插件才能正常运行。
</p>
</div>
),
confirmText: '注册插件管理器',
cancelText: '稍后再说',
onConfirm: () => {
(async () => {
try {
await PluginManager.registerPluginManager();
toast.success('插件管理器注册成功');
setPluginManagerNotFound(false);
} catch (e: any) {
toast.error('注册失败: ' + e.message);
}
})();
},
});
}
} else if (data.message) {
if (typeof data.progress === 'number' && data.progress >= 0 && data.progress <= 100) {
setInstallProgress((prev) => ({
...prev,
show: true,
message: data.message,
progress: data.progress,
// 保存下载详情,避免被后续非下载步骤的消息清空
speedStr: data.speedStr || (data.message.includes('下载') ? prev.speedStr : undefined),
eta: data.eta !== undefined ? data.eta : (data.message.includes('下载') ? prev.eta : undefined),
downloadedStr: data.downloadedStr || (data.message.includes('下载') ? prev.downloadedStr : undefined),
totalStr: data.totalStr || (data.message.includes('下载') ? prev.totalStr : undefined),
}));
} else {
toast.loading(data.message, { id: loadingToast });
}
}
} catch (e) {
console.error('Failed to parse SSE message:', e);
}
};
eventSource.onerror = (error) => {
console.error('SSE连接出错:', error);
toast.error('连接中断,安装失败', { id: loadingToast });
setInstallProgress(prev => ({ ...prev, show: false }));
eventSource.close();
};
} catch (error: any) {
toast.error(`安装失败: ${error.message || '未知错误'}`, { id: loadingToast });
}
};
const getStoreSourceDisplayName = () => {
if (!currentStoreSource) return '默认源';
try {
return new URL(currentStoreSource).hostname;
} catch {
return currentStoreSource;
}
};
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
return (
<>
<title>插件商店 - SnowCat WebUI</title>
<div className='sc-page'>
{/* 固定头部区域 */}
<div className={clsx(
'sticky top-14 z-10 backdrop-blur-xl py-3 px-3 md:px-6 rounded-2xl mb-4 -mx-3 md:-mx-6 -mt-4 md:-mt-6 transition-colors',
hasBackground
? 'bg-black/25'
: 'bg-transparent'
)}
>
{/* 头部布局:标题 + 搜索 + 工具栏 */}
<div className='flex flex-col md:flex-row mb-3 items-start md:items-center justify-between gap-3'>
<div className='flex items-center gap-3 flex-shrink-0'>
<div className='w-10 h-10 sc-accent-tile'>
<LuStore size={20} />
</div>
<div className='min-w-0'>
<h1 className='sc-title truncate'>插件商店</h1>
<p className='sc-subtitle truncate'>社区插件一键发现与安装</p>
</div>
<Tooltip content='刷新列表'>
<Button
isIconOnly
size='sm'
variant='flat'
className='bg-white/60 dark:bg-white/10 text-default-600 dark:text-default-300 backdrop-blur-md hover:bg-white/80 dark:hover:bg-white/20'
radius='full'
onPress={() => loadPlugins(true)}
isLoading={loading}
>
<IoMdRefresh size={18} />
</Button>
</Tooltip>
</div>
{/* 顶栏搜索框与列表源 */}
<div className='flex items-center gap-3 w-full md:w-auto flex-1 justify-end'>
<Input
ref={searchInputRef}
placeholder='搜索(Ctrl+F)...'
startContent={<IoMdSearch className='text-default-400' />}
value={searchQuery}
onValueChange={handleSearchChange}
className='max-w-xs w-full'
size='sm'
isClearable
classNames={{
inputWrapper: 'bg-default-100/50 dark:bg-black/20 backdrop-blur-md border-white/20 dark:border-white/10',
}}
/>
{/* 商店列表源简易卡片 */}
<div className='hidden sm:flex items-center gap-2 bg-default-100/50 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/20 dark:border-white/10'>
<span className='text-xs text-default-500 whitespace-nowrap'>源: {getStoreSourceDisplayName()}</span>
<Tooltip content='切换列表源'>
<Button
isIconOnly
size='sm'
variant='light'
className='min-w-unit-6 w-6 h-6'
onPress={() => setStoreSourceModalOpen(true)}
>
<IoMdSettings size={14} />
</Button>
</Tooltip>
</div>
</div>
</div>
{/* 标签页导航 */}
<Tabs
aria-label='Plugin Store Categories'
className='max-w-full'
selectedKey={activeTab}
onSelectionChange={(key) => handleTabChange(String(key))}
classNames={{
tabList: 'sc-tablist',
cursor: 'sc-tab-cursor',
panel: 'hidden',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={`${tab.title} (${tab.count})`}
/>
))}
</Tabs>
</div>
{/* 插件列表区域 */}
<div className='relative'>
{/* 加载遮罩 - 只遮住插件列表区域 */}
{loading && (
<div className='absolute inset-0 bg-zinc-500/10 z-30 flex justify-center items-center backdrop-blur-sm rounded-lg'>
<Spinner size='lg' />
</div>
)}
<EmptySection isEmpty={!currentCategoryPlugins.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-4'>
{paginatedPlugins.map((plugin) => {
const installInfo = getPluginInstallInfo(plugin);
return (
<PluginStoreCard
key={plugin.id}
data={plugin}
installStatus={installInfo.status}
installedVersion={installInfo.installedVersion}
onInstall={() => { handleInstall(plugin); }}
onViewDetail={() => {
setSelectedPlugin(plugin);
setDetailModalOpen(true);
}}
/>
);
})}
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className='flex justify-center mt-6 mb-2'>
<Pagination
total={totalPages}
page={currentPage}
onChange={setCurrentPage}
showControls
showShadow
color='primary'
size='lg'
classNames={{
wrapper: 'backdrop-blur-md bg-white/50 dark:bg-white/[0.05] border border-white/60 dark:border-white/10',
}}
/>
</div>
)}
</div>
</div>
{/* 商店列表源选择弹窗 */}
<MirrorSelectorModal
isOpen={storeSourceModalOpen}
onClose={() => setStoreSourceModalOpen(false)}
onSelect={(mirror) => {
setCurrentStoreSource(mirror);
}}
currentMirror={currentStoreSource}
type='raw'
/>
{/* 下载镜像选择弹窗 */}
<MirrorSelectorModal
isOpen={downloadMirrorModalOpen}
onClose={() => {
setDownloadMirrorModalOpen(false);
setPendingInstallPlugin(null);
}}
onSelect={(mirror) => {
setSelectedDownloadMirror(mirror);
// 选择后立即开始安装
if (pendingInstallPlugin) {
setDownloadMirrorModalOpen(false);
installPluginWithSSE(pendingInstallPlugin.id, mirror);
setPendingInstallPlugin(null);
}
}}
currentMirror={selectedDownloadMirror}
type='file'
/>
{/* 插件详情弹窗 */}
<PluginDetailModal
isOpen={detailModalOpen}
onClose={() => {
setDetailModalOpen(false);
setSelectedPlugin(null);
// 清除 URL 参数
if (searchParams.has('pluginId')) {
searchParams.delete('pluginId');
setSearchParams(searchParams);
}
}}
plugin={selectedPlugin}
installStatus={selectedPlugin ? getPluginInstallInfo(selectedPlugin).status : 'not-installed'}
installedVersion={selectedPlugin ? getPluginInstallInfo(selectedPlugin).installedVersion : undefined}
onInstall={() => {
if (selectedPlugin) {
handleInstall(selectedPlugin);
}
}}
/>
{/* 插件下载进度条全局居中样式 */}
{installProgress.show && (
<div className='fixed inset-0 flex items-center justify-center z-[9999] animate-in fade-in duration-300'>
{/* 毛玻璃背景层 */}
<div className='absolute inset-0 bg-black/20 dark:bg-black/40 backdrop-blur-md' />
<div
className={clsx(
'relative w-[90%] max-w-md bg-white/90 dark:bg-zinc-900/90 backdrop-blur-2xl shadow-2xl shadow-primary-900/20 rounded-3xl border border-white/70 dark:border-white/10 p-7',
'flex flex-col gap-5 animate-in zoom-in-95 duration-300'
)}
>
<div className='flex items-start gap-3'>
<div className='w-10 h-10 sc-accent-tile flex-shrink-0'>
<LuStore size={18} />
</div>
<div className='flex flex-col gap-0.5 min-w-0'>
<h3 className='text-base font-bold text-default-900 dark:text-white'>正在安装插件</h3>
<p className='text-xs text-default-500 truncate'>{installProgress.message}</p>
</div>
</div>
<div className='flex flex-col gap-4'>
{/* 速度 & 百分比 */}
<div className='flex items-center justify-between'>
<div className='flex flex-col gap-0.5'>
{installProgress.speedStr && (
<p className='text-xs text-primary font-bold'>
{installProgress.speedStr}
</p>
)}
{installProgress.eta !== undefined && installProgress.eta !== null && (
<p className='text-xs text-default-400'>
剩余时间: {
installProgress.eta > 0
? (installProgress.eta < 60 ? `${installProgress.eta}s` : `${Math.floor(installProgress.eta / 60)}m ${installProgress.eta % 60}s`)
: '计算中...'
}
</p>
)}
</div>
<span className='text-2xl font-black text-primary font-mono'>{Math.round(installProgress.progress)}%</span>
</div>
{/* 进度条 */}
<div className='w-full bg-default-200/50 dark:bg-white/[0.08] rounded-full h-3 overflow-hidden'>
<div
className='h-full rounded-full transition-all duration-500 ease-out bg-gradient-to-r from-primary-400 via-primary-500 to-primary-600 shadow-[0_0_16px_rgba(255,127,172,0.55)]'
style={{ width: `${Math.max(installProgress.progress, 2)}%` }}
/>
</div>
{/* 详细数据 (大小) - 始终显示 */}
<div className='flex items-center justify-between text-xs text-default-400 font-bold tracking-tight'>
<span>已下载 {installProgress.downloadedStr || '0.0MB'}</span>
<span>总计 {installProgress.totalStr || '获取中...'}</span>
</div>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,187 @@
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 { useLocalStorage } from '@uidotdev/usehooks';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import toast from 'react-hot-toast';
import { IoAdd, IoClose } from 'react-icons/io5';
import key from '@/const/key';
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>('');
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
const hasBackground = !!backgroundImage;
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-3 px-3 md:px-6 py-4 md:py-6 h-[calc(100vh-3.5rem)]'>
<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'>
{tabs.length > 0 && (
<TabList className={clsx(
'flex-1 !overflow-x-auto w-full hide-scrollbar backdrop-blur-xl p-1 rounded-2xl border shadow-sm',
hasBackground
? 'bg-black/25 border-white/20'
: 'bg-white/60 dark:bg-white/[0.05] border-white/60 dark:border-white/10'
)}
>
<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 ml-auto rounded-xl shadow-sm shadow-primary-500/20'
/>
</div>
<div className='flex-grow overflow-hidden'>
{tabs.length === 0 && (
<div className='flex flex-col gap-3 items-center justify-center h-full text-default-400'>
<div className='w-14 h-14 rounded-2xl bg-white/60 dark:bg-white/[0.06] border border-white/60 dark:border-white/10 flex items-center justify-center'>
<IoAdd className='text-2xl' />
</div>
<div className='text-sm'>还没有终端会话</div>
<div className='text-tiny text-default-400'>点击右上角 + 新建一个终端</div>
</div>
)}
{tabs.map((tab) => (
<TabPanel key={tab.id} value={tab.id} className='h-full rounded-2xl overflow-hidden border border-white/60 dark:border-white/10 shadow-sm'>
<TerminalInstance id={tab.id} />
</TabPanel>
))}
</div>
</Tabs>
</DndContext>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,474 @@
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 CryptoJS from 'crypto-js';
import { MdSettings } from 'react-icons/md';
import logo from '@/assets/images/logo.png';
import GUIDManager from '@/components/guid_manager';
import Modal from '@/components/modal';
import HoverEffectCard from '@/components/effect_card';
import PasswordLogin from '@/components/password_login';
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 type { CaptchaCallbackData } from '@/components/tencent_captcha';
import QQManager from '@/controllers/qq_manager';
import useDialog from '@/hooks/use-dialog';
import PureLayout from '@/layouts/pure';
import { motion } from 'motion/react';
const parseLoginError = (errorStr: string) => {
if (errorStr.startsWith('登录失败: ')) {
const jsonPart = errorStr.substring('登录失败: '.length);
try {
const parsed = JSON.parse(jsonPart);
if (Array.isArray(parsed) && parsed[1]) {
const info = parsed[1];
const codeStr = info.serverErrorCode ? ` (错误码: ${info.serverErrorCode})` : '';
return `${info.message || errorStr}${codeStr}`;
}
} catch (_e) {
// 忽略解析错误
}
}
return errorStr;
};
export default function QQLoginPage () {
const navigate = useNavigate();
const dialog = useDialog();
const [uinValue, setUinValue] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [qrcode, setQrcode] = useState<string>('');
const [loginPhase, setLoginPhase] = useState<string>('waiting_qrcode');
const [isRecoveringLoginService, setIsRecoveringLoginService] = useState(false);
const qrcodeRef = useRef<string>('');
const qrcodeRequestVersionRef = useRef(0);
const isRefreshingQRCodeRef = useRef(false);
const [loginError, setLoginError] = useState<string>('');
const lastErrorRef = useRef<string>('');
const [qqList, setQQList] = useState<(QQItem | LoginListItem)[]>([]);
const [refresh, setRefresh] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState<string>('shortcut');
const firstLoad = useRef<boolean>(true);
const [captchaState, setCaptchaState] = useState<{
needCaptcha: boolean;
proofWaterUrl: string;
uin: string;
password: string;
} | null>(null);
const [captchaVerifying, setCaptchaVerifying] = useState(false);
const [newDeviceState, setNewDeviceState] = useState<{
needNewDevice: boolean;
jumpUrl: string;
newDevicePullQrCodeSig: string;
uin: string;
password: string;
} | null>(null);
// newDevicePullQrCodeSig is kept for step:2 login after QR verification
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 onPasswordSubmit = async (uin: string, password: string) => {
setIsLoading(true);
try {
// 计算密码的MD5值
const passwordMd5 = CryptoJS.MD5(password).toString();
const result = await QQManager.passwordLogin(uin, passwordMd5);
if (result?.needCaptcha && result.proofWaterUrl) {
// 需要验证码,显示验证码组件
setCaptchaState({
needCaptcha: true,
proofWaterUrl: result.proofWaterUrl,
uin,
password,
});
toast('需要安全验证,请完成验证码', { icon: '🔒' });
} else if (result?.needNewDevice && result.jumpUrl) {
setNewDeviceState({
needNewDevice: true,
jumpUrl: result.jumpUrl,
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
uin,
password,
});
toast('检测到新设备,请扫码验证', { icon: '📱' });
} else {
toast.success('密码登录请求已发送');
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`密码登录失败: ${msg}`);
} finally {
setIsLoading(false);
}
};
const onCaptchaSubmit = async (uin: string, password: string, captchaData: CaptchaCallbackData) => {
setIsLoading(true);
setCaptchaVerifying(true);
try {
const passwordMd5 = CryptoJS.MD5(password).toString();
const result = await QQManager.captchaLogin(uin, passwordMd5, captchaData.ticket, captchaData.randstr, captchaData.sid);
if (result?.needNewDevice && result.jumpUrl) {
setCaptchaState(null);
setNewDeviceState({
needNewDevice: true,
jumpUrl: result.jumpUrl,
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
uin,
password,
});
toast('检测到异常设备,请扫码验证', { icon: '📱' });
} else {
toast.success('验证码登录请求已发送');
setCaptchaState(null);
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`验证码登录失败: ${msg}`);
setCaptchaState(null);
} finally {
setIsLoading(false);
setCaptchaVerifying(false);
}
};
const onCaptchaCancel = () => {
setCaptchaState(null);
};
const onNewDeviceVerified = async (token: string) => {
if (!newDeviceState) return;
setIsLoading(true);
try {
const passwordMd5 = CryptoJS.MD5(newDeviceState.password).toString();
// Use the str_nt_succ_token from QR verification as newDevicePullQrCodeSig for step:2
const sig = token || newDeviceState.newDevicePullQrCodeSig;
const result = await QQManager.newDeviceLogin(newDeviceState.uin, passwordMd5, sig);
if (result?.needNewDevice && result.jumpUrl) {
// 新设备验证后又触发了异常设备验证,更新 jumpUrl
setNewDeviceState({
needNewDevice: true,
jumpUrl: result.jumpUrl,
newDevicePullQrCodeSig: result.newDevicePullQrCodeSig || '',
uin: newDeviceState.uin,
password: newDeviceState.password,
});
toast('检测到异常设备,请继续扫码验证', { icon: '📱' });
} else {
toast.success('新设备验证登录请求已发送');
setNewDeviceState(null);
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`新设备验证登录失败: ${msg}`);
setNewDeviceState(null);
} finally {
setIsLoading(false);
}
};
const onNewDeviceCancel = () => {
setNewDeviceState(null);
};
const onUpdateQrCode = async () => {
const requestVersion = qrcodeRequestVersionRef.current;
if (firstLoad.current) setIsLoading(true);
try {
const data = await QQManager.checkQQLoginStatusWithQrcode();
setLoginPhase(data.loginPhase || (data.isLogin ? 'ready' : 'waiting_qrcode'));
if (data.loginPhase === 'reconnecting') {
setIsRecoveringLoginService(true);
}
if (firstLoad.current) {
setIsLoading(false);
firstLoad.current = false;
}
if (data.isLogin) {
toast.success('QQ登录成功');
navigate('/', { replace: true });
} else {
// 刷新二维码是一个需要等待结果的操作。忽略在它开始前发出的
// 轮询响应,避免旧 URL 在刷新完成后覆盖刚拿到的新二维码。
if (requestVersion === qrcodeRequestVersionRef.current && !isRefreshingQRCodeRef.current) {
qrcodeRef.current = data.qrcodeurl;
setQrcode(data.qrcodeurl);
if (data.qrcodeurl) {
setIsRecoveringLoginService(false);
}
}
if (data.loginError && data.loginError !== lastErrorRef.current) {
lastErrorRef.current = data.loginError;
setLoginError(data.loginError);
const friendlyMsg = parseLoginError(data.loginError);
// 被踢下线时页面会自动进入登录服务恢复流程。它不是可由用户
// 处理的登录表单错误,也不应弹出对话框遮住恢复状态。
const isRecovering = data.loginPhase === 'reconnecting' || isRecoveringLoginService;
const isQrCodeExpired = friendlyMsg.includes('二维码') && (friendlyMsg.includes('过期') || friendlyMsg.includes('失效'));
if (!isRecovering && (!isQrCodeExpired || activeTab === 'qrcode')) {
dialog.alert({
title: '登录失败',
content: friendlyMsg,
confirmText: '确定',
});
}
} else if (!data.loginError) {
lastErrorRef.current = '';
setLoginError('');
}
}
} catch (error) {
const msg = (error as Error).message;
// Worker 重启时,旧连接被关闭是预期现象。恢复状态持续轮询即可,
// 不要把 ERR_EMPTY_RESPONSE 作为用户可操作的错误反复弹出。
if (!isRecoveringLoginService) {
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);
};
const onRefreshQRCode = async () => {
if (isRecoveringLoginService || loginPhase === 'reconnecting') {
toast('QQ 登录服务正在恢复,请等待新的二维码…', { icon: '⏳' });
return;
}
if (isRefreshingQRCodeRef.current) {
return;
}
qrcodeRequestVersionRef.current += 1;
isRefreshingQRCodeRef.current = true;
setIsRefreshingQRCode(true);
try {
lastErrorRef.current = '';
setLoginError('');
const { qrcodeurl, restarting } = await QQManager.refreshQRCode();
if (restarting) {
setIsRecoveringLoginService(true);
setLoginPhase('reconnecting');
return;
}
if (!qrcodeurl || qrcodeurl === qrcodeRef.current) {
throw new Error('未获取到新的二维码');
}
qrcodeRef.current = qrcodeurl;
setQrcode(qrcodeurl);
toast.success('二维码已刷新');
} catch (error) {
const msg = (error as Error).message;
if (msg.includes('QQ_LOGIN_SERVICE_RESTARTING')) {
setIsRecoveringLoginService(true);
setLoginPhase('reconnecting');
toast('QQ 登录服务正在重启,正在生成新的二维码…', { icon: '⏳' });
} else if (msg.includes('二维码刷新超时')) {
toast.error('未能取得新的二维码,请稍后重试');
} else {
toast.error(`刷新二维码失败: ${msg}`);
}
} finally {
qrcodeRequestVersionRef.current += 1;
isRefreshingQRCodeRef.current = false;
setIsRefreshingQRCode(false);
}
};
const [showGUIDManager, setShowGUIDManager] = useState(false);
const [isRefreshingQRCode, setIsRefreshingQRCode] = useState(false);
useEffect(() => {
const isLoginTransitioning = loginPhase === 'qrcode_scanned' || loginPhase === 'initializing' || loginPhase === 'reconnecting' || isRecoveringLoginService;
const pollInterval = isRefreshingQRCode || isLoginTransitioning ? 500 : 3000;
const timer = setInterval(() => {
onUpdateQrCode();
}, pollInterval);
onUpdateQrCode();
onUpdateQQList();
return () => clearInterval(timer);
}, [isRefreshingQRCode, loginPhase, isRecoveringLoginService]);
useEffect(() => {
if (!isRecoveringLoginService) return;
const timer = setTimeout(() => {
setIsRecoveringLoginService(false);
setLoginPhase('offline');
setLoginError('QQ 登录服务恢复超时,请稍后重试');
}, 180_000);
return () => clearTimeout(timer);
}, [isRecoveringLoginService]);
return (
<>
<title>QQ登录 - SnowCat WebUI</title>
<PureLayout>
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.5, type: 'spring', stiffness: 120, damping: 20 }}
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 rounded-3xl border border-white/70 dark:border-white/10 bg-white/70 dark:bg-white/[0.05] shadow-2xl shadow-primary-900/10'
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-3 pt-9'>
<Image alt='logo' height='5.5em' src={logo} className='drop-shadow-md' />
<div className='text-left'>
<div className='text-2xl font-bold tracking-tight bg-gradient-to-r from-primary-500 to-secondary-400 bg-clip-text text-transparent'>
SnowCat
</div>
<div className='text-tiny tracking-[0.28em] text-default-400 uppercase'>
雪猫登录
</div>
</div>
</div>
<div className='absolute right-4 top-4 flex items-center gap-2'>
<Button isIconOnly variant='light' aria-label='Settings' onPress={() => setShowGUIDManager(true)}>
<MdSettings size={22} />
</Button>
<ThemeSwitch />
</div>
</CardHeader>
<CardBody className='flex gap-5 p-6 md:p-10 pt-0'>
<Tabs
fullWidth
classNames={{
tabList: 'sc-tablist',
cursor: 'sc-tab-cursor',
}}
isDisabled={isLoading}
size='lg'
selectedKey={activeTab}
onSelectionChange={(key) => key !== null && setActiveTab(key.toString())}
>
<Tab key='shortcut' title='快速登录'>
<QuickLogin
handleSelectionChange={handleSelectionChange}
isLoading={isLoading}
qqList={qqList}
refresh={refresh}
selectedQQ={uinValue}
onSubmit={onSubmit}
onUpdateQQList={onUpdateQQList}
/>
</Tab>
<Tab key='password' title='密码登录'>
<PasswordLogin
isLoading={isLoading}
onSubmit={onPasswordSubmit}
onCaptchaSubmit={onCaptchaSubmit}
onNewDeviceVerified={onNewDeviceVerified}
qqList={qqList}
captchaState={captchaState}
captchaVerifying={captchaVerifying}
newDeviceState={newDeviceState}
onCaptchaCancel={onCaptchaCancel}
onNewDeviceCancel={onNewDeviceCancel}
/>
</Tab>
<Tab key='qrcode' title='扫码登录'>
<QrCodeLogin
loginError={parseLoginError(loginError)}
qrcode={qrcode}
onRefresh={onRefreshQRCode}
isRefreshing={isRefreshingQRCode}
loginPhase={loginPhase}
isRecoveringLoginService={isRecoveringLoginService}
/>
</Tab>
</Tabs>
<Button
className='w-fit mx-auto'
variant='light'
color='primary'
onPress={() => {
navigate('/web_login', {
replace: true,
});
}}
>
返回 Web Login
</Button>
</CardBody>
</HoverEffectCard>
</motion.div>
</PureLayout>
{showGUIDManager && (
<Modal
title='设备 GUID 管理'
content={<GUIDManager compact showRestart />}
size='lg'
hideFooter
onClose={() => setShowGUIDManager(false)}
/>
)}
</>
);
}
@@ -0,0 +1,403 @@
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, IoArrowBack } from 'react-icons/io5';
import { useNavigate } from 'react-router-dom';
import logo from '@/assets/images/logo.png';
import key from '@/const/key';
import HoverEffectCard from '@/components/effect_card';
import { ThemeSwitch } from '@/components/theme-switch';
import WebUIManager from '@/controllers/webui_manager';
import PureLayout from '@/layouts/pure';
import { motion } from 'motion/react';
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 [isPasskeyLoading, setIsPasskeyLoading] = useState<boolean>(true); // 初始为true,表示正在检查passkey
const [, setLocalToken] = useLocalStorage<string>(key.token, '');
// 2FA相关状态
const [require2FA, setRequire2FA] = useState<boolean>(false);
const [totpCode, setTotpCode] = useState<string>('');
const [pendingToken, setPendingToken] = useState<string>('');
// Helper function to decode base64url
function base64UrlToUint8Array (base64Url: string): Uint8Array {
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
// Helper function to encode Uint8Array to base64url
function uint8ArrayToBase64Url (uint8Array: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...uint8Array));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// 自动检查并尝试passkey登录
const tryPasskeyLogin = async () => {
if (!navigator.credentials || !navigator.credentials.get) {
// toast.error('当前浏览器/环境不支持 Passkey 登录。');
return;
}
try {
// 检查是否有passkey
const options = await WebUIManager.generatePasskeyAuthenticationOptions();
// 如果有passkey,自动进行认证
const credential = await navigator.credentials.get({
publicKey: {
challenge: base64UrlToUint8Array(options.challenge) as BufferSource,
allowCredentials: options.allowCredentials?.map((cred: any) => ({
id: base64UrlToUint8Array(cred.id) as BufferSource,
type: cred.type,
transports: cred.transports,
})),
userVerification: options.userVerification,
},
}) as PublicKeyCredential;
if (!credential) {
throw new Error('Passkey authentication cancelled');
}
// 准备响应进行验证 - 转换为base64url字符串格式
const authResponse = credential.response as AuthenticatorAssertionResponse;
const response = {
id: credential.id,
rawId: uint8ArrayToBase64Url(new Uint8Array(credential.rawId)),
response: {
authenticatorData: uint8ArrayToBase64Url(new Uint8Array(authResponse.authenticatorData)),
clientDataJSON: uint8ArrayToBase64Url(new Uint8Array(authResponse.clientDataJSON)),
signature: uint8ArrayToBase64Url(new Uint8Array(authResponse.signature)),
userHandle: authResponse.userHandle ? uint8ArrayToBase64Url(new Uint8Array(authResponse.userHandle)) : null,
},
type: credential.type,
};
// 验证认证
const data = await WebUIManager.verifyPasskeyAuthentication(response);
if (data && data.Credential) {
setLocalToken(data.Credential);
navigate('/qq_login', { replace: true });
return true; // 登录成功
}
} catch (error) {
// passkey登录失败,继续显示token登录界面
console.log('Passkey login failed or not available:', error);
}
return false; // 登录失败
};
// 第一次登录(输入token)
const onSubmitToken = async () => {
if (!tokenValue) {
toast.error('请输入token');
return;
}
setIsLoading(true);
try {
const data = await WebUIManager.loginWithToken(tokenValue);
if (data) {
if (data.require2FA) {
// 需要2FA验证
setRequire2FA(true);
setPendingToken(tokenValue);
setIsLoading(false);
return;
}
// 不需要2FA,直接登录
if (data.Credential) {
setLocalToken(data.Credential);
}
navigate('/qq_login', { replace: true });
}
} catch (error) {
toast.error((error as Error).message);
} finally {
setIsLoading(false);
}
};
// 2FA验证提交
const onSubmit2FA = async () => {
if (!totpCode || totpCode.length !== 6) {
toast.error('Please enter 6-digit code');
return;
}
setIsLoading(true);
try {
const data = await WebUIManager.loginWithToken(pendingToken, totpCode);
if (data && data.Credential) {
setLocalToken(data.Credential);
navigate('/qq_login', { replace: true });
}
} catch (error) {
toast.error((error as Error).message);
} finally {
setIsLoading(false);
}
};
// 返回token输入步骤
const goBackToToken = () => {
setRequire2FA(false);
setTotpCode('');
setPendingToken('');
};
// 处理全局键盘事件
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !isLoading && !isPasskeyLoading) {
if (require2FA) {
onSubmit2FA();
} else {
onSubmitToken();
}
}
};
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
// 清理函数
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [tokenValue, totpCode, isLoading, isPasskeyLoading, require2FA]); // 依赖项包含用于登录的状态
useEffect(() => {
// 如果URL中有token,直接登录
if (token) {
// 不需要检查passkey,立即清除loading状态,避免登录失败后输入框被永久禁用
setIsPasskeyLoading(false);
onSubmitToken();
return;
}
// 否则尝试passkey自动登录
tryPasskeyLogin().finally(() => {
setIsPasskeyLoading(false);
});
}, []);
return (
<>
<title>WebUI登录 - SnowCat WebUI</title>
<PureLayout>
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.5, type: 'spring', stiffness: 120, damping: 20 }}
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 rounded-3xl border border-white/70 dark:border-white/10 bg-white/70 dark:bg-white/[0.05] shadow-2xl shadow-primary-900/10'
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-3 pt-9'>
<Image alt='logo' height='5.5em' src={logo} className='drop-shadow-md' />
<div className='text-left'>
<div className='text-2xl font-bold tracking-tight bg-gradient-to-r from-primary-500 to-secondary-400 bg-clip-text text-transparent'>
SnowCat
</div>
<div className='text-tiny tracking-[0.28em] text-default-400 uppercase'>
雪猫控制台
</div>
</div>
</div>
<ThemeSwitch className='absolute right-4 top-4' />
</CardHeader>
<CardBody className='flex gap-5 py-5 px-5 md:px-10'>
{isPasskeyLoading && (
<div className='text-center text-small text-default-600 dark:text-default-400 px-2'>
🔐 正在检查Passkey...
</div>
)}
{/* 2FA验证步骤 */}
{require2FA
? (
<>
<Button
isIconOnly
variant='light'
className='self-start -ml-2'
onPress={goBackToToken}
>
<IoArrowBack size={20} />
</Button>
<div className='text-center text-default-600 dark:text-default-400'>
请输入Authenticator中的验证码
</div>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit2FA();
}}
>
<Input
type='text'
inputMode='numeric'
pattern='[0-9]*'
maxLength={6}
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-md',
'bg-white/70',
'dark:bg-white/[0.06]',
'border border-white/60 dark:border-white/10',
'backdrop-blur-xl',
'hover:bg-white/85',
'dark:hover:bg-white/[0.09]',
'group-data-[focus=true]:bg-white/90',
'dark:group-data-[focus=true]:bg-white/[0.1]',
'!cursor-text',
],
}}
isDisabled={isLoading}
label='验证码'
placeholder='请输入6位验证码'
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={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
/>
</form>
<Button
className='mx-4 md:mx-8 mt-8 text-lg py-7 font-semibold bg-gradient-to-r from-primary-400 to-primary-600 shadow-lg shadow-primary-500/30'
color='primary'
isLoading={isLoading}
radius='full'
size='lg'
variant='shadow'
onPress={onSubmit2FA}
>
验证
</Button>
</>
)
: (
<>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmitToken();
}}
>
{/* 隐藏的用户名字段,帮助浏览器识别登录表单 */}
<input
type='text'
name='username'
value='snowcat-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-md',
'bg-white/70',
'dark:bg-white/[0.06]',
'border border-white/60 dark:border-white/10',
'backdrop-blur-xl',
'hover:bg-white/85',
'dark:hover:bg-white/[0.09]',
'group-data-[focus=true]:bg-white/90',
'dark:group-data-[focus=true]:bg-white/[0.1]',
'!cursor-text',
],
}}
isDisabled={isLoading || isPasskeyLoading}
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'>
💡 提示:请从 SnowCat 启动日志中查看登录密钥
</div>
<Button
className='mx-4 md:mx-8 mt-8 text-lg py-7 font-semibold bg-gradient-to-r from-primary-400 to-primary-600 shadow-lg shadow-primary-500/30'
color='primary'
isLoading={isLoading}
radius='full'
size='lg'
variant='shadow'
onPress={onSubmitToken}
>
{!isLoading && (
<Image
alt='logo'
classNames={{
wrapper: '-ml-8',
}}
height='2em'
src={logo}
/>
)}
登录
</Button>
</>
)}
</CardBody>
</HoverEffectCard>
</motion.div>
</PureLayout>
</>
);
}