From 68e593237e2cea40b620387e51bde5b740900969 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 13 Jun 2026 09:27:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8E=A5=E5=85=A5NapCat=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- apps/web-antdv-next/src/api/qqbot/index.ts | 17 ++ .../src/views/qqbot/account/list.tsx | 229 ++++++++++++++++++ 3 files changed, 247 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6089f30..eda1eeb 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ pnpm run build:antdv-next ## 业务页面 - 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。 -- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / qrcode 每步进度。 +- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / qrcode 每步进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API。 ## 部署说明 diff --git a/apps/web-antdv-next/src/api/qqbot/index.ts b/apps/web-antdv-next/src/api/qqbot/index.ts index 9096423..215e7da 100644 --- a/apps/web-antdv-next/src/api/qqbot/index.ts +++ b/apps/web-antdv-next/src/api/qqbot/index.ts @@ -85,6 +85,7 @@ export namespace QqbotApi { export interface AccountScanResult { accountId?: string; + captchaUrl?: string; containerId?: string; containerName?: string; errorMessage?: string; @@ -105,6 +106,13 @@ export namespace QqbotApi { step: string; } + export interface AccountScanCaptchaBody { + randstr: string; + sessionId: string; + sid?: string; + ticket: string; + } + export interface Rule { cooldownMs: number; enabled: boolean; @@ -388,6 +396,15 @@ export function refreshQqbotAccountScanQrcode(sessionId: string) { ); } +export function submitQqbotAccountScanCaptcha( + data: QqbotApi.AccountScanCaptchaBody, +) { + return requestClient.post( + '/qqbot/account/scan/captcha/submit', + data, + ); +} + export function cancelQqbotAccountScan(sessionId: string) { return requestClient.post( `/qqbot/account/scan/cancel?sessionId=${sessionId}`, diff --git a/apps/web-antdv-next/src/views/qqbot/account/list.tsx b/apps/web-antdv-next/src/views/qqbot/account/list.tsx index 63e0303..db29fd5 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/list.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/list.tsx @@ -36,6 +36,7 @@ import { refreshQqbotAccountScanQrcode, startQqbotAccountScanCreate, startQqbotAccountScanRefresh, + submitQqbotAccountScanCaptcha, updateQqbotAccount, } from '#/api/qqbot'; import { KtTable, useKtTable } from '#/components/ktTable'; @@ -46,6 +47,32 @@ const ASteps = Steps as any; const ATypographyLink = Typography.Link 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, + ) => TencentCaptchaInstance; + } +} + +let tencentCaptchaScriptPromise: Promise | undefined; + export default defineComponent({ name: 'QqBotAccountList', setup() { @@ -57,6 +84,7 @@ export default defineComponent({ const scanQrcodeText = ref(''); const scanEvents = ref([]); const scanState = reactive<{ + captchaUrl?: string; containerId?: string; containerName?: string; errorMessage?: string; @@ -102,6 +130,9 @@ export default defineComponent({ Math.max(scanProgressItems.value.length - 1, 0), ); const scanQrcodePlaceholderText = computed(() => { + if (scanState.captchaUrl) { + return '等待安全验证'; + } if ( scanState.mode === 'refresh' && scanState.errorMessage?.includes('正在尝试快速登录') @@ -395,6 +426,7 @@ export default defineComponent({ result: QqbotApi.AccountScanResult, options: { reloadQrcode?: boolean } = {}, ) { + scanState.captchaUrl = result.captchaUrl; scanState.containerId = result.containerId; scanState.containerName = result.containerName; 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() { if (scanTimer) return; scanTimer = window.setInterval(() => { @@ -521,6 +584,7 @@ export default defineComponent({ stopScanPolling(); stopScanEvents(); Object.assign(scanState, { + captchaUrl: undefined, containerId: undefined, containerName: undefined, errorMessage: undefined, @@ -965,6 +1029,28 @@ export default defineComponent({ type="info" /> ) : null} + {scanState.captchaUrl ? ( + + + 请在当前页面完成腾讯安全验证,验证结果会自动提交到对应 + NapCat 容器。 + + + 完成安全验证 + + + } + message="QQ 密码登录需要安全验证" + showIcon + type="warning" + /> + ) : null} {scanProgressItems.value.length > 0 ? ( > { + 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, + ) => { + 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((resolve, reject) => { + const existing = document.querySelector( + `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 = {}; + 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; + } +}