Merge branch 'main' into feat-plugin-npm

This commit is contained in:
手瓜一十雪
2026-02-20 21:54:13 +08:00
111 changed files with 4377 additions and 1025 deletions

View File

@@ -54,7 +54,7 @@ const AddButton: React.FC<AddButtonProps> = (props) => {
textValue='title'
>
<div className='flex items-center gap-2 justify-center'>
<div className='w-5 h-5 -ml-3'>
<div className='w-5 h-5 -ml-3 flex items-center justify-center'>
<PlusIcon />
</div>
<div className='text-primary-400'></div>

View File

@@ -0,0 +1,714 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Divider } from '@heroui/divider';
import { Chip } from '@heroui/chip';
import { Listbox, ListboxItem } from '@heroui/listbox';
import { Spinner } from '@heroui/spinner';
import { useEffect, useState, useCallback, useMemo } from 'react';
import toast from 'react-hot-toast';
import { MdContentCopy, MdDelete, MdRefresh, MdSave, MdRestorePage, MdBackup } from 'react-icons/md';
import MD5 from 'crypto-js/md5';
import QQManager from '@/controllers/qq_manager';
import useDialog from '@/hooks/use-dialog';
interface GUIDManagerProps {
/** 是否显示重启按钮 */
showRestart?: boolean;
/** 紧凑模式(用于弹窗场景) */
compact?: boolean;
}
const GUIDManager: React.FC<GUIDManagerProps> = ({ showRestart = true, compact = false }) => {
const dialog = useDialog();
// 平台检测
const [platform, setPlatform] = useState<string>('');
const isWindows = platform === 'win32';
const isMac = platform === 'darwin';
const isLinux = platform !== '' && platform !== 'win32' && platform !== 'darwin';
const platformDetected = platform !== '';
// Windows 状态
const [currentGUID, setCurrentGUID] = useState<string>('');
const [inputGUID, setInputGUID] = useState<string>('');
const [backups, setBackups] = useState<string[]>([]);
// Linux 状态
const [currentMAC, setCurrentMAC] = useState<string>('');
const [inputMAC, setInputMAC] = useState<string>('');
const [machineId, setMachineId] = useState<string>('');
const [linuxBackups, setLinuxBackups] = useState<string[]>([]);
// 通用状态
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [restarting, setRestarting] = useState(false);
const isValidGUID = (guid: string) => /^[0-9a-fA-F]{32}$/.test(guid);
const isValidMAC = (mac: string) => /^[0-9a-fA-F]{2}(-[0-9a-fA-F]{2}){5}$/.test(mac.trim().toLowerCase());
// 前端实时计算 Linux GUID = MD5(machine-id + MAC)
const computedLinuxGUID = useMemo(() => {
if (!isLinux) return '';
const mac = inputMAC.trim().toLowerCase();
if (!machineId && !mac) return '';
return MD5(machineId + mac).toString();
}, [isLinux, machineId, inputMAC]);
// 当前生效的 GUID (基于已保存的 MAC)
const currentLinuxGUID = useMemo(() => {
if (!isLinux || !currentMAC) return '';
return MD5(machineId + currentMAC).toString();
}, [isLinux, machineId, currentMAC]);
// 检测平台
const fetchPlatform = useCallback(async () => {
try {
const data = await QQManager.getPlatformInfo();
setPlatform(data.platform);
} catch {
// 如果获取失败,默认 win32 向后兼容
setPlatform('win32');
}
}, []);
// Windows: 获取 GUID
const fetchGUID = useCallback(async () => {
setLoading(true);
try {
const data = await QQManager.getDeviceGUID();
setCurrentGUID(data.guid);
setInputGUID(data.guid);
} catch (error) {
const msg = (error as Error).message;
setCurrentGUID('');
setInputGUID('');
if (!msg.includes('not found')) {
toast.error(`获取 GUID 失败: ${msg}`);
}
} finally {
setLoading(false);
}
}, []);
// Windows: 获取备份
const fetchBackups = useCallback(async () => {
try {
const data = await QQManager.getGUIDBackups();
setBackups(data);
} catch {
// ignore
}
}, []);
// Linux: 获取 MAC + machine-id
const fetchLinuxInfo = useCallback(async () => {
setLoading(true);
try {
const [macData, midData] = await Promise.all([
QQManager.getLinuxMAC().catch(() => ({ mac: '' })),
QQManager.getLinuxMachineId().catch(() => ({ machineId: '' })),
]);
setCurrentMAC(macData.mac);
setInputMAC(macData.mac);
setMachineId(midData.machineId);
} catch (error) {
const msg = (error as Error).message;
toast.error(`获取设备信息失败: ${msg}`);
} finally {
setLoading(false);
}
}, []);
// Linux: 获取备份
const fetchLinuxBackups = useCallback(async () => {
try {
const data = await QQManager.getLinuxMachineInfoBackups();
setLinuxBackups(data);
} catch {
// ignore
}
}, []);
useEffect(() => {
fetchPlatform();
}, [fetchPlatform]);
useEffect(() => {
if (!platformDetected) return;
if (isWindows) {
fetchGUID();
fetchBackups();
} else {
fetchLinuxInfo();
fetchLinuxBackups();
}
}, [platformDetected, isWindows, fetchGUID, fetchBackups, fetchLinuxInfo, fetchLinuxBackups]);
// ========== Windows 操作 ==========
const handleCopy = () => {
const guid = isLinux ? currentLinuxGUID : currentGUID;
if (guid) {
navigator.clipboard.writeText(guid);
toast.success('已复制到剪贴板');
}
};
const handleSave = async () => {
if (!isValidGUID(inputGUID)) {
toast.error('GUID 格式无效,需要 32 位十六进制字符');
return;
}
setSaving(true);
try {
await QQManager.setDeviceGUID(inputGUID);
setCurrentGUID(inputGUID);
toast.success('GUID 已设置,重启后生效');
await fetchBackups();
} catch (error) {
const msg = (error as Error).message;
toast.error(`设置 GUID 失败: ${msg}`);
} finally {
setSaving(false);
}
};
const handleDelete = () => {
dialog.confirm({
title: '确认删除',
content: '删除 Registry20 后QQ 将在下次启动时生成新的设备标识。确定要删除吗?',
confirmText: '删除',
cancelText: '取消',
onConfirm: async () => {
try {
await QQManager.resetDeviceID();
setCurrentGUID('');
setInputGUID('');
toast.success('已删除,重启后生效');
await fetchBackups();
} catch (error) {
const msg = (error as Error).message;
toast.error(`删除失败: ${msg}`);
}
},
});
};
const handleBackup = async () => {
try {
await QQManager.createGUIDBackup();
toast.success('备份已创建');
await fetchBackups();
} catch (error) {
const msg = (error as Error).message;
toast.error(`备份失败: ${msg}`);
}
};
const handleRestore = (backupName: string) => {
dialog.confirm({
title: '确认恢复',
content: `确定要从备份 "${backupName}" 恢复吗?当前的 Registry20 将被覆盖。`,
confirmText: '恢复',
cancelText: '取消',
onConfirm: async () => {
try {
await QQManager.restoreGUIDBackup(backupName);
toast.success('已恢复,重启后生效');
await fetchGUID();
await fetchBackups();
} catch (error) {
const msg = (error as Error).message;
toast.error(`恢复失败: ${msg}`);
}
},
});
};
// ========== Linux 操作 ==========
const handleLinuxSaveMAC = async () => {
const mac = inputMAC.trim().toLowerCase();
if (!isValidMAC(mac)) {
toast.error('MAC 格式无效,需要 xx-xx-xx-xx-xx-xx 格式');
return;
}
setSaving(true);
try {
await QQManager.setLinuxMAC(mac);
setCurrentMAC(mac);
toast.success('MAC 已设置,重启后生效');
await fetchLinuxBackups();
} catch (error) {
const msg = (error as Error).message;
toast.error(`设置 MAC 失败: ${msg}`);
} finally {
setSaving(false);
}
};
const handleLinuxCopyMAC = () => {
if (currentMAC) {
navigator.clipboard.writeText(currentMAC);
toast.success('MAC 已复制到剪贴板');
}
};
const handleLinuxDelete = () => {
dialog.confirm({
title: '确认删除',
content: '删除 machine-info 后QQ 将在下次启动时生成新的设备标识。确定要删除吗?',
confirmText: '删除',
cancelText: '取消',
onConfirm: async () => {
try {
await QQManager.resetLinuxDeviceID();
setCurrentMAC('');
setInputMAC('');
toast.success('已删除,重启后生效');
await fetchLinuxBackups();
} catch (error) {
const msg = (error as Error).message;
toast.error(`删除失败: ${msg}`);
}
},
});
};
const handleLinuxBackup = async () => {
try {
await QQManager.createLinuxMachineInfoBackup();
toast.success('备份已创建');
await fetchLinuxBackups();
} catch (error) {
const msg = (error as Error).message;
toast.error(`备份失败: ${msg}`);
}
};
const handleLinuxRestore = (backupName: string) => {
dialog.confirm({
title: '确认恢复',
content: `确定要从备份 "${backupName}" 恢复吗?当前的 machine-info 将被覆盖。`,
confirmText: '恢复',
cancelText: '取消',
onConfirm: async () => {
try {
await QQManager.restoreLinuxMachineInfoBackup(backupName);
toast.success('已恢复,重启后生效');
await fetchLinuxInfo();
await fetchLinuxBackups();
} catch (error) {
const msg = (error as Error).message;
toast.error(`恢复失败: ${msg}`);
}
},
});
};
// ========== 重启 ==========
const handleRestart = () => {
dialog.confirm({
title: '确认重启',
content: '确定要重启 NapCat 吗?这将导致当前连接断开。',
confirmText: '重启',
cancelText: '取消',
onConfirm: async () => {
setRestarting(true);
try {
await QQManager.restartNapCat();
toast.success('重启指令已发送');
} catch (error) {
const msg = (error as Error).message;
toast.error(`重启失败: ${msg}`);
} finally {
setRestarting(false);
}
},
});
};
if (loading || !platformDetected) {
return (
<div className='flex items-center justify-center py-8'>
<Spinner label='加载中...' />
</div>
);
}
// ========== macOS 不支持 ==========
if (isMac) {
return (
<div className={`flex flex-col gap-${compact ? '3' : '4'}`}>
<div className='flex flex-col items-center justify-center py-8 gap-2'>
<Chip variant='flat' color='warning' className='text-xs'>
macOS GUID
</Chip>
<div className='text-xs text-default-400'>
Windows Linux
</div>
</div>
</div>
);
}
// ========== Linux 渲染 ==========
if (isLinux) {
return (
<div className={`flex flex-col gap-${compact ? '3' : '4'}`}>
{/* 当前设备信息 */}
<div className='flex flex-col gap-2'>
<div className='text-sm font-medium text-default-700'> GUID</div>
<div className='flex items-center gap-2'>
{currentLinuxGUID ? (
<Chip variant='flat' color='primary' className='font-mono text-xs max-w-full'>
{currentLinuxGUID}
</Chip>
) : (
<Chip variant='flat' color='warning' className='text-xs'>
/
</Chip>
)}
{currentLinuxGUID && (
<Button
isIconOnly
size='sm'
variant='light'
onPress={handleCopy}
aria-label='复制GUID'
>
<MdContentCopy size={16} />
</Button>
)}
<Button
isIconOnly
size='sm'
variant='light'
onPress={fetchLinuxInfo}
aria-label='刷新'
>
<MdRefresh size={16} />
</Button>
</div>
<div className='text-xs text-default-400'>
GUID = MD5(machine-id + MAC) MAC GUID
</div>
</div>
<Divider />
{/* machine-id 显示 */}
<div className='flex flex-col gap-1'>
<div className='text-sm font-medium text-default-700'>Machine ID</div>
<Chip variant='flat' color='default' className='font-mono text-xs max-w-full'>
{machineId || '未知'}
</Chip>
<div className='text-xs text-default-400'>
/etc/machine-id
</div>
</div>
<Divider />
{/* 当前 MAC 显示 */}
<div className='flex flex-col gap-2'>
<div className='text-sm font-medium text-default-700'> MAC </div>
<div className='flex items-center gap-2'>
{currentMAC ? (
<Chip variant='flat' color='secondary' className='font-mono text-xs max-w-full'>
{currentMAC}
</Chip>
) : (
<Chip variant='flat' color='warning' className='text-xs'>
/
</Chip>
)}
{currentMAC && (
<Button
isIconOnly
size='sm'
variant='light'
onPress={handleLinuxCopyMAC}
aria-label='复制MAC'
>
<MdContentCopy size={16} />
</Button>
)}
</div>
</div>
<Divider />
{/* 编辑 MAC 地址 */}
<div className='flex flex-col gap-2'>
<div className='text-sm font-medium text-default-700'> MAC </div>
<div className='flex items-center gap-2'>
<Input
size='sm'
variant='bordered'
placeholder='xx-xx-xx-xx-xx-xx'
value={inputMAC}
onValueChange={setInputMAC}
isInvalid={inputMAC.length > 0 && !isValidMAC(inputMAC)}
errorMessage={inputMAC.length > 0 && !isValidMAC(inputMAC) ? '格式: xx-xx-xx-xx-xx-xx' : undefined}
classNames={{
input: 'font-mono text-sm',
}}
maxLength={17}
/>
</div>
{/* 实时 GUID 预览 */}
{inputMAC && isValidMAC(inputMAC) && (
<div className='flex flex-col gap-1 p-2 rounded-lg bg-default-100'>
<div className='text-xs font-medium text-default-500'> GUID</div>
<div className='font-mono text-xs text-primary break-all'>
{computedLinuxGUID}
</div>
{computedLinuxGUID !== currentLinuxGUID && (
<div className='text-xs text-warning-500'>
GUID
</div>
)}
</div>
)}
<div className='flex items-center gap-2'>
<Button
size='sm'
color='primary'
variant='flat'
isLoading={saving}
isDisabled={!isValidMAC(inputMAC) || inputMAC.trim().toLowerCase() === currentMAC}
onPress={handleLinuxSaveMAC}
startContent={<MdSave size={16} />}
>
MAC
</Button>
<Button
size='sm'
color='danger'
variant='flat'
isDisabled={!currentMAC}
onPress={handleLinuxDelete}
startContent={<MdDelete size={16} />}
>
</Button>
<Button
size='sm'
color='secondary'
variant='flat'
isDisabled={!currentMAC}
onPress={handleLinuxBackup}
startContent={<MdBackup size={16} />}
>
</Button>
</div>
<div className='text-xs text-default-400'>
MAC GUID NapCat
</div>
</div>
{/* 备份恢复 */}
{linuxBackups.length > 0 && (
<>
<Divider />
<div className='flex flex-col gap-2'>
<div className='text-sm font-medium text-default-700'>
<span className='text-xs text-default-400 ml-2'></span>
</div>
<div className='max-h-[160px] overflow-y-auto rounded-lg border border-default-200'>
<Listbox
aria-label='备份列表'
selectionMode='none'
onAction={(key) => handleLinuxRestore(key as string)}
>
{linuxBackups.map((name) => (
<ListboxItem
key={name}
startContent={<MdRestorePage size={16} className='text-default-400' />}
className='font-mono text-xs'
>
{name}
</ListboxItem>
))}
</Listbox>
</div>
</div>
</>
)}
{/* 重启 */}
{showRestart && (
<>
<Divider />
<Button
size='sm'
color='warning'
variant='flat'
isLoading={restarting}
onPress={handleRestart}
startContent={<MdRefresh size={16} />}
>
NapCat
</Button>
</>
)}
</div>
);
}
// ========== Windows 渲染 ==========
return (
<div className={`flex flex-col gap-${compact ? '3' : '4'}`}>
{/* 当前 GUID 显示 */}
<div className='flex flex-col gap-2'>
<div className='text-sm font-medium text-default-700'> GUID</div>
<div className='flex items-center gap-2'>
{currentGUID ? (
<Chip variant='flat' color='primary' className='font-mono text-xs max-w-full'>
{currentGUID}
</Chip>
) : (
<Chip variant='flat' color='warning' className='text-xs'>
/
</Chip>
)}
{currentGUID && (
<Button
isIconOnly
size='sm'
variant='light'
onPress={handleCopy}
aria-label='复制GUID'
>
<MdContentCopy size={16} />
</Button>
)}
<Button
isIconOnly
size='sm'
variant='light'
onPress={fetchGUID}
aria-label='刷新'
>
<MdRefresh size={16} />
</Button>
</div>
</div>
<Divider />
{/* 设置 GUID */}
<div className='flex flex-col gap-2'>
<div className='text-sm font-medium text-default-700'> GUID</div>
<div className='flex items-center gap-2'>
<Input
size='sm'
variant='bordered'
placeholder='输入32位十六进制 GUID'
value={inputGUID}
onValueChange={setInputGUID}
isInvalid={inputGUID.length > 0 && !isValidGUID(inputGUID)}
errorMessage={inputGUID.length > 0 && !isValidGUID(inputGUID) ? '需要32位十六进制字符' : undefined}
classNames={{
input: 'font-mono text-sm',
}}
maxLength={32}
/>
</div>
<div className='flex items-center gap-2'>
<Button
size='sm'
color='primary'
variant='flat'
isLoading={saving}
isDisabled={!isValidGUID(inputGUID) || inputGUID === currentGUID}
onPress={handleSave}
startContent={<MdSave size={16} />}
>
GUID
</Button>
<Button
size='sm'
color='danger'
variant='flat'
isDisabled={!currentGUID}
onPress={handleDelete}
startContent={<MdDelete size={16} />}
>
GUID
</Button>
<Button
size='sm'
color='secondary'
variant='flat'
isDisabled={!currentGUID}
onPress={handleBackup}
startContent={<MdBackup size={16} />}
>
</Button>
</div>
<div className='text-xs text-default-400'>
GUID NapCat
</div>
</div>
{/* 备份恢复 */}
{backups.length > 0 && (
<>
<Divider />
<div className='flex flex-col gap-2'>
<div className='text-sm font-medium text-default-700'>
<span className='text-xs text-default-400 ml-2'></span>
</div>
<div className='max-h-[160px] overflow-y-auto rounded-lg border border-default-200'>
<Listbox
aria-label='备份列表'
selectionMode='none'
onAction={(key) => handleRestore(key as string)}
>
{backups.map((name) => (
<ListboxItem
key={name}
startContent={<MdRestorePage size={16} className='text-default-400' />}
className='font-mono text-xs'
>
{name}
</ListboxItem>
))}
</Listbox>
</div>
</div>
</>
)}
{/* 重启 */}
{showRestart && (
<>
<Divider />
<Button
size='sm'
color='warning'
variant='flat'
isLoading={restarting}
onPress={handleRestart}
startContent={<MdRefresh size={16} />}
>
NapCat
</Button>
</>
)}
</div>
);
};
export default GUIDManager;

