feat: 接入 QQBot 更新登录进度展示

This commit is contained in:
sunlei 2026-06-04 11:44:37 +08:00
parent dea3306b41
commit d2af27dd9a
2 changed files with 123 additions and 1 deletions

View File

@ -84,6 +84,14 @@ export namespace QqbotApi {
webuiPort?: null | number; webuiPort?: null | number;
} }
export interface AccountScanEvent {
createdAt: number;
message: string;
result?: AccountScanResult;
status: 'error' | 'info' | 'processing' | 'success';
step: string;
}
export interface Rule { export interface Rule {
cooldownMs: number; cooldownMs: number;
enabled: boolean; 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) { export function getQqbotRuleList(params: QqbotApi.Query) {
return requestClient.get<QqbotApi.PageResult<QqbotApi.Rule>>( return requestClient.get<QqbotApi.PageResult<QqbotApi.Rule>>(
'/qqbot/rule/list', '/qqbot/rule/list',

View File

@ -14,7 +14,15 @@ import { Page, useVbenModal } from '@vben/common-ui';
import { Plus } from '@vben/icons'; import { Plus } from '@vben/icons';
import { useQRCode } from '@vueuse/integrations/useQRCode'; 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 { useVbenForm } from '#/adapter/form';
import { import {
@ -22,6 +30,7 @@ import {
createQqbotAccount, createQqbotAccount,
deleteQqbotAccount, deleteQqbotAccount,
getQqbotAccountList, getQqbotAccountList,
getQqbotAccountScanEventsUrl,
getQqbotAccountScanStatus, getQqbotAccountScanStatus,
kickQqbotAccount, kickQqbotAccount,
refreshQqbotAccountScanQrcode, refreshQqbotAccountScanQrcode,
@ -33,6 +42,7 @@ import { KtTable, useKtTable } from '#/components/ktTable';
const AKtTable = KtTable as any; const AKtTable = KtTable as any;
const AButton = Button as any; const AButton = Button as any;
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;
@ -45,6 +55,7 @@ export default defineComponent({
const scanQrcodeImageFailed = ref(false); const scanQrcodeImageFailed = ref(false);
const scanQrcodeRevision = ref(0); const scanQrcodeRevision = ref(0);
const scanQrcodeText = ref(''); const scanQrcodeText = ref('');
const scanEvents = ref<QqbotApi.AccountScanEvent[]>([]);
const scanState = reactive<{ const scanState = reactive<{
containerId?: string; containerId?: string;
containerName?: string; containerName?: string;
@ -80,7 +91,19 @@ export default defineComponent({
} }
return qrcode; 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 scanTimer: number | undefined;
let scanEventSessionId = '';
let scanEventSource: EventSource | undefined;
const [AccountForm, accountFormApi] = useVbenForm({ const [AccountForm, accountFormApi] = useVbenForm({
commonConfig: { commonConfig: {
@ -296,6 +319,7 @@ export default defineComponent({
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopScanPolling(); stopScanPolling();
stopScanEvents();
}); });
async function openScanCreate() { async function openScanCreate() {
@ -362,9 +386,11 @@ export default defineComponent({
if (result.status === 'pending') { if (result.status === 'pending') {
startScanPolling(); startScanPolling();
startScanEvents(result.sessionId);
return; return;
} }
stopScanPolling(); stopScanPolling();
stopScanEvents();
if (result.status === 'success') { if (result.status === 'success') {
message.success( message.success(
result.selfId ? `账号 ${result.selfId} 登录态已更新` : '账号已更新', result.selfId ? `账号 ${result.selfId} 登录态已更新` : '账号已更新',
@ -412,8 +438,57 @@ export default defineComponent({
scanTimer = undefined; 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') { function resetScanState(mode: 'create' | 'refresh') {
stopScanPolling(); stopScanPolling();
stopScanEvents();
Object.assign(scanState, { Object.assign(scanState, {
containerId: undefined, containerId: undefined,
containerName: undefined, containerName: undefined,
@ -428,11 +503,13 @@ export default defineComponent({
scanQrcodeImageFailed.value = false; scanQrcodeImageFailed.value = false;
scanQrcodeRevision.value = 0; scanQrcodeRevision.value = 0;
scanQrcodeText.value = ''; scanQrcodeText.value = '';
scanEvents.value = [];
} }
function cleanupScanSession() { function cleanupScanSession() {
const sessionId = scanState.sessionId; const sessionId = scanState.sessionId;
stopScanPolling(); stopScanPolling();
stopScanEvents();
if (sessionId && scanState.status === 'pending') { if (sessionId && scanState.status === 'pending') {
void cancelQqbotAccountScan(sessionId); void cancelQqbotAccountScan(sessionId);
} }
@ -469,6 +546,19 @@ export default defineComponent({
return '扫码登录请求失败'; 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) { function isQrcodeImageCandidate(value: string) {
return ( return (
/^data:image\//i.test(value) || /^data:image\//i.test(value) ||
@ -779,6 +869,14 @@ export default defineComponent({
type="info" type="info"
/> />
) : null} ) : null}
{scanProgressItems.value.length > 0 ? (
<ASteps
current={scanProgressCurrent.value}
direction="vertical"
items={scanProgressItems.value}
size="small"
/>
) : null}
<div style={{ display: 'flex', justifyContent: 'center' }}> <div style={{ display: 'flex', justifyContent: 'center' }}>
{scanQrcodeText.value ? ( {scanQrcodeText.value ? (
<img <img