Compare commits
3 Commits
d038501228
...
8616f16383
| Author | SHA1 | Date | |
|---|---|---|---|
| 8616f16383 | |||
| 8795227c05 | |||
| c2cbdf808a |
@ -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 回退。
|
||||||
|
|
||||||
## 提交规范
|
## 提交规范
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>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];
|
||||||
|
}
|
||||||
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user