diff --git a/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts b/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts index 4cc14ff..d4776f9 100644 --- a/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts +++ b/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts @@ -4,15 +4,18 @@ import { requestClient } from '#/api/request'; import { cancelQqbotAccountScan, + createQqbotNapcatWebuiSession, getNapcatLoginProgressLabel, getNapcatNewDeviceStatusMessage, getQqbotAccountScanEventsUrl, getQqbotAccountScanStatus, getQqbotNapcatRuntimeDetail, + heartbeatQqbotNapcatWebuiSession, mergeNapcatAccountScanResult, NAPCAT_LOGIN_PROGRESS_LABELS, refreshQqbotAccountScanQrcode, resolveNapcatLoginDisplayQrcode, + revokeQqbotNapcatWebuiSession, startQqbotAccountScanCreate, startQqbotAccountScanRefresh, submitQqbotAccountScanCaptcha, @@ -234,4 +237,52 @@ describe('napcat login display helpers', () => { }, ); }); + + it('owns NapCat WebUI gateway session caller routes', async () => { + const sessionResult = { + account: { + id: 'account-1', + name: 'preview', + selfId: '10001', + }, + container: { + id: 'container-1', + name: 'kt-qqbot-napcat-10001', + webuiStatus: 'online' as const, + }, + expiresAt: 1_782_000_000, + iframeUrl: '/qqbot/napcat/webui/session/session-1/', + sessionId: 'session-1', + }; + const lifecycleResult = { + sessionId: 'session-1', + status: 'active' as const, + }; + vi.mocked(requestClient.post) + .mockResolvedValueOnce(sessionResult) + .mockResolvedValueOnce(lifecycleResult) + .mockResolvedValueOnce({ ...lifecycleResult, status: 'revoked' }); + + await expect( + createQqbotNapcatWebuiSession({ accountId: 'account-1' }), + ).resolves.toBe(sessionResult); + await expect(heartbeatQqbotNapcatWebuiSession('session-1')).resolves.toBe( + lifecycleResult, + ); + await expect(revokeQqbotNapcatWebuiSession('session-1')).resolves.toEqual({ + sessionId: 'session-1', + status: 'revoked', + }); + + expect(requestClient.post).toHaveBeenCalledWith( + '/qqbot/napcat/webui/session', + { accountId: 'account-1' }, + ); + expect(requestClient.post).toHaveBeenCalledWith( + '/qqbot/napcat/webui/session/session-1/heartbeat', + ); + expect(requestClient.post).toHaveBeenCalledWith( + '/qqbot/napcat/webui/session/session-1/revoke', + ); + }); }); diff --git a/apps/web-antdv-next/src/api/qqbot/napcat.ts b/apps/web-antdv-next/src/api/qqbot/napcat.ts index 237007e..138d8d4 100644 --- a/apps/web-antdv-next/src/api/qqbot/napcat.ts +++ b/apps/web-antdv-next/src/api/qqbot/napcat.ts @@ -24,6 +24,36 @@ export namespace QqbotNapcatApi { sessionBehaviorProfile?: Record; } + export interface WebuiGatewaySessionAccount { + id: string; + name: string; + selfId: string; + } + + export interface WebuiGatewaySessionContainer { + id: string; + name: string; + webuiStatus: 'offline' | 'online' | 'unknown'; + } + + export interface WebuiGatewaySession { + account: WebuiGatewaySessionAccount; + container: WebuiGatewaySessionContainer; + expiresAt: number; + iframeUrl: string; + sessionId: string; + } + + export interface WebuiGatewaySessionCreateBody { + accountId: string; + } + + export interface WebuiGatewayLifecycleResult { + expiresAt?: number; + sessionId: string; + status: 'active' | 'revoked'; + } + export interface AccountScanResult { accountId?: string; captchaUrl?: string; @@ -218,6 +248,36 @@ export function getQqbotNapcatRuntimeDetail(accountId: string) { ); } +/** + * Creates a short-lived gateway session for opening one account's NapCat WebUI. + */ +export function createQqbotNapcatWebuiSession( + data: QqbotNapcatApi.WebuiGatewaySessionCreateBody, +) { + return requestClient.post( + '/qqbot/napcat/webui/session', + data, + ); +} + +/** + * Extends an active NapCat WebUI gateway session while the page is alive. + */ +export function heartbeatQqbotNapcatWebuiSession(sessionId: string) { + return requestClient.post( + `/qqbot/napcat/webui/session/${sessionId}/heartbeat`, + ); +} + +/** + * Revokes a NapCat WebUI gateway session when the page leaves the WebUI view. + */ +export function revokeQqbotNapcatWebuiSession(sessionId: string) { + return requestClient.post( + `/qqbot/napcat/webui/session/${sessionId}/revoke`, + ); +} + function buildApiUrl(path: string) { const baseUrl = requestClient.getBaseUrl() || ''; if (!baseUrl) return path; diff --git a/apps/web-antdv-next/src/router/routes/modules/qqbot.ts b/apps/web-antdv-next/src/router/routes/modules/qqbot.ts index 2e05613..0bb2a03 100644 --- a/apps/web-antdv-next/src/router/routes/modules/qqbot.ts +++ b/apps/web-antdv-next/src/router/routes/modules/qqbot.ts @@ -39,6 +39,16 @@ const routes: RouteRecordRaw[] = [ name: 'QqBotAccountConfig', path: '/qqbot/account/config', }, + { + component: () => import('#/views/qqbot/account/napcat-webui'), + meta: { + activePath: '/qqbot/account', + hideInMenu: true, + title: 'NapCat WebUI', + }, + name: 'QqBotAccountNapcatWebui', + path: '/qqbot/account/:accountId/napcat-webui', + }, { component: () => import('#/views/qqbot/rule/list'), meta: { 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 e5c4dd3..dbfa05b 100644 --- a/apps/web-antdv-next/src/views/qqbot/account/list.tsx +++ b/apps/web-antdv-next/src/views/qqbot/account/list.tsx @@ -173,6 +173,14 @@ export default defineComponent({ onClick: openRuntimeProfile, permissionCodes: ['QqBot:Account:Config'], }, + { + disabled: (row) => + !row.napcat?.containerName || getWebuiStatus(row) === 'offline', + key: 'napcatWebui', + label: 'WebUI', + onClick: openNapcatWebui, + permissionCodes: ['QqBot:Account:WebUI'], + }, { confirm: (row) => `确认删除账号「${row.selfId}」吗?该操作会同时删除该账号专属的 NapCat 容器。`, @@ -521,6 +529,16 @@ export default defineComponent({ }); } + /** + * Opens the route that will own the NapCat WebUI session lifecycle. + */ + function openNapcatWebui(row: QqbotApi.Account) { + void router.push({ + name: 'QqBotAccountNapcatWebui', + params: { accountId: row.id }, + }); + } + function openEdit(row: QqbotApi.Account) { editingId.value = row.id; accountModalApi 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 index d559b61..d9b4038 100644 --- 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 @@ -12,6 +12,19 @@ const accountRoot = resolve( const readAccountSource = (relativePath: string) => readFileSync(resolve(accountRoot, relativePath), 'utf8'); +/** + * Reads QQBot router module source for route boundary assertions. + */ +const readRouteSource = (relativePath: string) => + readFileSync( + resolve( + cwd(), + 'apps/web-antdv-next/src/router/routes/modules', + relativePath, + ), + 'utf8', + ); + describe('qqbot account NapCat login view boundary', () => { it('keeps login session state out of account/list.tsx', () => { const source = readAccountSource('list.tsx'); @@ -37,4 +50,25 @@ describe('qqbot account NapCat login view boundary', () => { ); expect(existsSync(resolve(accountRoot, 'napcat/qrcode.ts'))).toBe(true); }); + + it('keeps WebUI gateway lifecycle logic out of account/list.tsx', () => { + const source = readAccountSource('list.tsx'); + + expect(source).toContain('QqBotAccountNapcatWebui'); + expect(source).toContain('QqBot:Account:WebUI'); + expect(source).not.toContain('createQqbotNapcatWebuiSession'); + expect(source).not.toContain('heartbeatQqbotNapcatWebuiSession'); + expect(source).not.toContain('revokeQqbotNapcatWebuiSession'); + expect(source).not.toContain('