diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.scss b/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.scss
new file mode 100644
index 0000000..118ebec
--- /dev/null
+++ b/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.scss
@@ -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;
+ }
+}
diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.tsx b/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.tsx
new file mode 100644
index 0000000..d4a24f6
--- /dev/null
+++ b/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/index.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (
+ session.state.value === 'error' ||
+ session.state.value === 'revoked'
+ ) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ };
+
+ /**
+ * Renders the page root required by Vben route transitions.
+ *
+ * @returns The stable single-root page shell.
+ */
+ const renderPage = () => {
+ return (
+
+
+
+
+
+ {session.container.value?.name || 'NapCat Gateway'}
+ {expiresAtText.value ? (
+ 有效期:{expiresAtText.value}
+ ) : null}
+
+
+
{renderBody()}
+
+
+ );
+ };
+
+ 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];
+}
diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx b/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx
new file mode 100644
index 0000000..c7b8f67
--- /dev/null
+++ b/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/napcat-webui.spec.tsx
@@ -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,
+ },
+}));
+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: {
+ id: 'container-1',
+ name: 'kt-qqbot-napcat-10001',
+ 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)');
+ });
+
+ 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' });
+ });
+});
diff --git a/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/useNapcatWebuiGatewaySession.ts b/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/useNapcatWebuiGatewaySession.ts
new file mode 100644
index 0000000..4caaa0e
--- /dev/null
+++ b/apps/web-antdv-next/src/views/qqbot/account/napcat-webui/useNapcatWebuiGatewaySession.ts
@@ -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) {
+ const account = ref();
+ const container = ref();
+ const errorMessage = ref('');
+ const expiresAt = ref();
+ const iframeUrl = ref('');
+ const sessionId = ref('');
+ const state = ref('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;
+}