feat: 增加 NapCat WebUI 二级页面

This commit is contained in:
sunlei 2026-06-24 15:37:26 +08:00
parent c2cbdf808a
commit 8795227c05
4 changed files with 796 additions and 0 deletions

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>{session.container.value?.name || 'NapCat Gateway'}</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: {
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' });
});
});

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;
}