Add password login support to web UI and backend
Some checks are pending
Build NapCat Artifacts / Build-Framework (push) Waiting to run
Build NapCat Artifacts / Build-Shell (push) Waiting to run

Implement password-based QQ login across the stack: add a PasswordLogin React component, integrate it into the QQ login page, and add a frontend controller method to call a new /QQLogin/PasswordLogin API. On the backend, add QQPasswordLoginHandler, router entry, and WebUiDataRuntime hooks (setPasswordLoginCall / requestPasswordLogin) plus a default handler. Register a password login callback in the shell (base.ts) that calls the kernel login service, handles common error cases and falls back to QR code when needed. Update types to include onPasswordLoginRequested and adjust NodeIKernelLoginService method signatures (including passwordLogin return type changed to Promise<QuickLoginResult>) and minor formatting fixes.
This commit is contained in:
手瓜一十雪 2026-02-02 19:48:31 +08:00
parent 78ac36f670
commit 74b1da67d8
9 changed files with 269 additions and 20 deletions

View File

@ -21,7 +21,7 @@ export interface PasswordLoginRetType {
jumpWord: string;
tipsTitle: string;
tipsContent: string;
}
};
}
export interface PasswordLoginArgType {
@ -55,7 +55,7 @@ export interface QuickLoginResult {
jumpUrl: string,
jumpWord: string,
tipsTitle: string,
tipsContent: string
tipsContent: string;
};
}
@ -76,16 +76,16 @@ export interface NodeIKernelLoginService {
initConfig (config: LoginInitConfig): void;
getLoginMiscData(data: string): Promise<GeneralCallResult & { value: string }>;
getLoginMiscData (data: string): Promise<GeneralCallResult & { value: string; }>;
getLoginList (): Promise<{
result: number, // 0是ok
LocalLoginInfoList: LoginListItem[]
LocalLoginInfoList: LoginListItem[];
}>;
quickLoginWithUin (uin: string): Promise<QuickLoginResult>;
passwordLogin(param: PasswordLoginArgType): Promise<unknown>;
passwordLogin (param: PasswordLoginArgType): Promise<QuickLoginResult>;
getQRCodePicture (): boolean;
}

View File

