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:
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, '');
|
||||
cleaned = cleaned.replace(/<img[^>]*\\balt=["']([^"']+)["'][^>]*\\bsrc=["']([^"']+)["'][^>]*>/gi, '');
|
||||
cleaned = cleaned.replace(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi, '');
|
||||
|
||||
// 移除其他 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user