feat: 新增 NapCat WebUI Admin 入口

This commit is contained in:
sunlei 2026-06-24 15:15:43 +08:00
parent d038501228
commit c2cbdf808a
5 changed files with 173 additions and 0 deletions

View File

@ -4,15 +4,18 @@ import { requestClient } from '#/api/request';
import { import {
cancelQqbotAccountScan, cancelQqbotAccountScan,
createQqbotNapcatWebuiSession,
getNapcatLoginProgressLabel, getNapcatLoginProgressLabel,
getNapcatNewDeviceStatusMessage, getNapcatNewDeviceStatusMessage,
getQqbotAccountScanEventsUrl, getQqbotAccountScanEventsUrl,
getQqbotAccountScanStatus, getQqbotAccountScanStatus,
getQqbotNapcatRuntimeDetail, getQqbotNapcatRuntimeDetail,
heartbeatQqbotNapcatWebuiSession,
mergeNapcatAccountScanResult, mergeNapcatAccountScanResult,
NAPCAT_LOGIN_PROGRESS_LABELS, NAPCAT_LOGIN_PROGRESS_LABELS,
refreshQqbotAccountScanQrcode, refreshQqbotAccountScanQrcode,
resolveNapcatLoginDisplayQrcode, resolveNapcatLoginDisplayQrcode,
revokeQqbotNapcatWebuiSession,
startQqbotAccountScanCreate, startQqbotAccountScanCreate,
startQqbotAccountScanRefresh, startQqbotAccountScanRefresh,
submitQqbotAccountScanCaptcha, 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',
);
});
}); });

View File

@ -24,6 +24,36 @@ export namespace QqbotNapcatApi {
sessionBehaviorProfile?: Record<string, unknown>; sessionBehaviorProfile?: Record<string, unknown>;
} }
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 { export interface AccountScanResult {
accountId?: string; accountId?: string;
captchaUrl?: 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<QqbotNapcatApi.WebuiGatewaySession>(
'/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<QqbotNapcatApi.WebuiGatewayLifecycleResult>(
`/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<QqbotNapcatApi.WebuiGatewayLifecycleResult>(
`/qqbot/napcat/webui/session/${sessionId}/revoke`,
);
}
function buildApiUrl(path: string) { function buildApiUrl(path: string) {
const baseUrl = requestClient.getBaseUrl() || ''; const baseUrl = requestClient.getBaseUrl() || '';
if (!baseUrl) return path; if (!baseUrl) return path;

View File

@ -39,6 +39,16 @@ const routes: RouteRecordRaw[] = [
name: 'QqBotAccountConfig', name: 'QqBotAccountConfig',
path: '/qqbot/account/config', 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'), component: () => import('#/views/qqbot/rule/list'),
meta: { meta: {

View File

@ -173,6 +173,14 @@ export default defineComponent({
onClick: openRuntimeProfile, onClick: openRuntimeProfile,
permissionCodes: ['QqBot:Account:Config'], permissionCodes: ['QqBot:Account:Config'],
}, },
{
disabled: (row) =>
!row.napcat?.containerName || getWebuiStatus(row) === 'offline',
key: 'napcatWebui',
label: 'WebUI',
onClick: openNapcatWebui,
permissionCodes: ['QqBot:Account:WebUI'],
},
{ {
confirm: (row) => confirm: (row) =>
`确认删除账号「${row.selfId}」吗?该操作会同时删除该账号专属的 NapCat 容器。`, `确认删除账号「${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) { function openEdit(row: QqbotApi.Account) {
editingId.value = row.id; editingId.value = row.id;
accountModalApi accountModalApi

View File

@ -12,6 +12,19 @@ const accountRoot = resolve(
const readAccountSource = (relativePath: string) => const readAccountSource = (relativePath: string) =>
readFileSync(resolve(accountRoot, relativePath), 'utf8'); 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', () => { describe('qqbot account NapCat login view boundary', () => {
it('keeps login session state out of account/list.tsx', () => { it('keeps login session state out of account/list.tsx', () => {
const source = readAccountSource('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); 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('<iframe');
expect(source).not.toContain('iframe');
});
it('registers the hidden NapCat WebUI page route under QQBot account', () => {
const source = readRouteSource('qqbot.ts');
expect(source).toContain('QqBotAccountNapcatWebui');
expect(source).toContain('/qqbot/account/:accountId/napcat-webui');
expect(source).toContain('hideInMenu: true');
expect(source).toContain("activePath: '/qqbot/account'");
});
}); });