fix: 接入NapCat验证码提交

This commit is contained in:
sunlei 2026-06-13 09:27:22 +08:00
parent ab43fe8632
commit 68e593237e
3 changed files with 247 additions and 1 deletions

View File

@ -47,7 +47,7 @@ pnpm run build:antdv-next
## 业务页面 ## 业务页面
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。 - 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / qrcode 每步进度。 - QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / qrcode 每步进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API
## 部署说明 ## 部署说明

View File

@ -85,6 +85,7 @@ export namespace QqbotApi {
export interface AccountScanResult { export interface AccountScanResult {
accountId?: string; accountId?: string;
captchaUrl?: string;
containerId?: string; containerId?: string;
containerName?: string; containerName?: string;
errorMessage?: string; errorMessage?: string;
@ -105,6 +106,13 @@ export namespace QqbotApi {
step: string; step: string;
} }
export interface AccountScanCaptchaBody {
randstr: string;
sessionId: string;
sid?: string;
ticket: string;
}
export interface Rule { export interface Rule {
cooldownMs: number; cooldownMs: number;
enabled: boolean; enabled: boolean;
@ -388,6 +396,15 @@ export function refreshQqbotAccountScanQrcode(sessionId: string) {
); );
} }
export function submitQqbotAccountScanCaptcha(
data: QqbotApi.AccountScanCaptchaBody,
) {
return requestClient.post<QqbotApi.AccountScanResult>(
'/qqbot/account/scan/captcha/submit',
data,
);
}
export function cancelQqbotAccountScan(sessionId: string) { export function cancelQqbotAccountScan(sessionId: string) {
return requestClient.post<boolean>( return requestClient.post<boolean>(
`/qqbot/account/scan/cancel?sessionId=${sessionId}`, `/qqbot/account/scan/cancel?sessionId=${sessionId}`,

View File

@ -36,6 +36,7 @@ import {
refreshQqbotAccountScanQrcode, refreshQqbotAccountScanQrcode,
startQqbotAccountScanCreate, startQqbotAccountScanCreate,
startQqbotAccountScanRefresh, startQqbotAccountScanRefresh,
submitQqbotAccountScanCaptcha,
updateQqbotAccount, updateQqbotAccount,
} from '#/api/qqbot'; } from '#/api/qqbot';
import { KtTable, useKtTable } from '#/components/ktTable'; import { KtTable, useKtTable } from '#/components/ktTable';
@ -46,6 +47,32 @@ const ASteps = Steps as any;
const ATypographyLink = Typography.Link as any; const ATypographyLink = Typography.Link as any;
const ATypographyText = Typography.Text as any; const ATypographyText = Typography.Text as any;
type TencentCaptchaResult = {
appid?: string;
errorCode?: number;
errorMessage?: string;
randstr?: string;
ret: number;
ticket?: string;
};
type TencentCaptchaInstance = {
destroy: () => void;
show: () => void;
};
declare global {
interface Window {
TencentCaptcha?: new (
appid: string,
callback: (res: TencentCaptchaResult) => void,
options?: Record<string, unknown>,
) => TencentCaptchaInstance;
}
}
let tencentCaptchaScriptPromise: Promise<void> | undefined;
export default defineComponent({ export default defineComponent({
name: 'QqBotAccountList', name: 'QqBotAccountList',
setup() { setup() {
@ -57,6 +84,7 @@ export default defineComponent({
const scanQrcodeText = ref(''); const scanQrcodeText = ref('');
const scanEvents = ref<QqbotApi.AccountScanEvent[]>([]); const scanEvents = ref<QqbotApi.AccountScanEvent[]>([]);
const scanState = reactive<{ const scanState = reactive<{
captchaUrl?: string;
containerId?: string; containerId?: string;
containerName?: string; containerName?: string;
errorMessage?: string; errorMessage?: string;
@ -102,6 +130,9 @@ export default defineComponent({
Math.max(scanProgressItems.value.length - 1, 0), Math.max(scanProgressItems.value.length - 1, 0),
); );
const scanQrcodePlaceholderText = computed(() => { const scanQrcodePlaceholderText = computed(() => {
if (scanState.captchaUrl) {
return '等待安全验证';
}
if ( if (
scanState.mode === 'refresh' && scanState.mode === 'refresh' &&
scanState.errorMessage?.includes('正在尝试快速登录') scanState.errorMessage?.includes('正在尝试快速登录')
@ -395,6 +426,7 @@ export default defineComponent({
result: QqbotApi.AccountScanResult, result: QqbotApi.AccountScanResult,
options: { reloadQrcode?: boolean } = {}, options: { reloadQrcode?: boolean } = {},
) { ) {
scanState.captchaUrl = result.captchaUrl;
scanState.containerId = result.containerId; scanState.containerId = result.containerId;
scanState.containerName = result.containerName; scanState.containerName = result.containerName;
scanState.errorMessage = result.errorMessage; scanState.errorMessage = result.errorMessage;
@ -456,6 +488,37 @@ export default defineComponent({
} }
} }
async function submitScanCaptcha() {
const sessionId = scanState.sessionId;
const captchaUrl = scanState.captchaUrl;
if (!sessionId || !captchaUrl || scanLoading.value) {
return;
}
scanLoading.value = true;
try {
const captcha = await requestTencentCaptcha(captchaUrl);
if (
scanState.sessionId !== sessionId ||
scanState.captchaUrl !== captchaUrl
) {
return;
}
await applyScanResult(
await submitQqbotAccountScanCaptcha({
...captcha,
sessionId,
}),
);
} catch (error) {
const text = getErrorMessage(error);
if (text !== '已取消安全验证') {
message.error(text);
}
} finally {
scanLoading.value = false;
}
}
function startScanPolling() { function startScanPolling() {
if (scanTimer) return; if (scanTimer) return;
scanTimer = window.setInterval(() => { scanTimer = window.setInterval(() => {
@ -521,6 +584,7 @@ export default defineComponent({
stopScanPolling(); stopScanPolling();
stopScanEvents(); stopScanEvents();
Object.assign(scanState, { Object.assign(scanState, {
captchaUrl: undefined,
containerId: undefined, containerId: undefined,
containerName: undefined, containerName: undefined,
errorMessage: undefined, errorMessage: undefined,
@ -965,6 +1029,28 @@ export default defineComponent({
type="info" type="info"
/> />
) : null} ) : null}
{scanState.captchaUrl ? (
<Alert
description={
<Space>
<ATypographyText>
NapCat
</ATypographyText>
<AButton
loading={scanLoading.value}
onClick={submitScanCaptcha}
type="primary"
>
</AButton>
</Space>
}
message="QQ 密码登录需要安全验证"
showIcon
type="warning"
/>
) : null}
{scanProgressItems.value.length > 0 ? ( {scanProgressItems.value.length > 0 ? (
<ASteps <ASteps
current={scanProgressCurrent.value} current={scanProgressCurrent.value}
@ -1021,3 +1107,146 @@ export default defineComponent({
); );
}, },
}); });
function requestTencentCaptcha(
proofWaterUrl: string,
): Promise<Omit<QqbotApi.AccountScanCaptchaBody, 'sessionId'>> {
const params = parseUrlParams(proofWaterUrl);
const appid = params.aid || '2081081773';
const sid = params.sid || '';
return loadTencentCaptchaScript().then(
() =>
new Promise((resolve, reject) => {
if (!window.TencentCaptcha) {
reject(new Error('腾讯验证码组件加载失败'));
return;
}
let captcha: TencentCaptchaInstance | undefined;
let settled = false;
const finish = (
error?: Error,
value?: Omit<QqbotApi.AccountScanCaptchaBody, 'sessionId'>,
) => {
if (settled) return;
settled = true;
try {
captcha?.destroy();
} catch {
// The captcha SDK may already have cleaned up its popup.
}
if (error) {
reject(error);
return;
}
if (!value) {
reject(new Error('腾讯验证码未返回验证结果'));
return;
}
resolve(value);
};
captcha = new window.TencentCaptcha(
appid,
(res) => {
if (res.ret === 0 && res.ticket && res.randstr) {
finish(undefined, {
randstr: res.randstr,
sid,
ticket: res.ticket,
});
return;
}
finish(new Error('已取消安全验证'));
},
{
enableAged: true,
login_appid: params.login_appid,
showHeader: false,
sid: params.sid,
type: 'popup',
uin: params.uin,
},
);
captcha.show();
}),
);
}
async function loadTencentCaptchaScript() {
if (window.TencentCaptcha) return;
tencentCaptchaScriptPromise =
tencentCaptchaScriptPromise ||
loadScriptWithFallback([
'https://captcha.gtimg.com/TCaptcha.js',
'https://ssl.captcha.qq.com/TCaptcha.js',
]);
try {
await tencentCaptchaScriptPromise;
} catch (error) {
tencentCaptchaScriptPromise = undefined;
throw error;
}
}
async function loadScriptWithFallback(sources: string[]) {
let lastError: unknown;
for (const source of sources) {
try {
await loadScript(source);
return;
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error
? lastError
: new Error('腾讯验证码脚本加载失败');
}
function loadScript(source: string) {
return new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${source}"]`,
);
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener(
'error',
() => reject(new Error(`腾讯验证码脚本加载失败:${source}`)),
{ once: true },
);
return;
}
const script = document.createElement('script');
script.async = true;
script.src = source;
script.addEventListener('load', () => resolve(), { once: true });
script.addEventListener(
'error',
() => reject(new Error(`腾讯验证码脚本加载失败:${source}`)),
{ once: true },
);
document.head.append(script);
});
}
function parseUrlParams(url: string) {
const params: Record<string, string> = {};
try {
const parsed = new URL(url);
parsed.searchParams.forEach((value, key) => {
params[key] = value;
});
return params;
} catch {
const query = url.split('?')[1] || '';
query.split('&').forEach((pair) => {
const [key, value = ''] = pair.split('=');
if (key) params[key] = decodeURIComponent(value);
});
return params;
}
}