From c95ac9df9ed1a49891809b83f51dc1648d65ed72 Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 16 Jun 2026 13:00:19 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E6=95=9BQQBot=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=99=BB=E5=BD=95=E9=A1=B5=E9=9D=A2=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/views/qqbot/account/list.tsx | 773 +----------------- .../qqbot/account/napcat-boundary.spec.ts | 40 + .../qqbot/account/napcat/NapcatLoginModal.tsx | 207 +++++ .../src/views/qqbot/account/napcat/qrcode.ts | 54 ++ .../qqbot/account/napcat/tencentCaptcha.ts | 170 ++++ .../account/napcat/useNapcatLoginSession.ts | 427 ++++++++++ 6 files changed, 912 insertions(+), 759 deletions(-) create mode 100644 apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts create mode 100644 apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatLoginModal.tsx create mode 100644 apps/web-antdv-next/src/views/qqbot/account/napcat/qrcode.ts create mode 100644 apps/web-antdv-next/src/views/qqbot/account/napcat/tencentCaptcha.ts create mode 100644 apps/web-antdv-next/src/views/qqbot/account/napcat/useNapcatLoginSession.ts 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 c718102..c845b36 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/list.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/list.tsx @@ -1,32 +1,21 @@ import type { TableColumnType } from 'antdv-next'; +import type { NapcatLoginModalExposed } from './napcat/NapcatLoginModal'; + import type { QqbotApi } from '#/api/qqbot'; -import type { - NapcatLoginNewDeviceStatus, - QqbotNapcatApi, -} from '#/api/qqbot/napcat'; import type { KtTableApi, KtTableButton, KtTableRowAction, } from '#/components/ktTable'; -import { computed, defineComponent, onBeforeUnmount, reactive, ref } from 'vue'; +import { computed, defineComponent, ref } from 'vue'; import { useRouter } from 'vue-router'; import { Page, useVbenModal } from '@vben/common-ui'; import { Plus } from '@vben/icons'; -import { useQRCode } from '@vueuse/integrations/useQRCode'; -import { - Alert, - Button, - message, - Space, - Steps, - Tag, - Typography, -} from 'antdv-next'; +import { message, Space, Tag, Typography } from 'antdv-next'; import { useVbenForm } from '#/adapter/form'; import { @@ -36,136 +25,19 @@ import { kickQqbotAccount, updateQqbotAccount, } from '#/api/qqbot'; -import { - cancelQqbotAccountScan, - getNapcatLoginProgressLabel, - getNapcatNewDeviceStatusMessage, - getQqbotAccountScanEventsUrl, - getQqbotAccountScanStatus, - mergeNapcatAccountScanResult, - refreshQqbotAccountScanQrcode, - resolveNapcatLoginDisplayQrcode, - startQqbotAccountScanCreate, - startQqbotAccountScanRefresh, - submitQqbotAccountScanCaptcha, -} from '#/api/qqbot/napcat'; import { KtTable, useKtTable } from '#/components/ktTable'; +import NapcatLoginModal from './napcat/NapcatLoginModal'; + const AKtTable = KtTable as any; -const AButton = Button as any; -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() { const editingId = ref(); + const napcatLoginRef = ref(); const router = useRouter(); - const scanLoading = ref(false); - const scanQrcodeImageFailed = ref(false); - const scanQrcodeRevision = ref(0); - const scanQrcodeText = ref(''); - const scanEvents = ref([]); - const scanState = reactive<{ - captchaUrl?: string; - containerId?: string; - containerName?: string; - deviceVerifyUrl?: string; - errorMessage?: string; - expiresAt?: number; - mode: 'create' | 'refresh'; - newDeviceQrcode?: string; - newDeviceStatus?: NapcatLoginNewDeviceStatus; - selfId?: string; - sessionId?: string; - status: 'error' | 'expired' | 'idle' | 'pending' | 'success'; - webuiPort?: null | number; - }>({ - mode: 'create', - status: 'idle', - }); - const scanQrcode = useQRCode(scanQrcodeText, { - errorCorrectionLevel: 'H', - margin: 2, - scale: 8, - }); - const scanQrcodeImageSrc = computed(() => { - const qrcode = scanQrcodeText.value.trim(); - if (!qrcode) return ''; - if (!scanQrcodeImageFailed.value && isQrcodeImageCandidate(qrcode)) { - return normalizeQrcodeImageSrc(qrcode, scanQrcodeRevision.value); - } - return scanQrcode.value; - }); - const scanQrcodeOpenHref = computed(() => { - const qrcode = scanQrcodeText.value.trim(); - if (!qrcode) return ''; - if (isQrcodeImageCandidate(qrcode)) { - return normalizeQrcodeImageSrc(qrcode, scanQrcodeRevision.value); - } - return qrcode; - }); - const scanProgressItems = computed(() => - scanEvents.value.map((event) => ({ - description: formatEventTime(event.createdAt), - status: getScanStepStatus(event.status), - title: getNapcatLoginProgressLabel(event), - })), - ); - const scanProgressCurrent = computed(() => - Math.max(scanProgressItems.value.length - 1, 0), - ); - const scanQrcodePlaceholderText = computed(() => { - if (scanState.newDeviceStatus) { - return getNapcatNewDeviceStatusMessage(scanState.newDeviceStatus); - } - if (scanState.captchaUrl) { - return '等待安全验证'; - } - if ( - scanState.mode === 'refresh' && - scanState.errorMessage?.includes('正在尝试快速登录') - ) { - return '正在尝试快速登录'; - } - if ( - scanState.mode === 'refresh' && - scanState.errorMessage?.includes('正在尝试密码登录') - ) { - return '正在尝试密码登录'; - } - return '二维码生成中'; - }); - let scanTimer: number | undefined; - let scanEventSessionId = ''; - let scanEventSource: EventSource | undefined; const [AccountForm, accountFormApi] = useVbenForm({ commonConfig: { @@ -365,21 +237,6 @@ export default defineComponent({ const modalTitle = computed(() => editingId.value ? '编辑账号' : '新建账号', ); - const scanTitle = computed(() => - scanState.mode === 'refresh' ? '更新账号登录' : '扫码新增账号', - ); - - const [ScanModal, scanModalApi] = useVbenModal({ - class: 'w-[520px]', - fullscreenButton: false, - onBeforeClose() { - cleanupScanSession(); - return true; - }, - onCancel() { - closeScanModal(); - }, - }); const [AccountModal, accountModalApi] = useVbenModal({ class: 'w-[620px]', @@ -395,334 +252,13 @@ export default defineComponent({ void resetAccountForm(values || getAccountFormDefaults()); }, }); - onBeforeUnmount(() => { - stopScanPolling(); - stopScanEvents(); - }); async function openScanCreate() { - await startScan('create'); + await napcatLoginRef.value?.openCreate(); } async function openScanRefresh(row: QqbotApi.Account) { - await startScan('refresh', row); - } - - async function startScan( - mode: 'create' | 'refresh', - row?: QqbotApi.Account, - ) { - resetScanState(mode); - scanModalApi.setState({ title: scanTitle.value }).open(); - scanLoading.value = true; - try { - if (mode === 'create') { - await applyScanResult(await startQqbotAccountScanCreate(), { - reloadQrcode: true, - }); - return; - } - if (!row) { - message.warning('请选择需要更新登录的账号'); - return; - } - await applyScanResult(await startQqbotAccountScanRefresh(row.id), { - reloadQrcode: true, - }); - } catch (error) { - stopScanPolling(); - scanState.status = 'error'; - scanState.errorMessage = getErrorMessage(error); - } finally { - scanLoading.value = false; - } - } - - async function applyScanResult( - result: QqbotNapcatApi.AccountScanResult, - options: { reloadQrcode?: boolean } = {}, - ) { - const nextState = mergeNapcatAccountScanResult(scanState, result); - scanState.captchaUrl = nextState.captchaUrl; - scanState.containerId = nextState.containerId; - scanState.containerName = nextState.containerName; - scanState.deviceVerifyUrl = nextState.deviceVerifyUrl; - scanState.errorMessage = nextState.errorMessage; - scanState.expiresAt = nextState.expiresAt; - scanState.mode = nextState.mode; - scanState.newDeviceQrcode = nextState.newDeviceQrcode; - scanState.newDeviceStatus = nextState.newDeviceStatus; - scanState.selfId = nextState.selfId; - scanState.sessionId = nextState.sessionId; - scanState.status = nextState.status; - scanState.webuiPort = nextState.webuiPort; - const nextQrcode = resolveNapcatLoginDisplayQrcode(nextState); - const qrcodeChanged = nextQrcode !== scanQrcodeText.value; - if (qrcodeChanged) { - scanQrcodeImageFailed.value = false; - } - scanQrcodeText.value = nextQrcode; - if (nextQrcode && (qrcodeChanged || options.reloadQrcode)) { - scanQrcodeRevision.value += 1; - scanQrcodeImageFailed.value = false; - } - - if (result.status === 'pending') { - startScanPolling(); - startScanEvents(result.sessionId); - return; - } - stopScanPolling(); - stopScanEvents(); - if (result.status === 'success') { - message.success( - result.selfId ? `账号 ${result.selfId} 登录态已更新` : '账号已更新', - ); - await scanModalApi.close(); - await tableApi.reload(); - } - } - - async function pollScanStatus() { - if (!scanState.sessionId || scanLoading.value) return; - scanLoading.value = true; - try { - await applyScanResult( - await getQqbotAccountScanStatus(scanState.sessionId), - ); - } finally { - scanLoading.value = false; - } - } - - async function refreshScanQrcode() { - if (!scanState.sessionId) return; - scanLoading.value = true; - try { - await applyScanResult( - await refreshQqbotAccountScanQrcode(scanState.sessionId), - { reloadQrcode: true }, - ); - } finally { - scanLoading.value = false; - } - } - - 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(() => { - void pollScanStatus(); - }, 2000); - } - - function stopScanPolling() { - if (!scanTimer) return; - window.clearInterval(scanTimer); - scanTimer = undefined; - } - - function startScanEvents(sessionId?: string) { - if (!sessionId || scanEventSessionId === sessionId) return; - stopScanEvents(); - scanEventSessionId = sessionId; - const source = new EventSource(getQqbotAccountScanEventsUrl(sessionId), { - withCredentials: true, - }); - scanEventSource = source; - source.addEventListener('message', (event) => { - handleScanEvent(event.data); - }); - source.addEventListener('error', () => { - stopScanEvents(); - }); - } - - function stopScanEvents() { - if (scanEventSource) { - scanEventSource.close(); - } - scanEventSource = undefined; - scanEventSessionId = ''; - } - - function handleScanEvent(payload: string) { - try { - const event = JSON.parse(payload) as QqbotNapcatApi.AccountScanEvent; - const index = scanEvents.value.findIndex( - (item) => item.step === event.step, - ); - if (index === -1) { - scanEvents.value.push(event); - } else { - scanEvents.value.splice(index, 1, event); - } - if (scanEvents.value.length > 20) { - scanEvents.value.splice(0, scanEvents.value.length - 20); - } - if (event.result) { - void applyScanResult(event.result, { - reloadQrcode: ['new-device-qrcode-ready', 'qrcode-ready'].includes( - event.step, - ), - }); - } - } catch { - // Ignore malformed SSE chunks and wait for the next event. - } - } - - function resetScanState(mode: 'create' | 'refresh') { - stopScanPolling(); - stopScanEvents(); - Object.assign(scanState, { - captchaUrl: undefined, - containerId: undefined, - containerName: undefined, - deviceVerifyUrl: undefined, - errorMessage: undefined, - expiresAt: undefined, - mode, - newDeviceQrcode: undefined, - newDeviceStatus: undefined, - selfId: undefined, - sessionId: undefined, - status: 'idle', - webuiPort: undefined, - }); - scanQrcodeImageFailed.value = false; - scanQrcodeRevision.value = 0; - scanQrcodeText.value = ''; - scanEvents.value = []; - } - - function cleanupScanSession() { - const sessionId = scanState.sessionId; - stopScanPolling(); - stopScanEvents(); - if (sessionId && scanState.status === 'pending') { - void cancelQqbotAccountScan(sessionId); - } - } - - function closeScanModal() { - void scanModalApi.close(); - } - - function getScanAlertType() { - if (scanState.status === 'success') return 'success'; - if (scanState.status === 'error') return 'error'; - if (scanState.status === 'expired') return 'warning'; - return 'info'; - } - - function getNewDeviceAlertType(status?: NapcatLoginNewDeviceStatus) { - if (status === 'failed') return 'error'; - if (status === 'verified') return 'success'; - return 'warning'; - } - - function getScanMessage() { - if (scanState.status === 'success') return '扫码登录成功'; - if (scanState.status === 'error') { - return scanState.errorMessage || '扫码登录失败'; - } - if (scanState.status === 'expired') return '二维码已过期,请刷新二维码'; - if (scanState.errorMessage) return scanState.errorMessage; - return '请使用目标 QQ 扫码登录,页面会自动轮询登录结果'; - } - - function getErrorMessage(error: unknown) { - if (error instanceof Error) return error.message; - if (typeof error === 'string') return error; - if (error && typeof error === 'object') { - const record = error as Record; - return `${record.msg || record.message || record.err || '扫码登录请求失败'}`; - } - return '扫码登录请求失败'; - } - - function getScanStepStatus( - status: QqbotNapcatApi.AccountScanEvent['status'], - ) { - if (status === 'error') return 'error'; - if (status === 'processing') return 'process'; - if (status === 'success') return 'finish'; - return 'wait'; - } - - function formatEventTime(value: number) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ''; - return date.toLocaleTimeString('zh-CN', { hour12: false }); - } - - function isQrcodeImageCandidate(value: string) { - return ( - /^data:image\//i.test(value) || - /^https?:\/\//i.test(value) || - isRawBase64Image(value) - ); - } - - function normalizeQrcodeImageSrc(value: string, revision = 0) { - if (isRawBase64Image(value)) { - return `data:image/png;base64,${value}`; - } - if (/^https?:\/\//i.test(value) && revision > 0) { - return appendQrcodeCacheBuster(value, revision); - } - return value; - } - - function appendQrcodeCacheBuster(value: string, revision: number) { - try { - const url = new URL(value); - url.searchParams.set('_kt_qrcode_v', `${revision}`); - return url.toString(); - } catch { - const joiner = value.includes('?') ? '&' : '?'; - return `${value}${joiner}_kt_qrcode_v=${revision}`; - } - } - - function isRawBase64Image(value: string) { - const normalized = value.trim(); - return ( - normalized.startsWith('iVBORw0KGgo') || - normalized.startsWith('/9j/') || - normalized.startsWith('R0lGOD') - ); + await napcatLoginRef.value?.openRefresh(row); } const renderAccountOnlineStatus = (row: QqbotApi.Account) => { @@ -1046,150 +582,12 @@ export default defineComponent({ }, }} /> - [ - - 关闭 - , - - 刷新二维码 - , - - 检查状态 - , - ], + { + void tableApi.reload(); }} - > - - - {scanState.containerName ? ( - - ) : null} - {scanState.newDeviceStatus ? ( - - - 请使用手机 QQ - 扫描下方新设备验证二维码,并在手机端确认登录。 - - {scanState.deviceVerifyUrl ? ( - - 打开新设备验证链接 - - ) : null} - - } - message={getNapcatNewDeviceStatusMessage( - scanState.newDeviceStatus, - )} - showIcon - type={getNewDeviceAlertType(scanState.newDeviceStatus)} - /> - ) : null} - {scanState.captchaUrl && !scanState.newDeviceStatus ? ( - - - 请在当前页面完成腾讯安全验证,验证结果会自动提交到对应 - NapCat 容器。 - - - 完成安全验证 - - - } - message="QQ 密码登录需要安全验证" - showIcon - type="warning" - /> - ) : null} - {scanProgressItems.value.length > 0 ? ( - - ) : null} -
- {scanQrcodeText.value ? ( - qqbot-login-qrcode { - if (isQrcodeImageCandidate(scanQrcodeText.value)) { - scanQrcodeImageFailed.value = true; - } - }} - src={scanQrcodeImageSrc.value} - style={{ - background: '#fff', - borderRadius: '8px', - height: '240px', - padding: '12px', - width: '240px', - }} - /> - ) : ( -
- {scanQrcodePlaceholderText.value} -
- )} -
- {scanQrcodeText.value ? ( - - 打开扫码链接 - - ) : null} - -
+ ref={napcatLoginRef as any} + /> @@ -1197,146 +595,3 @@ export default defineComponent({ ); }, }); - -function requestTencentCaptcha( - proofWaterUrl: string, -): Promise> { - 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; - } -} diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts b/apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts new file mode 100644 index 0000000..d559b61 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/napcat-boundary.spec.ts @@ -0,0 +1,40 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { cwd } from 'node:process'; + +import { describe, expect, it } from 'vitest'; + +const accountRoot = resolve( + cwd(), + 'apps/web-antdv-next/src/views/qqbot/account', +); + +const readAccountSource = (relativePath: string) => + readFileSync(resolve(accountRoot, relativePath), 'utf8'); + +describe('qqbot account NapCat login view boundary', () => { + it('keeps login session state out of account/list.tsx', () => { + const source = readAccountSource('list.tsx'); + + expect(source).toContain('NapcatLoginModal'); + expect(source).not.toContain('EventSource'); + expect(source).not.toContain('TencentCaptcha'); + expect(source).not.toContain('useQRCode'); + expect(source).not.toContain('/qqbot/account/scan'); + expect(source).not.toContain('startQqbotAccountScan'); + expect(source).not.toContain('submitQqbotAccountScanCaptcha'); + }); + + it('keeps NapCat login modal and session helpers in the napcat package', () => { + expect( + existsSync(resolve(accountRoot, 'napcat/NapcatLoginModal.tsx')), + ).toBe(true); + expect( + existsSync(resolve(accountRoot, 'napcat/useNapcatLoginSession.ts')), + ).toBe(true); + expect(existsSync(resolve(accountRoot, 'napcat/tencentCaptcha.ts'))).toBe( + true, + ); + expect(existsSync(resolve(accountRoot, 'napcat/qrcode.ts'))).toBe(true); + }); +}); diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatLoginModal.tsx b/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatLoginModal.tsx new file mode 100644 index 0000000..788c3db --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatLoginModal.tsx @@ -0,0 +1,207 @@ +import type { QqbotApi } from '#/api/qqbot'; + +import { defineComponent } from 'vue'; + +import { useVbenModal } from '@vben/common-ui'; + +import { Alert, Button, Space, Steps, Typography } from 'antdv-next'; + +import { getNapcatNewDeviceStatusMessage } from '#/api/qqbot/napcat'; + +import { useNapcatLoginSession } from './useNapcatLoginSession'; + +const AButton = Button as any; +const ASteps = Steps as any; +const ATypographyLink = Typography.Link as any; +const ATypographyText = Typography.Text as any; + +export type NapcatLoginModalExposed = { + openCreate: () => Promise; + openRefresh: (row: QqbotApi.Account) => Promise; +}; + +export default defineComponent({ + name: 'NapcatLoginModal', + emits: ['success'], + setup(_, { emit, expose }) { + let cleanupScanSession = () => {}; + let closeScanModal = () => {}; + const [ScanModal, scanModalApi] = useVbenModal({ + class: 'w-[520px]', + fullscreenButton: false, + onBeforeClose() { + cleanupScanSession(); + return true; + }, + onCancel() { + closeScanModal(); + }, + }); + const session = useNapcatLoginSession({ + closeModal: () => scanModalApi.close(), + onSuccess: () => { + emit('success'); + }, + openModal: (title) => { + scanModalApi.setState({ title }).open(); + }, + }); + cleanupScanSession = session.cleanupScanSession; + closeScanModal = session.closeScanModal; + + expose({ + openCreate: session.openCreate, + openRefresh: session.openRefresh, + } satisfies NapcatLoginModalExposed); + + return () => ( + [ + + 关闭 + , + + 刷新二维码 + , + + 检查状态 + , + ], + }} + > + + + {session.scanState.containerName ? ( + + ) : null} + {session.scanState.newDeviceStatus ? ( + + + 请使用手机 QQ 扫描下方新设备验证二维码,并在手机端确认登录。 + + {session.scanState.deviceVerifyUrl ? ( + + 打开新设备验证链接 + + ) : null} + + } + message={getNapcatNewDeviceStatusMessage( + session.scanState.newDeviceStatus, + )} + showIcon + type={ + session.getNewDeviceAlertType( + session.scanState.newDeviceStatus, + ) as any + } + /> + ) : null} + {session.scanState.captchaUrl && + !session.scanState.newDeviceStatus ? ( + + + 请在当前页面完成腾讯安全验证,验证结果会自动提交到对应 + NapCat 容器。 + + + 完成安全验证 + + + } + message="QQ 密码登录需要安全验证" + showIcon + type="warning" + /> + ) : null} + {session.scanProgressItems.value.length > 0 ? ( + + ) : null} +
+ {session.scanQrcodeText.value ? ( + qqbot-login-qrcode + ) : ( +
+ {session.scanQrcodePlaceholderText.value} +
+ )} +
+ {session.scanQrcodeText.value ? ( + + 打开扫码链接 + + ) : null} + +
+ ); + }, +}); diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat/qrcode.ts b/apps/web-antdv-next/src/views/qqbot/account/napcat/qrcode.ts new file mode 100644 index 0000000..8387f98 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/napcat/qrcode.ts @@ -0,0 +1,54 @@ +import type { QqbotNapcatApi } from '#/api/qqbot/napcat'; + +export function formatEventTime(value: number) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleTimeString('zh-CN', { hour12: false }); +} + +export function getScanStepStatus( + status: QqbotNapcatApi.AccountScanEvent['status'], +) { + if (status === 'error') return 'error'; + if (status === 'processing') return 'process'; + if (status === 'success') return 'finish'; + return 'wait'; +} + +export function isQrcodeImageCandidate(value: string) { + return ( + /^data:image\//i.test(value) || + /^https?:\/\//i.test(value) || + isRawBase64Image(value) + ); +} + +export function normalizeQrcodeImageSrc(value: string, revision = 0) { + if (isRawBase64Image(value)) { + return `data:image/png;base64,${value}`; + } + if (/^https?:\/\//i.test(value) && revision > 0) { + return appendQrcodeCacheBuster(value, revision); + } + return value; +} + +function appendQrcodeCacheBuster(value: string, revision: number) { + try { + const url = new URL(value); + url.searchParams.set('_kt_qrcode_v', `${revision}`); + return url.toString(); + } catch { + const joiner = value.includes('?') ? '&' : '?'; + return `${value}${joiner}_kt_qrcode_v=${revision}`; + } +} + +function isRawBase64Image(value: string) { + const normalized = value.trim(); + return ( + normalized.startsWith('iVBORw0KGgo') || + normalized.startsWith('/9j/') || + normalized.startsWith('R0lGOD') + ); +} diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat/tencentCaptcha.ts b/apps/web-antdv-next/src/views/qqbot/account/napcat/tencentCaptcha.ts new file mode 100644 index 0000000..a5321b9 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/napcat/tencentCaptcha.ts @@ -0,0 +1,170 @@ +import type { QqbotNapcatApi } from '#/api/qqbot/napcat'; + +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 function requestTencentCaptcha( + proofWaterUrl: string, +): Promise> { + 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; + } +} diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat/useNapcatLoginSession.ts b/apps/web-antdv-next/src/views/qqbot/account/napcat/useNapcatLoginSession.ts new file mode 100644 index 0000000..69367c0 --- /dev/null +++ b/apps/web-antdv-next/src/views/qqbot/account/napcat/useNapcatLoginSession.ts @@ -0,0 +1,427 @@ +import type { QqbotApi } from '#/api/qqbot'; +import type { + NapcatLoginNewDeviceStatus, + QqbotNapcatApi, +} from '#/api/qqbot/napcat'; + +import { computed, onBeforeUnmount, reactive, ref } from 'vue'; + +import { useQRCode } from '@vueuse/integrations/useQRCode'; +import { message } from 'antdv-next'; + +import { + cancelQqbotAccountScan, + getNapcatLoginProgressLabel, + getNapcatNewDeviceStatusMessage, + getQqbotAccountScanEventsUrl, + getQqbotAccountScanStatus, + mergeNapcatAccountScanResult, + refreshQqbotAccountScanQrcode, + resolveNapcatLoginDisplayQrcode, + startQqbotAccountScanCreate, + startQqbotAccountScanRefresh, + submitQqbotAccountScanCaptcha, +} from '#/api/qqbot/napcat'; + +import { + formatEventTime, + getScanStepStatus, + isQrcodeImageCandidate, + normalizeQrcodeImageSrc, +} from './qrcode'; +import { requestTencentCaptcha } from './tencentCaptcha'; + +type NapcatLoginSessionOptions = { + closeModal: () => Promise | unknown; + onSuccess?: () => Promise | unknown; + openModal: (title: string) => void; +}; + +export type NapcatLoginScanState = { + captchaUrl?: string; + containerId?: string; + containerName?: string; + deviceVerifyUrl?: string; + errorMessage?: string; + expiresAt?: number; + mode: 'create' | 'refresh'; + newDeviceQrcode?: string; + newDeviceStatus?: NapcatLoginNewDeviceStatus; + selfId?: string; + sessionId?: string; + status: 'error' | 'expired' | 'idle' | 'pending' | 'success'; + webuiPort?: null | number; +}; + +export function useNapcatLoginSession(options: NapcatLoginSessionOptions) { + const scanLoading = ref(false); + const scanQrcodeImageFailed = ref(false); + const scanQrcodeRevision = ref(0); + const scanQrcodeText = ref(''); + const scanEvents = ref([]); + const scanState = reactive({ + mode: 'create', + status: 'idle', + }); + const scanQrcode = useQRCode(scanQrcodeText, { + errorCorrectionLevel: 'H', + margin: 2, + scale: 8, + }); + const scanQrcodeImageSrc = computed(() => { + const qrcode = scanQrcodeText.value.trim(); + if (!qrcode) return ''; + if (!scanQrcodeImageFailed.value && isQrcodeImageCandidate(qrcode)) { + return normalizeQrcodeImageSrc(qrcode, scanQrcodeRevision.value); + } + return scanQrcode.value; + }); + const scanQrcodeOpenHref = computed(() => { + const qrcode = scanQrcodeText.value.trim(); + if (!qrcode) return ''; + if (isQrcodeImageCandidate(qrcode)) { + return normalizeQrcodeImageSrc(qrcode, scanQrcodeRevision.value); + } + return qrcode; + }); + const scanProgressItems = computed(() => + scanEvents.value.map((event) => ({ + description: formatEventTime(event.createdAt), + status: getScanStepStatus(event.status), + title: getNapcatLoginProgressLabel(event), + })), + ); + const scanProgressCurrent = computed(() => + Math.max(scanProgressItems.value.length - 1, 0), + ); + const scanQrcodePlaceholderText = computed(() => { + if (scanState.newDeviceStatus) { + return getNapcatNewDeviceStatusMessage(scanState.newDeviceStatus); + } + if (scanState.captchaUrl) { + return '等待安全验证'; + } + if ( + scanState.mode === 'refresh' && + scanState.errorMessage?.includes('正在尝试快速登录') + ) { + return '正在尝试快速登录'; + } + if ( + scanState.mode === 'refresh' && + scanState.errorMessage?.includes('正在尝试密码登录') + ) { + return '正在尝试密码登录'; + } + return '二维码生成中'; + }); + const scanTitle = computed(() => + scanState.mode === 'refresh' ? '更新账号登录' : '扫码新增账号', + ); + let scanTimer: number | undefined; + let scanEventSessionId = ''; + let scanEventSource: EventSource | undefined; + + onBeforeUnmount(() => { + stopScanPolling(); + stopScanEvents(); + }); + + async function openCreate() { + await startScan('create'); + } + + async function openRefresh(row: QqbotApi.Account) { + await startScan('refresh', row); + } + + async function startScan(mode: 'create' | 'refresh', row?: QqbotApi.Account) { + resetScanState(mode); + options.openModal(scanTitle.value); + scanLoading.value = true; + try { + if (mode === 'create') { + await applyScanResult(await startQqbotAccountScanCreate(), { + reloadQrcode: true, + }); + return; + } + if (!row) { + message.warning('请选择需要更新登录的账号'); + return; + } + await applyScanResult(await startQqbotAccountScanRefresh(row.id), { + reloadQrcode: true, + }); + } catch (error) { + stopScanPolling(); + scanState.status = 'error'; + scanState.errorMessage = getErrorMessage(error); + } finally { + scanLoading.value = false; + } + } + + async function applyScanResult( + result: QqbotNapcatApi.AccountScanResult, + applyOptions: { reloadQrcode?: boolean } = {}, + ) { + const nextState = mergeNapcatAccountScanResult(scanState, result); + scanState.captchaUrl = nextState.captchaUrl; + scanState.containerId = nextState.containerId; + scanState.containerName = nextState.containerName; + scanState.deviceVerifyUrl = nextState.deviceVerifyUrl; + scanState.errorMessage = nextState.errorMessage; + scanState.expiresAt = nextState.expiresAt; + scanState.mode = nextState.mode; + scanState.newDeviceQrcode = nextState.newDeviceQrcode; + scanState.newDeviceStatus = nextState.newDeviceStatus; + scanState.selfId = nextState.selfId; + scanState.sessionId = nextState.sessionId; + scanState.status = nextState.status; + scanState.webuiPort = nextState.webuiPort; + const nextQrcode = resolveNapcatLoginDisplayQrcode(nextState); + const qrcodeChanged = nextQrcode !== scanQrcodeText.value; + if (qrcodeChanged) { + scanQrcodeImageFailed.value = false; + } + scanQrcodeText.value = nextQrcode; + if (nextQrcode && (qrcodeChanged || applyOptions.reloadQrcode)) { + scanQrcodeRevision.value += 1; + scanQrcodeImageFailed.value = false; + } + + if (result.status === 'pending') { + startScanPolling(); + startScanEvents(result.sessionId); + return; + } + stopScanPolling(); + stopScanEvents(); + if (result.status === 'success') { + message.success( + result.selfId ? `账号 ${result.selfId} 登录态已更新` : '账号已更新', + ); + await options.closeModal(); + await options.onSuccess?.(); + } + } + + async function pollScanStatus() { + if (!scanState.sessionId || scanLoading.value) return; + scanLoading.value = true; + try { + await applyScanResult( + await getQqbotAccountScanStatus(scanState.sessionId), + ); + } finally { + scanLoading.value = false; + } + } + + async function refreshScanQrcode() { + if (!scanState.sessionId) return; + scanLoading.value = true; + try { + await applyScanResult( + await refreshQqbotAccountScanQrcode(scanState.sessionId), + { reloadQrcode: true }, + ); + } finally { + scanLoading.value = false; + } + } + + 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(() => { + void pollScanStatus(); + }, 2000); + } + + function stopScanPolling() { + if (!scanTimer) return; + window.clearInterval(scanTimer); + scanTimer = undefined; + } + + function startScanEvents(sessionId?: string) { + if (!sessionId || scanEventSessionId === sessionId) return; + stopScanEvents(); + scanEventSessionId = sessionId; + const source = new EventSource(getQqbotAccountScanEventsUrl(sessionId), { + withCredentials: true, + }); + scanEventSource = source; + source.addEventListener('message', (event) => { + handleScanEvent(event.data); + }); + source.addEventListener('error', () => { + stopScanEvents(); + }); + } + + function stopScanEvents() { + if (scanEventSource) { + scanEventSource.close(); + } + scanEventSource = undefined; + scanEventSessionId = ''; + } + + function handleScanEvent(payload: string) { + try { + const event = JSON.parse(payload) as QqbotNapcatApi.AccountScanEvent; + const index = scanEvents.value.findIndex( + (item) => item.step === event.step, + ); + if (index === -1) { + scanEvents.value.push(event); + } else { + scanEvents.value.splice(index, 1, event); + } + if (scanEvents.value.length > 20) { + scanEvents.value.splice(0, scanEvents.value.length - 20); + } + if (event.result) { + void applyScanResult(event.result, { + reloadQrcode: ['new-device-qrcode-ready', 'qrcode-ready'].includes( + event.step, + ), + }); + } + } catch { + // Ignore malformed SSE chunks and wait for the next event. + } + } + + function resetScanState(mode: 'create' | 'refresh') { + stopScanPolling(); + stopScanEvents(); + Object.assign(scanState, { + captchaUrl: undefined, + containerId: undefined, + containerName: undefined, + deviceVerifyUrl: undefined, + errorMessage: undefined, + expiresAt: undefined, + mode, + newDeviceQrcode: undefined, + newDeviceStatus: undefined, + selfId: undefined, + sessionId: undefined, + status: 'idle', + webuiPort: undefined, + }); + scanQrcodeImageFailed.value = false; + scanQrcodeRevision.value = 0; + scanQrcodeText.value = ''; + scanEvents.value = []; + } + + function cleanupScanSession() { + const sessionId = scanState.sessionId; + stopScanPolling(); + stopScanEvents(); + if (sessionId && scanState.status === 'pending') { + void cancelQqbotAccountScan(sessionId); + } + } + + function closeScanModal() { + void options.closeModal(); + } + + function getScanAlertType() { + if (scanState.status === 'success') return 'success'; + if (scanState.status === 'error') return 'error'; + if (scanState.status === 'expired') return 'warning'; + return 'info'; + } + + function getNewDeviceAlertType(status?: NapcatLoginNewDeviceStatus) { + if (status === 'failed') return 'error'; + if (status === 'verified') return 'success'; + return 'warning'; + } + + function getScanMessage() { + if (scanState.status === 'success') return '扫码登录成功'; + if (scanState.status === 'error') { + return scanState.errorMessage || '扫码登录失败'; + } + if (scanState.status === 'expired') return '二维码已过期,请刷新二维码'; + if (scanState.errorMessage) return scanState.errorMessage; + return '请使用目标 QQ 扫码登录,页面会自动轮询登录结果'; + } + + function onQrcodeImageError() { + if (isQrcodeImageCandidate(scanQrcodeText.value)) { + scanQrcodeImageFailed.value = true; + } + } + + return { + cleanupScanSession, + closeScanModal, + getNewDeviceAlertType, + getScanAlertType, + getScanMessage, + onQrcodeImageError, + openCreate, + openRefresh, + pollScanStatus, + refreshScanQrcode, + scanLoading, + scanProgressCurrent, + scanProgressItems, + scanQrcodeImageSrc, + scanQrcodeOpenHref, + scanQrcodePlaceholderText, + scanQrcodeText, + scanState, + scanTitle, + submitScanCaptcha, + }; +} + +function getErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (error && typeof error === 'object') { + const record = error as Record; + return `${record.msg || record.message || record.err || '扫码登录请求失败'}`; + } + return '扫码登录请求失败'; +}