Files
NapCatQQ/packages/napcat-webui-frontend/src/pages/qq_login.tsx
手瓜一十雪 2f8569f30c Add Registry20 GUID management and DPAPI support
Introduce tools and UI to read, write, backup and restore QQ Registry20 GUIDs using Windows DPAPI. Adds a Python CLI (guid_tool.py) for local Registry20 operations and a new TypeScript package napcat-dpapi with native bindings for DPAPI. Implements Registry20Utils in the webui-backend to protect/unprotect Registry20, plus backup/restore/delete helpers. Exposes new backend API handlers and routes (get/set GUID, backups, restore, reset, restart) and integrates frontend GUIDManager component and qq_manager controller methods. Propagates QQ data path via WebUiDataRuntime (setter/getter) and wires it up in framework/shell; updates Vite alias and package.json to include the new dpapi workspace. Includes native addon binaries for win32 x64/arm64 and basic tsconfig/readme/license for the new package.
2026-02-12 17:08:24 +08:00

291 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { title } from '@/components/primitives';
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 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 [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 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();
await QQManager.passwordLogin(uin, passwordMd5);
toast.success('密码登录请求已发送');
} catch (error) {
const msg = (error as Error).message;
toast.error(`密码登录失败: ${msg}`);
} finally {
setIsLoading(false);
}
};
const onUpdateQrCode = async () => {
if (firstLoad.current) setIsLoading(true);
try {
const data = await QQManager.checkQQLoginStatusWithQrcode();
if (firstLoad.current) {
setIsLoading(false);
firstLoad.current = false;
}
if (data.isLogin) {
toast.success('QQ登录成功');
navigate('/', { replace: true });
} else {
setQrcode(data.qrcodeurl);
if (data.loginError && data.loginError !== lastErrorRef.current) {
lastErrorRef.current = data.loginError;
setLoginError(data.loginError);
const friendlyMsg = parseLoginError(data.loginError);
// 仅在扫码登录 Tab 下才弹窗,或者错误不是"二维码已过期"
// 如果是 "二维码已过期",且不在 qrcode tab则不弹窗
const isQrCodeExpired = friendlyMsg.includes('二维码') && (friendlyMsg.includes('过期') || friendlyMsg.includes('失效'));
if (!isQrCodeExpired || activeTab === 'qrcode') {
dialog.alert({
title: '登录失败',
content: friendlyMsg,
confirmText: '确定',
});
}
} else if (!data.loginError) {
lastErrorRef.current = '';
setLoginError('');
}
}
} catch (error) {
const msg = (error as Error).message;
toast.error(`获取二维码失败: ${msg}`);
}
};
const onUpdateQQList = async () => {
setRefresh(true);
try {
const data = await QQManager.getQQQuickLoginListNew();
setQQList(data);
} catch (_error) {
try {
const data = await QQManager.getQQQuickLoginList();
const qqList = data.map((item) => ({
uin: item,
}));
setQQList(qqList);
} catch (_error) {
const msg = (_error as Error).message;
toast.error(`获取QQ列表失败: ${msg}`);
}
} finally {
setRefresh(false);
}
};
const handleSelectionChange: React.ChangeEventHandler<HTMLSelectElement> = (
e
) => {
setUinValue(e.target.value);
};
const onRefreshQRCode = async () => {
try {
lastErrorRef.current = '';
setLoginError('');
await QQManager.refreshQRCode();
toast.success('已发送刷新请求');
} catch (error) {
const msg = (error as Error).message;
toast.error(`刷新二维码失败: ${msg}`);
}
};
const [showGUIDManager, setShowGUIDManager] = useState(false);
useEffect(() => {
const timer = setInterval(() => {
onUpdateQrCode();
}, 3000);
onUpdateQrCode();
onUpdateQQList();
return () => clearInterval(timer);
}, []);
return (
<>
<title>QQ登录 - NapCat 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 bg-default-50'
maxXRotation={3}
maxYRotation={3}
>
<CardHeader className='inline-block max-w-lg text-center justify-center'>
<div className='flex items-center justify-center w-full gap-2 pt-10'>
<Image alt='logo' height='7em' src={logo} />
<div>
<span className={title()}>Web&nbsp;</span>
<span className={title({ color: 'violet' })}>
Login&nbsp;
</span>
</div>
</div>
<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'>
<Tabs
fullWidth
classNames={{
tabList: 'shadow-sm dark:shadow-none',
}}
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}
qqList={qqList}
/>
</Tab>
<Tab key='qrcode' title='扫码登录'>
<QrCodeLogin
loginError={parseLoginError(loginError)}
qrcode={qrcode}
onRefresh={onRefreshQRCode}
/>
</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)}
/>
)}
</>
);
}