Compare commits

..

3 Commits

Author SHA1 Message Date
8616f16383 fix: 暴露NapCat WebUI同源代理 2026-06-24 16:30:12 +08:00
8795227c05 feat: 增加 NapCat WebUI 二级页面 2026-06-24 15:37:26 +08:00
c2cbdf808a feat: 新增 NapCat WebUI Admin 入口 2026-06-24 15:15:43 +08:00
12 changed files with 1002 additions and 1 deletions

View File

@ -59,7 +59,7 @@ Jenkins 使用 `Jenkinsfile` 执行:
3. `pnpm run build:antdv-next` 3. `pnpm run build:antdv-next`
4. 将 `apps/web-antdv-next/dist` 原子发布到 Nginx 挂载的 Admin 静态目录 4. 将 `apps/web-antdv-next/dist` 原子发布到 Nginx 挂载的 Admin 静态目录
Nginx 配置见 `deploy/nginx-admin.conf`,默认监听 `5999`,静态根目录为 `/usr/share/nginx/html/admin`,并将浏览器侧 `/api/*` 转发到后端 `192.168.31.224:48085`。配置保留 gzip、静态资源长缓存、入口 HTML 不缓存和 SPA 回退。 Nginx 配置见 `deploy/nginx-admin.conf`,默认监听 `5999`,静态根目录为 `/usr/share/nginx/html/admin`,并将浏览器侧 `/api/*` 转发到后端 `192.168.31.224:48085`,将 `/napcat-webui/*` 转发到 NapCat WebUI Gateway `192.168.31.224:48086`。配置保留 gzip、静态资源长缓存、入口 HTML 不缓存、WebUI WebSocket 转发和 SPA 回退。
## 提交规范 ## 提交规范

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,50 @@ 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: {
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,34 @@ export namespace QqbotNapcatApi {
sessionBehaviorProfile?: Record<string, unknown>; sessionBehaviorProfile?: Record<string, unknown>;
} }
export interface WebuiGatewaySessionAccount {
id: string;
name: string;
selfId: string;
}
export interface WebuiGatewaySessionContainer {
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 +246,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,28 @@ const accountRoot = resolve(
const readAccountSource = (relativePath: string) => const readAccountSource = (relativePath: string) =>
readFileSync(resolve(accountRoot, relativePath), 'utf8'); readFileSync(resolve(accountRoot, relativePath), 'utf8');
/**
* Reads repository-root files that define browser-facing deployment boundaries.
*
* @param relativePath - Repository-relative path to read.
* @returns Source text for boundary assertions.
*/
const readRepoSource = (relativePath: string) =>
readFileSync(resolve(cwd(), 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 +59,36 @@ 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'");
});
it('exposes the NapCat WebUI gateway through the Admin same-origin dev and nginx routes', () => {
const viteSource = readRepoSource('apps/web-antdv-next/vite.config.mts');
const nginxSource = readRepoSource('deploy/nginx-admin.conf');
expect(viteSource).toContain("'/napcat-webui'");
expect(viteSource).toContain('http://localhost:48086');
expect(nginxSource).toContain('location ^~ /napcat-webui/');
expect(nginxSource).toContain('48086');
expect(nginxSource).toContain('proxy_http_version 1.1');
});
}); });

View File