@ -220,6 +220,52 @@ async function handleLoginInner (context: { isLogined: boolean; }, logger: LogWr
}
});
});
// 注册密码登录回调
WebUiDataRuntime.setPasswordLoginCall(async (uin: string, passwordMd5: string) => {
return await new Promise((resolve) => {
if (uin && passwordMd5) {
logger.log('正在密码登录 ', uin);
loginService.passwordLogin({
uin,
passwordMd5,
step: 0,
newDeviceLoginSig: '',
proofWaterSig: '',
proofWaterRand: '',
proofWaterSid: '',
}).then(res => {
if (res.result === '140022008') {
const errMsg = '需要验证码,暂不支持';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else if (res.result === '140022010') {
const errMsg = '新设备需要扫码登录,暂不支持';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else if (res.result !== '0') {
const errMsg = res.loginErrorInfo?.errMsg || '密码登录失败';
WebUiDataRuntime.setQQLoginError(errMsg);
loginService.getQRCodePicture();
resolve({ result: false, message: errMsg });
} else {
WebUiDataRuntime.setQQLoginStatus(true);
WebUiDataRuntime.setQQLoginError('');
resolve({ result: true, message: '' });
}
}).catch((e) => {
logger.logError(e);
WebUiDataRuntime.setQQLoginError('密码登录发生错误');
loginService.getQRCodePicture();
resolve({ result: false, message: '密码登录发生错误' });
});
} else {
resolve({ result: false, message: '密码登录失败:参数不完整' });
}
});
});
if (quickLoginUin) {
if (historyLoginList.some(u => u.uin === quickLoginUin)) {
logger.log('正在快速登录 ', quickLoginUin);

View File

@ -108,3 +108,29 @@ export const QQRefreshQRcodeHandler: RequestHandler = async (_, res) => {
await WebUiDataRuntime.refreshQRCode();
return sendSuccess(res, null);
};
// 密码登录
export const QQPasswordLoginHandler: RequestHandler = async (req, res) => {
// 获取QQ号和密码MD5
const { uin, passwordMd5 } = req.body;
// 判断是否已经登录
const isLogin = WebUiDataRuntime.getQQLoginStatus();
if (isLogin) {
return sendError(res, 'QQ Is Logined');
}
// 判断QQ号是否为空
if (isEmpty(uin)) {
return sendError(res, 'uin is empty');
}
// 判断密码MD5是否为空
if (isEmpty(passwordMd5)) {
return sendError(res, 'passwordMd5 is empty');
}
// 执行密码登录
const { result, message } = await WebUiDataRuntime.requestPasswordLogin(uin, passwordMd5);
if (!result) {
return sendError(res, message);
}
return sendSuccess(res, null);
};

View File

@ -33,6 +33,9 @@ const LoginRuntime: LoginRuntimeType = {
onQuickLoginRequested: async () => {
return { result: false, message: '' };
},
onPasswordLoginRequested: async () => {
return { result: false, message: '密码登录功能未初始化' };
},
onRestartProcessRequested: async () => {
return { result: false, message: '重启功能未初始化' };
},
@ -136,6 +139,14 @@ export const WebUiDataRuntime = {
return LoginRuntime.NapCatHelper.onQuickLoginRequested(uin);
} as LoginRuntimeType['NapCatHelper']['onQuickLoginRequested'],
setPasswordLoginCall (func: LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested']): void {
LoginRuntime.NapCatHelper.onPasswordLoginRequested = func;
},
requestPasswordLogin: function (uin: string, passwordMd5: string) {
return LoginRuntime.NapCatHelper.onPasswordLoginRequested(uin, passwordMd5);
} as LoginRuntimeType['NapCatHelper']['onPasswordLoginRequested'],
setOnOB11ConfigChanged (func: LoginRuntimeType['NapCatHelper']['onOB11ConfigChanged']): void {
LoginRuntime.NapCatHelper.onOB11ConfigChanged = func;
},

View File

@ -10,6 +10,7 @@ import {
getAutoLoginAccountHandler,
setAutoLoginAccountHandler,
QQRefreshQRcodeHandler,
QQPasswordLoginHandler,
} from '@/napcat-webui-backend/src/api/QQLogin';
const router: Router = Router();
@ -31,5 +32,7 @@ router.post('/GetQuickLoginQQ', getAutoLoginAccountHandler);
router.post('/SetQuickLoginQQ', setAutoLoginAccountHandler);
// router:刷新QQ登录二维码
router.post('/RefreshQRcode', QQRefreshQRcodeHandler);
// router:密码登录
router.post('/PasswordLogin', QQPasswordLoginHandler);
export { router as QQLoginRouter };

View File

@ -56,6 +56,7 @@ export interface LoginRuntimeType {
OneBotContext: any | null; // OneBot 上下文,用于调试功能
NapCatHelper: {
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
onPasswordLoginRequested: (uin: string, passwordMd5: string) => Promise<{ result: boolean; message: string; }>;
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
onRestartProcessRequested: () => Promise<{ result: boolean; message: string; }>;
QQLoginList: string[];

View File

@ -0,0 +1,122 @@
import { Avatar } from '@heroui/avatar';
import { Button } from '@heroui/button';
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@heroui/dropdown';
import { Image } from '@heroui/image';
import { Input } from '@heroui/input';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { IoChevronDown } from 'react-icons/io5';
import type { QQItem } from '@/components/quick_login';
import { isQQQuickNewItem } from '@/utils/qq';
interface PasswordLoginProps {
onSubmit: (uin: string, password: string) => void;
isLoading: boolean;
qqList: (QQItem | LoginListItem)[];
}
const PasswordLogin: React.FC<PasswordLoginProps> = ({ onSubmit, isLoading, qqList }) => {
const [uin, setUin] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = () => {
if (!uin) {
toast.error('请输入QQ号');
return;
}
if (!password) {
toast.error('请输入密码');
return;
}
onSubmit(uin, password);
};
return (
<div className='flex flex-col gap-8'>
<div className='flex justify-center'>
<Image
className='shadow-lg'
height={100}
radius='full'
src={`https://q1.qlogo.cn/g?b=qq&nk=${uin || '0'}&s=100`}
width={100}
alt="QQ Avatar"
/>
</div>
<div className='flex flex-col gap-4'>
<Input
type="text"
label="QQ账号"
placeholder="请输入QQ号"
value={uin}
onValueChange={setUin}
variant="bordered"
size='lg'
autoComplete="off"
endContent={
<Dropdown>
<DropdownTrigger>
<Button isIconOnly variant="light" size="sm" radius="full">
<IoChevronDown size={16} />
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="QQ Login History"
items={qqList}
onAction={(key) => setUin(key.toString())}
>
{(item) => (
<DropdownItem key={item.uin} textValue={item.uin}>
<div className='flex items-center gap-2'>
<Avatar
alt={item.uin}
className='flex-shrink-0'
size='sm'
src={
isQQQuickNewItem(item)
? item.faceUrl
: `https://q1.qlogo.cn/g?b=qq&nk=${item.uin}&s=1`
}
/>
<div className='flex flex-col'>
{isQQQuickNewItem(item)
? `${item.nickName}(${item.uin})`
: item.uin}
</div>
</div>
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
}
/>
<Input
type="password"
label="密码"
placeholder="请输入密码"
value={password}
onValueChange={setPassword}
variant="bordered"
size='lg'
autoComplete="new-password"
/>
</div>
<div className='flex justify-center mt-5'>
<Button
className='w-64 max-w-full'
color='primary'
isLoading={isLoading}
radius='full'
size='lg'
variant='shadow'
onPress={handleSubmit}
>
</Button>
</div>
</div>
);
};
export default PasswordLogin;

View File

@ -93,4 +93,11 @@ export default class QQManager {
uin,
});
}
public static async passwordLogin (uin: string, passwordMd5: string) {
await serverRequest.post<ServerResponse<null>>('/QQLogin/PasswordLogin', {
uin,
passwordMd5,
});
}
}

View File

@ -5,11 +5,13 @@ 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 logo from '@/assets/images/logo.png';
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';
@ -51,6 +53,7 @@ export default function QQLoginPage () {
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) {
@ -72,6 +75,21 @@ export default function QQLoginPage () {
}
};
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 {
@ -91,11 +109,17 @@ export default function QQLoginPage () {
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('');
@ -197,6 +221,8 @@ export default function QQLoginPage () {
}}
isDisabled={isLoading}
size='lg'
selectedKey={activeTab}
onSelectionChange={(key) => setActiveTab(key.toString())}
>
<Tab key='shortcut' title='快速登录'>
<QuickLogin
@ -209,6 +235,13 @@ export default function QQLoginPage () {
onUpdateQQList={onUpdateQQList}
/>
</Tab>
<Tab key='password' title='密码登录'>
<PasswordLogin
isLoading={isLoading}
onSubmit={onPasswordSubmit}
qqList={qqList}
/>
</Tab>
<Tab key='qrcode' title='扫码登录'>
<QrCodeLogin
loginError={parseLoginError(loginError)}