feat: 增加 NapCat WebUI 二级页面
This commit is contained in:
parent
c2cbdf808a
commit
8795227c05
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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];
|
||||||
|
}
|
||||||
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user