@ -0,0 +1,107 @@
.qqbot-napcat-webui {
display: flex;
flex-direction: column;
gap: 12px;
height: var(--vben-content-height, 100%);
min-height: 0;
overflow: hidden;
color: hsl(var(--foreground));
background: hsl(var(--background));
&__header {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
min-height: 44px;
padding-bottom: 12px;
border-bottom: 1px solid hsl(var(--border));
}
&__identity {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
min-width: 0;
}
&__back {
display: inline-flex;
gap: 6px;
align-items: center;
padding-inline: 0;
}
&__back-icon {
width: 16px;
height: 16px;
}
&__title {
display: inline-flex;
gap: 8px;
align-items: center;
min-width: 0;
font-size: 16px;
font-weight: 600;
}
&__actions {
flex: 0 0 auto;
}
&__meta {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 12px;
min-width: 0;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
&__content {
display: flex;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
&__center,
&__message {
display: flex;
flex: 1 1 0;
flex-direction: column;
gap: 12px;
align-items: center;
justify-content: center;
min-height: 0;
}
&__message {
align-items: stretch;
max-width: 560px;
margin: 0 auto;
}
&__iframe-shell {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
&__iframe {
display: block;
width: 100%;
height: 100%;
background: hsl(var(--background));
border: 0;
}
}

View File

@ -0,0 +1,224 @@
import type { NapcatWebuiGatewaySessionState } from './useNapcatWebuiGatewaySession';
import { computed, defineComponent, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { ArrowLeft } from '@vben/icons';
import { Alert, Button, Space, Spin, Tag } from 'antdv-next';
import { useNapcatWebuiGatewaySession } from './useNapcatWebuiGatewaySession';
import './index.scss';
const AAlert = Alert as any;
const AButton = Button as any;
const ASpace = Space as any;
const ASpin = Spin as any;
const ATag = Tag as any;
export default defineComponent({
name: 'QqBotAccountNapcatWebui',
/**
* Wires route account identity to the page-owned NapCat WebUI session.
*/
setup() {
const route = useRoute();
const router = useRouter();
const routeAccountId = computed(() =>
normalizeRouteParam(route.params.accountId),
);
const session = useNapcatWebuiGatewaySession(routeAccountId);
const accountTitle = computed(() => {
const account = session.account.value;
if (!account) return 'NapCat WebUI';
if (account.name) return `${account.name}${account.selfId}`;
return account.selfId;
});
const expiresAtText = computed(() =>
formatGatewayExpiresAt(session.expiresAt.value),
);
const statusMeta = computed(() => getStatusMeta(session.state.value));
watch(
routeAccountId,
() => {
void session.open();
},
{ immediate: true },
);
/**
* Navigates back to the account list route.
*/
function goBack() {
void router.push({ name: 'QqBotAccount' });
}
/**
* Reopens the gateway session for the current route account.
*/
function reopen() {
void session.open();
}
/**
* Closes the current gateway session while staying on this page.
*/
function closeSession() {
void session.revoke();
}
/**
* Renders the main state area for loading, error, revoked, and ready states.
*
* @returns TSX content for the current gateway state.
*/
const renderBody = () => {
if (session.state.value === 'ready' && session.iframeUrl.value) {
return (
<div class="qqbot-napcat-webui__iframe-shell">
<iframe
class="qqbot-napcat-webui__iframe"
src={session.iframeUrl.value}
title={`NapCat WebUI ${accountTitle.value}`}
/>
</div>
);
}
if (
session.state.value === 'error' ||
session.state.value === 'revoked'
) {
return (
<div class="qqbot-napcat-webui__message">
<AAlert
message={
session.state.value === 'error'
? session.errorMessage.value
: 'NapCat WebUI 会话已关闭。'
}
showIcon
type={session.state.value === 'error' ? 'error' : 'info'}
/>
<AButton onClick={reopen} type="primary">
</AButton>
</div>
);
}
return (
<div class="qqbot-napcat-webui__center">
<ASpin spinning={session.state.value === 'loading'} />
</div>
);
};
/**
* Renders the page root required by Vben route transitions.
*
* @returns The stable single-root page shell.
*/
const renderPage = () => {
return (
<Page autoContentHeight>
<div class="qqbot-napcat-webui">
<div class="qqbot-napcat-webui__header">
<div class="qqbot-napcat-webui__identity">
<AButton
class="qqbot-napcat-webui__back"
onClick={goBack}
type="text"
>
<ArrowLeft class="qqbot-napcat-webui__back-icon" />
</AButton>
<div class="qqbot-napcat-webui__title">
<span>{accountTitle.value}</span>
<ATag color={statusMeta.value.color}>
{statusMeta.value.label}
</ATag>
</div>
</div>
<ASpace class="qqbot-napcat-webui__actions">
<AButton
disabled={session.state.value === 'loading'}
onClick={reopen}
>
</AButton>
<AButton
danger
disabled={session.state.value === 'loading'}
onClick={closeSession}
>
</AButton>
</ASpace>
</div>
<div class="qqbot-napcat-webui__meta">
<span>NapCat WebUI</span>
{expiresAtText.value ? (
<span>{expiresAtText.value}</span>
) : null}
</div>
<div class="qqbot-napcat-webui__content">{renderBody()}</div>
</div>
</Page>
);
};
return renderPage;
},
});
/**
* Normalizes a vue-router route param into a single trimmed account id.
*
* @param value - Raw route param value.
* @returns Trimmed account id or an empty string.
*/
function normalizeRouteParam(value: unknown) {
if (Array.isArray(value)) return `${value[0] || ''}`.trim();
return `${value || ''}`.trim();
}
/**
* Formats the gateway expiry timestamp for compact page metadata.
*
* @param value - Epoch milliseconds returned by the Gateway heartbeat.
* @returns Local date-time text, or an empty string when the value is absent.
*/
function formatGatewayExpiresAt(value?: number) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const pad = (unit: number) => `${unit}`.padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate(),
)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
date.getSeconds(),
)}`;
}
/**
* Maps gateway state to a compact Ant Design status tag.
*
* @param state - Current gateway lifecycle state.
* @returns Tag color and Chinese label for the page header.
*/
function getStatusMeta(state: NapcatWebuiGatewaySessionState) {
const statusMap = {
error: { color: 'error', label: '异常' },
idle: { color: 'default', label: '待打开' },
loading: { color: 'processing', label: '打开中' },
ready: { color: 'success', label: '已连接' },
revoked: { color: 'default', label: '已关闭' },
} as const;
return statusMap[state];
}

View File

@ -0,0 +1,241 @@
/* @vitest-environment happy-dom */
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createQqbotNapcatWebuiSession,
heartbeatQqbotNapcatWebuiSession,
revokeQqbotNapcatWebuiSession,
} from '#/api/qqbot/napcat';
import QqBotAccountNapcatWebui from './index';
const testState = vi.hoisted(() => ({
intervalControls: {
callback: undefined as (() => unknown) | undefined,
pause: vi.fn(),
resume: vi.fn(),
},
pushSpy: vi.fn(),
route: {
params: {
accountId: 'account-1',
} as Record<string, unknown>,
},
}));
const { intervalControls, pushSpy, route } = testState;
vi.mock('vue-router', () => ({
useRoute: () => route,
useRouter: () => ({
push: pushSpy,
}),
}));
vi.mock('@vben/common-ui', () => ({
Page: defineComponent({
name: 'MockPage',
setup(_, { slots }) {
return () => h('main', slots.default?.());
},
}),
}));
vi.mock('@vben/icons', () => ({
ArrowLeft: defineComponent({
name: 'MockArrowLeft',
setup() {
return () => h('i', { 'data-testid': 'arrow-left' });
},
}),
}));
vi.mock('antdv-next', () => ({
Alert: defineComponent({
name: 'MockAlert',
props: {
message: String,
type: String,
},
setup(props) {
return () =>
h('div', { role: 'alert' }, [props.message, props.type as string]);
},
}),
Button: defineComponent({
name: 'MockButton',
props: {
danger: Boolean,
disabled: Boolean,
type: String,
},
emits: ['click'],
setup(props, { emit, slots }) {
return () =>
h(
'button',
{
disabled: props.disabled,
type: 'button',
onClick: () => emit('click'),
},
slots.default?.(),
);
},
}),
Space: defineComponent({
name: 'MockSpace',
setup(_, { slots }) {
return () => h('div', slots.default?.());
},
}),
Spin: defineComponent({
name: 'MockSpin',
props: {
spinning: Boolean,
},
setup(props, { slots }) {
return () =>
h(
'div',
{ 'data-spinning': String(props.spinning) },
slots.default?.(),
);
},
}),
Tag: defineComponent({
name: 'MockTag',
props: {
color: String,
},
setup(_, { slots }) {
return () => h('span', slots.default?.());
},
}),
}));
vi.mock('@vueuse/core', () => ({
useIntervalFn: vi.fn((callback: () => unknown) => {
intervalControls.callback = callback;
return {
pause: intervalControls.pause,
resume: intervalControls.resume,
};
}),
}));
vi.mock('#/api/qqbot/napcat', () => ({
createQqbotNapcatWebuiSession: vi.fn(),
heartbeatQqbotNapcatWebuiSession: vi.fn(),
revokeQqbotNapcatWebuiSession: vi.fn(),
}));
/**
* Creates a mocked gateway session payload for page lifecycle tests.
*
* @param overrides - Partial fields that should replace the default fixture.
* @returns A gateway session payload shaped like the backend response.
*/
function createSessionFixture(overrides = {}) {
return {
account: {
id: 'account-1',
name: '主账号',
selfId: '10001',
},
container: {
webuiStatus: 'online' as const,
},
expiresAt: new Date('2026-06-24T15:30:00+08:00').getTime(),
iframeUrl: '/qqbot/napcat/webui/session/session-1/',
sessionId: 'session-1',
...overrides,
};
}
describe('qqbot account NapCat WebUI page', () => {
beforeEach(() => {
route.params.accountId = 'account-1';
pushSpy.mockReset();
intervalControls.callback = undefined;
intervalControls.pause.mockReset();
intervalControls.resume.mockReset();
vi.clearAllMocks();
vi.mocked(createQqbotNapcatWebuiSession).mockResolvedValue(
createSessionFixture(),
);
vi.mocked(heartbeatQqbotNapcatWebuiSession).mockResolvedValue({
expiresAt: new Date('2026-06-24T15:40:00+08:00').getTime(),
sessionId: 'session-1',
status: 'active',
});
vi.mocked(revokeQqbotNapcatWebuiSession).mockResolvedValue({
sessionId: 'session-1',
status: 'revoked',
});
});
it('creates a session for the route account and renders the gateway iframe', async () => {
const wrapper = mount(QqBotAccountNapcatWebui);
await flushPromises();
expect(createQqbotNapcatWebuiSession).toHaveBeenCalledWith({
accountId: 'account-1',
});
expect(intervalControls.resume).toHaveBeenCalledTimes(1);
expect(wrapper.find('iframe').attributes('src')).toBe(
'/qqbot/napcat/webui/session/session-1/',
);
expect(wrapper.text()).toContain('主账号10001');
expect(wrapper.text()).toContain('NapCat WebUI');
expect(wrapper.text()).not.toContain('kt-qqbot-napcat');
});
it('revokes the active session and pauses heartbeat on unmount', async () => {
const wrapper = mount(QqBotAccountNapcatWebui);
await flushPromises();
wrapper.unmount();
await flushPromises();
expect(intervalControls.pause).toHaveBeenCalled();
expect(revokeQqbotNapcatWebuiSession).toHaveBeenCalledWith('session-1');
});
it('uses the captured heartbeat callback to extend expiry while ready', async () => {
const wrapper = mount(QqBotAccountNapcatWebui);
await flushPromises();
await intervalControls.callback?.();
await flushPromises();
expect(heartbeatQqbotNapcatWebuiSession).toHaveBeenCalledWith('session-1');
expect(wrapper.text()).toContain('2026-06-24 15:40:00');
});
it('renders an error state and hides iframe when session creation fails', async () => {
vi.mocked(createQqbotNapcatWebuiSession).mockRejectedValueOnce(
new Error('Gateway unavailable'),
);
const wrapper = mount(QqBotAccountNapcatWebui);
await flushPromises();
expect(wrapper.find('iframe').exists()).toBe(false);
expect(wrapper.text()).toContain('Gateway unavailable');
expect(intervalControls.resume).not.toHaveBeenCalled();
});
it('routes back to the QQBot account list from the back button', async () => {
const wrapper = mount(QqBotAccountNapcatWebui);
await flushPromises();
await wrapper.find('button').trigger('click');
expect(pushSpy).toHaveBeenCalledWith({ name: 'QqBotAccount' });
});
});

View File

@ -0,0 +1,224 @@
import type { Ref } from 'vue';
import type { QqbotNapcatApi } from '#/api/qqbot/napcat';
import { onBeforeUnmount, ref } from 'vue';
import { useIntervalFn } from '@vueuse/core';
import {
createQqbotNapcatWebuiSession,
heartbeatQqbotNapcatWebuiSession,
revokeQqbotNapcatWebuiSession,
} from '#/api/qqbot/napcat';
export type NapcatWebuiGatewaySessionState =
| 'error'
| 'idle'
| 'loading'
| 'ready'
| 'revoked';
/**
* Owns one NapCat WebUI gateway session for the current account page lifetime.
*
* @param accountId - Reactive account id read from the route.
* @returns Reactive gateway session state and lifecycle actions.
*/
export function useNapcatWebuiGatewaySession(accountId: Ref<string>) {
const account = ref<QqbotNapcatApi.WebuiGatewaySessionAccount>();
const container = ref<QqbotNapcatApi.WebuiGatewaySessionContainer>();
const errorMessage = ref('');
const expiresAt = ref<number>();
const iframeUrl = ref('');
const sessionId = ref('');
const state = ref<NapcatWebuiGatewaySessionState>('idle');
let disposed = false;
let openToken = 0;
const heartbeat = useIntervalFn(sendHeartbeat, 20_000, {
immediate: false,
});
onBeforeUnmount(handleBeforeUnmount);
/**
* Opens a new gateway session after revoking any existing local session.
*
* @returns A promise that settles after the open attempt finishes.
*/
async function open() {
const nextAccountId = accountId.value.trim();
heartbeat.pause();
errorMessage.value = '';
if (sessionId.value) {
await revoke();
errorMessage.value = '';
}
if (!nextAccountId) {
state.value = 'error';
errorMessage.value =
'缺少账号 ID请从账号连接列表重新进入 NapCat WebUI。';
return;
}
const currentOpenToken = ++openToken;
state.value = 'loading';
try {
const result = await createQqbotNapcatWebuiSession({
accountId: nextAccountId,
});
if (disposed || currentOpenToken !== openToken) {
await revokeDetachedSession(result.sessionId);
return;
}
applyGatewaySession(result);
state.value = 'ready';
heartbeat.resume();
} catch (error) {
if (currentOpenToken !== openToken) return;
heartbeat.pause();
state.value = 'error';
errorMessage.value = getErrorMessage(
error,
'NapCat WebUI 会话创建失败,请稍后重试。',
);
}
}
/**
* Revokes the current gateway session and clears local iframe credentials.
*
* @returns A promise that settles after local state has been cleared.
*/
async function revoke() {
openToken += 1;
heartbeat.pause();
const currentSessionId = sessionId.value;
if (!currentSessionId) {
clearGatewaySession();
state.value = 'revoked';
return;
}
try {
await revokeQqbotNapcatWebuiSession(currentSessionId);
} catch (error) {
errorMessage.value = getErrorMessage(
error,
'NapCat WebUI 会话已在本地关闭,远端会话将等待过期。',
);
} finally {
clearGatewaySession();
state.value = 'revoked';
}
}
/**
* Sends one heartbeat for the active ready session and updates its expiry.
*
* @returns A promise that settles after the heartbeat attempt finishes.
*/
async function sendHeartbeat() {
const currentSessionId = sessionId.value;
if (state.value !== 'ready' || !currentSessionId) return;
try {
const result = await heartbeatQqbotNapcatWebuiSession(currentSessionId);
if (result.expiresAt !== undefined) {
expiresAt.value = result.expiresAt;
}
} catch (error) {
heartbeat.pause();
state.value = 'error';
errorMessage.value = getErrorMessage(
error,
'NapCat WebUI 会话心跳失败,请重新打开。',
);
}
}
/**
* Applies the gateway session payload returned by the backend.
*
* @param result - Gateway session payload containing iframe URL and metadata.
*/
function applyGatewaySession(result: QqbotNapcatApi.WebuiGatewaySession) {
account.value = result.account;
container.value = result.container;
expiresAt.value = result.expiresAt;
iframeUrl.value = result.iframeUrl;
sessionId.value = result.sessionId;
}
/**
* Clears all local data that can keep a gateway session reachable.
*/
function clearGatewaySession() {
account.value = undefined;
container.value = undefined;
expiresAt.value = undefined;
iframeUrl.value = '';
sessionId.value = '';
}
/**
* Revokes a session created after this composable was already invalidated.
*
* @param staleSessionId - Session id returned by an outdated open request.
* @returns A promise that ignores revoke failures because the page is leaving.
*/
async function revokeDetachedSession(staleSessionId: string) {
try {
await revokeQqbotNapcatWebuiSession(staleSessionId);
} catch {
// The page has already moved on; the backend TTL remains the fallback.
}
}
/**
* Marks the composable disposed and starts best-effort session revocation.
*/
function handleBeforeUnmount() {
disposed = true;
void revoke();
}
return {
account,
container,
errorMessage,
expiresAt,
iframeUrl,
open,
revoke,
sessionId,
state,
};
}
/**
* Extracts a user-facing error message from unknown rejection values.
*
* @param error - Unknown error thrown by the request client.
* @param fallback - Message to show when the error has no readable text.
* @returns A Chinese or backend-provided message for page alerts.
*/
function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) return error.message;
if (typeof error === 'string' && error.trim()) return error.trim();
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string' &&
error.message.trim()
) {
return error.message.trim();
}
return fallback;
}

View File

@ -13,6 +13,11 @@ const config = defineConfig(async () => {
target: 'http://localhost:48085', target: 'http://localhost:48085',
ws: true, ws: true,
}, },
'/napcat-webui': {
changeOrigin: true,
target: 'http://localhost:48086',
ws: true,
},
}, },
}, },
}, },

View File

@ -23,6 +23,17 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location ^~ /napcat-webui/ {
proxy_pass http://192.168.31.224:48086/napcat-webui/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";