From d2af27dd9a19b5af525bf8c8c424e74bd479897f Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 4 Jun 2026 11:44:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=20QQBot=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=99=BB=E5=BD=95=E8=BF=9B=E5=BA=A6=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antdv-next/src/api/qqbot/index.ts | 24 +++++ .../src/views/qqbot/account/list.tsx | 100 +++++++++++++++++- 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/apps/web-antdv-next/src/api/qqbot/index.ts b/apps/web-antdv-next/src/api/qqbot/index.ts index 5c9ae36..a2debf9 100644 --- a/apps/web-antdv-next/src/api/qqbot/index.ts +++ b/apps/web-antdv-next/src/api/qqbot/index.ts @@ -84,6 +84,14 @@ export namespace QqbotApi { webuiPort?: null | number; } + export interface AccountScanEvent { + createdAt: number; + message: string; + result?: AccountScanResult; + status: 'error' | 'info' | 'processing' | 'success'; + step: string; + } + export interface Rule { cooldownMs: number; enabled: boolean; @@ -355,6 +363,22 @@ export function cancelQqbotAccountScan(sessionId: string) { ); } +export function getQqbotAccountScanEventsUrl(sessionId: string) { + return buildApiUrl( + `/qqbot/account/scan/events?sessionId=${encodeURIComponent(sessionId)}`, + ); +} + +function buildApiUrl(path: string) { + const baseUrl = requestClient.getBaseUrl() || ''; + if (!baseUrl) return path; + if (/^https?:\/\//i.test(path)) return path; + if (/^https?:\/\//i.test(baseUrl)) { + return new URL(path, baseUrl).toString(); + } + return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; +} + export function getQqbotRuleList(params: QqbotApi.Query) { return requestClient.get>( '/qqbot/rule/list', 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 d7e6e1b..bc90b4a 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/list.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/list.tsx @@ -14,7 +14,15 @@ import { Page, useVbenModal } from '@vben/common-ui'; import { Plus } from '@vben/icons'; import { useQRCode } from '@vueuse/integrations/useQRCode'; -import { Alert, Button, message, Space, Tag, Typography } from 'antdv-next'; +import { + Alert, + Button, + message, + Space, + Steps, + Tag, + Typography, +} from 'antdv-next'; import { useVbenForm } from '#/adapter/form'; import { @@ -22,6 +30,7 @@ import { createQqbotAccount, deleteQqbotAccount, getQqbotAccountList, + getQqbotAccountScanEventsUrl, getQqbotAccountScanStatus, kickQqbotAccount, refreshQqbotAccountScanQrcode, @@ -33,6 +42,7 @@ import { KtTable, useKtTable } from '#/components/ktTable'; 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; @@ -45,6 +55,7 @@ export default defineComponent({ const scanQrcodeImageFailed = ref(false); const scanQrcodeRevision = ref(0); const scanQrcodeText = ref(''); + const scanEvents = ref([]); const scanState = reactive<{ containerId?: string; containerName?: string; @@ -80,7 +91,19 @@ export default defineComponent({ } return qrcode; }); + const scanProgressItems = computed(() => + scanEvents.value.map((event) => ({ + description: formatEventTime(event.createdAt), + status: getScanStepStatus(event.status), + title: event.message, + })), + ); + const scanProgressCurrent = computed(() => + Math.max(scanProgressItems.value.length - 1, 0), + ); let scanTimer: number | undefined; + let scanEventSessionId = ''; + let scanEventSource: EventSource | undefined; const [AccountForm, accountFormApi] = useVbenForm({ commonConfig: { @@ -296,6 +319,7 @@ export default defineComponent({ }); onBeforeUnmount(() => { stopScanPolling(); + stopScanEvents(); }); async function openScanCreate() { @@ -362,9 +386,11 @@ export default defineComponent({ if (result.status === 'pending') { startScanPolling(); + startScanEvents(result.sessionId); return; } stopScanPolling(); + stopScanEvents(); if (result.status === 'success') { message.success( result.selfId ? `账号 ${result.selfId} 登录态已更新` : '账号已更新', @@ -412,8 +438,57 @@ export default defineComponent({ 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 QqbotApi.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: event.step === 'qrcode-ready', + }); + } + } catch { + // Ignore malformed SSE chunks and wait for the next event. + } + } + function resetScanState(mode: 'create' | 'refresh') { stopScanPolling(); + stopScanEvents(); Object.assign(scanState, { containerId: undefined, containerName: undefined, @@ -428,11 +503,13 @@ export default defineComponent({ 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); } @@ -469,6 +546,19 @@ export default defineComponent({ return '扫码登录请求失败'; } + function getScanStepStatus(status: QqbotApi.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) || @@ -779,6 +869,14 @@ export default defineComponent({ type="info" /> ) : null} + {scanProgressItems.value.length > 0 ? ( + + ) : null}
{scanQrcodeText.value ? (