View File

@@ -101,4 +101,100 @@ export default class QQManager {
passwordMd5,
});
}
public static async resetDeviceID () {
await serverRequest.post<ServerResponse<null>>('/QQLogin/ResetDeviceID');
}
public static async restartNapCat () {
await serverRequest.post<ServerResponse<null>>('/QQLogin/RestartNapCat');
}
public static async getDeviceGUID () {
const data = await serverRequest.post<ServerResponse<{ guid: string; }>>('/QQLogin/GetDeviceGUID');
return data.data.data;
}
public static async setDeviceGUID (guid: string) {
await serverRequest.post<ServerResponse<null>>('/QQLogin/SetDeviceGUID', { guid });
}
public static async getGUIDBackups () {
const data = await serverRequest.post<ServerResponse<string[]>>('/QQLogin/GetGUIDBackups');
return data.data.data;
}
public static async restoreGUIDBackup (backupName: string) {
await serverRequest.post<ServerResponse<null>>('/QQLogin/RestoreGUIDBackup', { backupName });
}
public static async createGUIDBackup () {
const data = await serverRequest.post<ServerResponse<{ path: string; }>>('/QQLogin/CreateGUIDBackup');
return data.data.data;
}
// ============================================================
// 平台信息 & Linux GUID 管理
// ============================================================
public static async getPlatformInfo () {
const data = await serverRequest.post<ServerResponse<{ platform: string; }>>('/QQLogin/GetPlatformInfo');
return data.data.data;
}
public static async getLinuxMAC () {
const data = await serverRequest.post<ServerResponse<{ mac: string; }>>('/QQLogin/GetLinuxMAC');
return data.data.data;
}
public static async setLinuxMAC (mac: string) {
await serverRequest.post<ServerResponse<null>>('/QQLogin/SetLinuxMAC', { mac });
}
public static async getLinuxMachineId () {
const data = await serverRequest.post<ServerResponse<{ machineId: string; }>>('/QQLogin/GetLinuxMachineId');
return data.data.data;
}
public static async computeLinuxGUID (mac?: string, machineId?: string) {
const data = await serverRequest.post<ServerResponse<{ guid: string; machineId: string; mac: string; }>>('/QQLogin/ComputeLinuxGUID', { mac, machineId });
return data.data.data;
}
public static async getLinuxMachineInfoBackups () {
const data = await serverRequest.post<ServerResponse<string[]>>('/QQLogin/GetLinuxMachineInfoBackups');
return data.data.data;
}
public static async createLinuxMachineInfoBackup () {
const data = await serverRequest.post<ServerResponse<{ path: string; }>>('/QQLogin/CreateLinuxMachineInfoBackup');
return data.data.data;
}
public static async restoreLinuxMachineInfoBackup (backupName: string) {
await serverRequest.post<ServerResponse<null>>('/QQLogin/RestoreLinuxMachineInfoBackup', { backupName });
}
public static async resetLinuxDeviceID () {
await serverRequest.post<ServerResponse<null>>('/QQLogin/ResetLinuxDeviceID');
}
// ============================================================
// NapCat 配置管理
// ============================================================
public static async getNapCatConfig () {
const { data } = await serverRequest.get<ServerResponse<NapCatConfig>>(
'/NapCatConfig/GetConfig'
);
return data.data;
}
public static async setNapCatConfig (config: Partial<NapCatConfig>) {
await serverRequest.post<ServerResponse<null>>(
'/NapCatConfig/SetConfig',
config
);
}
}

