diff --git a/apps/web-antdv-next/package.json b/apps/web-antdv-next/package.json index 22cc3bb..5be5d7b 100644 --- a/apps/web-antdv-next/package.json +++ b/apps/web-antdv-next/package.json @@ -45,10 +45,12 @@ "@vben/types": "workspace:*", "@vben/utils": "workspace:*", "@vueuse/core": "catalog:", + "@vueuse/integrations": "catalog:", "antdv-next": "catalog:", "dayjs": "catalog:", "json-bigint": "catalog:", "pinia": "catalog:", + "qrcode": "catalog:", "vue": "catalog:", "vue-router": "catalog:" }, diff --git a/apps/web-antdv-next/src/api/qqbot/index.ts b/apps/web-antdv-next/src/api/qqbot/index.ts index 69b54e9..1825768 100644 --- a/apps/web-antdv-next/src/api/qqbot/index.ts +++ b/apps/web-antdv-next/src/api/qqbot/index.ts @@ -55,6 +55,20 @@ export namespace QqbotApi { selfId: string; } + export interface AccountScanResult { + accountId?: string; + containerId?: string; + containerName?: string; + errorMessage?: string; + expiresAt?: number; + mode: 'create' | 'refresh'; + qrcode?: string; + selfId?: string; + sessionId?: string; + status: 'error' | 'expired' | 'pending' | 'success'; + webuiPort?: null | number; + } + export interface Rule { cooldownMs: number; enabled: boolean; @@ -174,6 +188,37 @@ export function kickQqbotAccount(selfId: string) { ); } +export function startQqbotAccountScanCreate() { + return requestClient.post( + '/qqbot/account/scan/create', + ); +} + +export function startQqbotAccountScanRefresh(id: string) { + return requestClient.post( + `/qqbot/account/scan/refresh?id=${id}`, + ); +} + +export function getQqbotAccountScanStatus(sessionId: string) { + return requestClient.get( + '/qqbot/account/scan/status', + { params: { sessionId } }, + ); +} + +export function refreshQqbotAccountScanQrcode(sessionId: string) { + return requestClient.post( + `/qqbot/account/scan/qrcode/refresh?sessionId=${sessionId}`, + ); +} + +export function cancelQqbotAccountScan(sessionId: string) { + return requestClient.post( + `/qqbot/account/scan/cancel?sessionId=${sessionId}`, + ); +} + 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 45b023d..6b6642f 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/list.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/list.tsx @@ -7,26 +7,46 @@ import type { KtTableRowAction, } from '#/components/ktTable'; -import { computed, defineComponent, reactive, ref } from 'vue'; +import { computed, defineComponent, onBeforeUnmount, reactive, ref } from 'vue'; import { Page } from '@vben/common-ui'; import { Plus } from '@vben/icons'; -import { Form, FormItem, Input, message, Modal, Switch, Tag } from 'antdv-next'; +import { useQRCode } from '@vueuse/integrations/useQRCode'; +import { + Alert, + Button, + Form, + FormItem, + Input, + message, + Modal, + Space, + Switch, + Tag, + Typography, +} from 'antdv-next'; import { + cancelQqbotAccountScan, createQqbotAccount, deleteQqbotAccount, getQqbotAccountList, + getQqbotAccountScanStatus, kickQqbotAccount, + refreshQqbotAccountScanQrcode, + startQqbotAccountScanCreate, + startQqbotAccountScanRefresh, updateQqbotAccount, } from '#/api/qqbot'; import { KtTable, useKtTable } from '#/components/ktTable'; const AKtTable = KtTable as any; +const AButton = Button as any; const AInput = Input as any; const AModal = Modal as any; const ASwitch = Switch as any; +const ATypographyLink = Typography.Link as any; export default defineComponent({ name: 'QqBotAccountList', @@ -34,6 +54,29 @@ export default defineComponent({ const saving = ref(false); const modalOpen = ref(false); const editingId = ref(); + const scanLoading = ref(false); + const scanModalOpen = ref(false); + const scanQrcodeText = ref(''); + const scanState = reactive<{ + containerId?: string; + containerName?: string; + errorMessage?: string; + expiresAt?: number; + mode: 'create' | 'refresh'; + 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, + }); + let scanTimer: number | undefined; const form = reactive({ accessToken: '', connectionMode: 'reverse-ws', @@ -78,14 +121,26 @@ export default defineComponent({ const buttons: Array> = [ { icon: , - key: 'create', - label: '新建账号', - onClick: openCreate, + key: 'scanCreate', + label: '扫码新增账号', + onClick: openScanCreate, permissionCodes: ['QqBot:Account:Create'], type: 'primary', }, + { + key: 'manualCreate', + label: '手动维护', + onClick: openCreate, + permissionCodes: ['QqBot:Account:Create'], + }, ]; const rowActions: Array> = [ + { + key: 'refreshLogin', + label: '更新登录', + onClick: openScanRefresh, + permissionCodes: ['QqBot:Account:RefreshLogin'], + }, { disabled: (row) => row.connectStatus !== 'online', key: 'kick', @@ -154,6 +209,162 @@ export default defineComponent({ const modalTitle = computed(() => editingId.value ? '编辑账号' : '新建账号', ); + const scanTitle = computed(() => + scanState.mode === 'refresh' ? '更新账号登录' : '扫码新增账号', + ); + + onBeforeUnmount(() => { + stopScanPolling(); + }); + + async function openScanCreate() { + await startScan('create'); + } + + async function openScanRefresh(row: QqbotApi.Account) { + await startScan('refresh', row); + } + + async function startScan( + mode: 'create' | 'refresh', + row?: QqbotApi.Account, + ) { + resetScanState(mode); + scanModalOpen.value = true; + scanLoading.value = true; + try { + if (mode === 'create') { + await applyScanResult(await startQqbotAccountScanCreate()); + return; + } + if (!row) { + message.warning('请选择需要更新登录的账号'); + return; + } + await applyScanResult(await startQqbotAccountScanRefresh(row.id)); + } catch (error) { + stopScanPolling(); + scanState.status = 'error'; + scanState.errorMessage = getErrorMessage(error); + } finally { + scanLoading.value = false; + } + } + + async function applyScanResult(result: QqbotApi.AccountScanResult) { + scanState.containerId = result.containerId; + scanState.containerName = result.containerName; + scanState.errorMessage = result.errorMessage; + scanState.expiresAt = result.expiresAt; + scanState.mode = result.mode; + scanState.selfId = result.selfId; + scanState.sessionId = result.sessionId; + scanState.status = result.status; + scanState.webuiPort = result.webuiPort; + scanQrcodeText.value = result.qrcode || ''; + + if (result.status === 'pending') { + startScanPolling(); + return; + } + stopScanPolling(); + if (result.status === 'success') { + message.success( + result.selfId ? `账号 ${result.selfId} 登录态已更新` : '账号已更新', + ); + scanModalOpen.value = false; + 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), + ); + } 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 resetScanState(mode: 'create' | 'refresh') { + stopScanPolling(); + Object.assign(scanState, { + containerId: undefined, + containerName: undefined, + errorMessage: undefined, + expiresAt: undefined, + mode, + selfId: undefined, + sessionId: undefined, + status: 'idle', + webuiPort: undefined, + }); + scanQrcodeText.value = ''; + } + + function closeScanModal() { + const sessionId = scanState.sessionId; + stopScanPolling(); + scanModalOpen.value = false; + if (sessionId && scanState.status === 'pending') { + void cancelQqbotAccountScan(sessionId); + } + } + + function getScanAlertType() { + if (scanState.status === 'success') return 'success'; + if (scanState.status === 'error') return 'error'; + if (scanState.status === 'expired') return 'warning'; + return 'info'; + } + + 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 openCreate() { editingId.value = undefined; @@ -229,6 +440,97 @@ export default defineComponent({ }, }} /> + + 关闭 + , + + 刷新二维码 + , + + 检查状态 + , + ]} + onCancel={closeScanModal} + {...{ + 'onUpdate:open': (value: boolean) => { + if (value) { + scanModalOpen.value = value; + return; + } + closeScanModal(); + }, + }} + open={scanModalOpen.value} + title={scanTitle.value} + width="520px" + > + + + {scanState.containerName ? ( + + ) : null} +
+ {scanQrcodeText.value ? ( + qqbot-login-qrcode + ) : ( +
+ 二维码生成中 +
+ )} +
+ {scanQrcodeText.value ? ( + + 打开扫码链接 + + ) : null} +
+