View File

@@ -0,0 +1,175 @@
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.getNapCatConfig();
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.setNapCatConfig({ 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 <PageLoading loading />;
return (
<>
<title> - NapCat WebUI</title>
<div className='flex flex-col gap-1 mb-2'>
<h3 className='text-lg font-semibold text-default-700'></h3>
<p className='text-sm text-default-500'>
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;

View File

@@ -14,6 +14,7 @@ import SSLConfigCard from './ssl';
import ThemeConfigCard from './theme';
import WebUIConfigCard from './webui';
import BackupConfigCard from './backup';
import BypassConfigCard from './bypass';
export interface ConfigPageProps {
children?: React.ReactNode;
@@ -114,6 +115,11 @@ export default function ConfigPage () {
<BackupConfigCard />
</ConfigPageItem>
</Tab>
<Tab title='反检测' key='bypass'>
<ConfigPageItem>
<BypassConfigCard />
</ConfigPageItem>
</Tab>
</Tabs>
</section>
);

View File

@@ -1,11 +1,13 @@
import { Input } from '@heroui/input';
import { Button } from '@heroui/button';
import { Divider } from '@heroui/divider';
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';
@@ -131,6 +133,14 @@ const LoginConfigCard = () => {
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>
</>
);
};

View File

@@ -131,7 +131,7 @@ const WebUIConfigCard = () => {
isLoading={isLoadingOptions}
className='w-fit'
>
{!isLoadingOptions && '📥'}
{!isLoadingOptions}
</Button>
<Button
@@ -225,12 +225,12 @@ const WebUIConfigCard = () => {
disabled={!registrationOptions}
className='w-fit'
>
🔐 Passkey
Passkey
</Button>
</div>
{registrationOptions && (
<div className='text-xs text-green-600'>
</div>
)}
</div>

View File

@@ -115,39 +115,42 @@ export default function ExtensionPage () {
</Button>
</div>
{extensionPages.length > 0 && (
<Tabs
aria-label='Extension Pages'
className='max-w-full'
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(key as string)}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'hidden',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
title={
<div className='flex items-center gap-2'>
{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 className='max-w-full overflow-x-auto overflow-y-hidden pb-1 -mb-1'>
<Tabs
aria-label='Extension Pages'
className='min-w-max'
selectedKey={selectedTab}
onSelectionChange={(key) => setSelectedTab(key as string)}
classNames={{
tabList: 'bg-white/40 dark:bg-black/20 backdrop-blur-md flex-nowrap',
cursor: 'bg-white/80 dark:bg-white/10 backdrop-blur-md shadow-sm',
panel: 'hidden',
}}
>
{tabs.map((tab) => (
<Tab
key={tab.key}
className='shrink-0'
title={
<div className='flex items-center gap-2'>
{tab.icon && <span>{tab.icon}</span>}
<span
className='cursor-pointer hover:underline truncate max-w-[6rem] md:max-w-none shrink-0'
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 shrink-0'>({tab.pluginName})</span>
</div>
}
/>
))}
</Tabs>
</div>
)}
</div>

View File

@@ -61,30 +61,28 @@ export default function PluginPage () {
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>{plugin.name}? </p>
<p className="text-small text-default-500"></p>
<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>
),
// This 'dialog' utility might not support returning a value from UI interacting.
// We might need to implement a custom confirmation flow if we want a checkbox.
// Alternatively, use two buttons? "Uninstall & Clean", "Uninstall Only"?
// Standard dialog usually has Confirm/Cancel.
// Let's stick to a simpler "Uninstall" and then maybe a second prompt? Or just clean data?
// User requested: "Uninstall prompts whether to clean data".
// Let's use `window.confirm` for the second step or assume `dialog.confirm` is flexible enough?
// I will implement a two-step confirmation or try to modify the dialog hook if visible (not visible here).
// Let's use a standard `window.confirm` for the data cleanup question if the custom dialog doesn't support complex return.
// Better: Inside onConfirm, ask again?
confirmText: '确定卸载',
cancelText: '取消',
onConfirm: async () => {
// Ask for data cleanup
// Since we are in an async callback, we can use another dialog or confirm.
// Native confirm is ugly but works reliably for logic:
const cleanData = window.confirm(`是否同时清理插件「${plugin.name}」的数据文件?\n点击“确定”清理数据点击“取消”仅卸载插件。`);
const loadingToast = toast.loading('卸载中...');
try {
await PluginManager.uninstallPlugin(plugin.id, cleanData);

View File

@@ -374,10 +374,10 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
<Button color="primary" onPress={handleSave} isLoading={saving}>
Save
</Button>
</ModalFooter>
</>

View File

@@ -6,8 +6,11 @@ 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 { title } from '@/components/primitives';
@@ -174,6 +177,8 @@ export default function QQLoginPage () {
}
};
const [showGUIDManager, setShowGUIDManager] = useState(false);
useEffect(() => {
const timer = setInterval(() => {
onUpdateQrCode();
@@ -210,7 +215,12 @@ export default function QQLoginPage () {
</span>
</div>
</div>
<ThemeSwitch className='absolute right-4 top-4' />
<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-10 pt-0'>
@@ -266,6 +276,15 @@ export default function QQLoginPage () {
</HoverEffectCard>
</motion.div>
</PureLayout>
{showGUIDManager && (
<Modal
title='设备 GUID 管理'
content={<GUIDManager compact showRestart />}
size='lg'
hideFooter
onClose={() => setShowGUIDManager(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,19 @@
interface BypassOptions {
hook: boolean;
window: boolean;
module: boolean;
process: boolean;
container: boolean;
js: boolean;
}
interface NapCatConfig {
fileLog: boolean;
consoleLog: boolean;
fileLogLevel: string;
consoleLogLevel: string;
packetBackend: string;
packetServer: string;
o3HookMode: number;
bypass?: BypassOptions;
}