fix: 修复网络管理弹窗并改为事件刷新

This commit is contained in:
sunlei 2026-07-23 09:30:19 +08:00
parent bba71812b8
commit f5165b238c
8 changed files with 332 additions and 62 deletions

View File

@ -50,7 +50,7 @@ pnpm run build:antdv-next
- Blog 左栏“管理”入口通过 `/#/auth/login?sso=1&redirect=%2Fblog%2Farticle` 进入 Admin SSO bootstrap。Admin 只在自身域内调用 `/api/auth/refresh` 恢复 HttpOnly refresh Cookie成功后进入文章管理失败则移除 `sso` 并显示登录表单,且只保留固定内部回跳 `/blog/article`。该链路不接受外部 return URL也不把 access token 放进地址栏。
- 系统管理 / 菜单管理维护后端 `admin_menu.sort` 排序字段;`/menu/all` caller 会把后端 `sort` 映射到 Vben 菜单生成器读取的 `meta.order`,保证侧边栏菜单展示以后端返回顺序为准。默认首页入口收敛到环境总览 `/analytics`,不再保留假工作台 `/workspace` 页面。
- 系统管理 / 站内信是日志级通知列表,只展示 API 错误、QQBot 下线、NapCat 离线等后端自动捕获事件;页面提供筛选、处理/重新打开、置顶和删除,不提供人工新增或编辑。
- 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态;页面展示 Agent、路由同步、UDP Keeper 和公网端点租约的独立状态,并支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史。Admin 不接触路由器或 MQTT 凭据;当前已验证切片只执行 UDP 路由器写入TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败STUN 操作保持可见且禁用。
- 系统管理 / 网络管理使用 TSX、KtTable 与统一 Vben 表单维护 API 持久化的 TCP/UDP 单端口转发期望状态;页面展示 Agent、路由同步、UDP Keeper 和公网端点租约的独立状态,并支持异步 CRUD、重试、Keeper 启停、立即 STUN 刷新及端点历史。首屏读取一次 HTTP 快照,后续只在 API SSE 状态事件或主动操作后刷新;心跳不刷新页面,不使用定时轮询。Admin 不接触路由器或 MQTT 凭据;当前已验证切片只执行 UDP 路由器写入TCP 可保存 CRUD 期望但 Agent 会显示设备协议门禁失败STUN 操作保持可见且禁用。
- QQBot / 账号连接页拆分 OneBot 连接、QQ 登录、NapCat 运行和运行说明列;更新登录通过 SSE 展示 quick / password / captcha / new-device / qrcode 每步中文进度,密码登录触发 QQ 安全验证时在弹窗内完成腾讯验证码并回交 API新设备验证二维码和腾讯验证码分开展示行操作“运行态”打开只读抽屉展示 NapCat runtime/protocol/session behavior profile、风险模式和登录事件证据。
- QQBot / 插件平台页保留在线命令能力表,并提供 manifest 校验、本地插件安装、安装记录、运行事件和账号绑定抽屉,接口走 `/qqbot/plugin-platform/*`
- 博客管理 / 文章管理提供“预览”行操作,打开隐藏二级路由 `/blog/article/:articleId/preview`;预览页按 NapCat WebUI 的微服务嵌入形态实现iframe 独占容器,文章标题、状态、预览 Host 和返回/刷新/新窗口操作放在右下角悬浮卡片,不占用 iframe 布局空间。新增隐藏路由和按钮权限需要同步 API `blog-menu.sql` / `vben-admin-init.sql` 中的 `BlogArticlePreview``BlogArticlePreviewButton`

View File

@ -8,6 +8,7 @@ import {
disableNetworkPortForwardKeeper,
enableNetworkPortForwardKeeper,
getNetworkAgentStatus,
getNetworkManagementEventsUrl,
getNetworkPortForwardEndpointHistory,
getNetworkPortForwardList,
probeNetworkPortForward,
@ -19,6 +20,7 @@ vi.mock('#/api/request', () => ({
requestClient: {
delete: vi.fn(),
get: vi.fn(),
getBaseUrl: vi.fn(() => '/api'),
post: vi.fn(),
put: vi.fn(),
},
@ -102,4 +104,13 @@ describe('system network api', () => {
{ params: { pageNo: 2, pageSize: 10 } },
);
});
it('builds the credentialed SSE URL with an optional replay cursor', () => {
expect(getNetworkManagementEventsUrl()).toBe(
'/api/system/network/events/stream',
);
expect(getNetworkManagementEventsUrl('network-event-1')).toBe(
'/api/system/network/events/stream?lastEventId=network-event-1',
);
});
});

View File

@ -16,6 +16,7 @@ export namespace SystemNetworkApi {
| 'stale'
| 'starting';
export type Protocol = 'tcp' | 'udp';
export type StateChangeSource = 'events' | 'reported' | 'status';
export type Revision = string;
export type SyncStatus =
| 'conflict'
@ -96,6 +97,12 @@ export namespace SystemNetworkApi {
remark?: string;
}
export interface StateChangeEvent {
eventId: string;
observedAt: string;
source: StateChangeSource;
}
export type PortForwardItem = PortForward;
export type PortForwardPayload = PortForwardInput;
@ -233,3 +240,32 @@ export function getNetworkAgentStatus() {
'/system/network/agent/status',
);
}
/**
* Builds the credentialed EventSource URL for committed network-state changes.
* @param lastEventId - Optional replay cursor retained by the current route instance.
* @returns Browser-ready SSE URL using the configured API base path.
*/
export function getNetworkManagementEventsUrl(lastEventId?: string) {
const query = lastEventId
? `?lastEventId=${encodeURIComponent(lastEventId)}`
: '';
return buildApiUrl(`/system/network/events/stream${query}`);
}
/**
* Joins one network API path with the request client's configured base URL.
* @param path - Relative API route for the EventSource connection.
* @returns Absolute or proxy-relative URL matching normal Admin requests.
*/
function buildApiUrl(path: string) {
const getBaseUrl = (requestClient as unknown as { getBaseUrl?: () => string })
.getBaseUrl;
const baseUrl = getBaseUrl?.() || '';
if (!baseUrl) return path;
if (/^https?:\/\//i.test(path)) return path;
if (/^https?:\/\//i.test(baseUrl)) {
return new URL(path, baseUrl).toString();
}
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
}

View File

@ -2,7 +2,7 @@
/* eslint-disable vue/one-component-per-file, vue/require-default-prop */
import { mount } from '@vue/test-utils';
import { flushPromises, mount } from '@vue/test-utils';
import { defineComponent, h } from 'vue';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@ -12,8 +12,10 @@ import NetworkPortForwardModal from './NetworkPortForwardModal';
const mocks = vi.hoisted(() => {
const modalApi = {
close: vi.fn(async () => {}),
getData: vi.fn(),
lock: vi.fn(),
open: vi.fn(),
setData: vi.fn(),
unlock: vi.fn(),
};
const formApi = {
@ -97,6 +99,15 @@ vi.mock('#/locales', () => ({
describe('network port-forward modal', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.formApi.resetForm.mockResolvedValue(undefined);
mocks.modalApi.getData.mockImplementation(
() => mocks.modalApi.setData.mock.calls.at(-1)?.[0] || {},
);
mocks.modalApi.setData.mockImplementation(() => mocks.modalApi);
mocks.modalApi.open.mockImplementation(() => {
mocks.modalOptions.onOpenChange?.(true);
return mocks.modalApi;
});
mocks.create.mockResolvedValue({});
mocks.update.mockResolvedValue({});
mocks.formApi.getValues.mockResolvedValue({
@ -127,6 +138,43 @@ describe('network port-forward modal', () => {
);
});
it.each([
[
'create',
(wrapper: ReturnType<typeof mount>) =>
(wrapper.vm as any).openCreate('192.168.31.224'),
],
[
'edit',
(wrapper: ReturnType<typeof mount>) =>
(wrapper.vm as any).openEdit({
desiredPresence: 'present',
desiredRevision: '7',
externalPort: 45_678,
id: '42',
internalPort: 45_678,
isDeleted: false,
keeperDesiredEnabled: false,
keeperStatus: 'disabled',
name: 'Game UDP',
protocol: 'udp',
syncStatus: 'synced',
targetIpv4: '192.168.31.224',
}),
],
])('opens the modal before restoring the %s form', async (_, openModal) => {
mocks.formApi.resetForm.mockImplementation(async () => {
expect(mocks.modalApi.open).toHaveBeenCalledOnce();
});
const wrapper = mount(NetworkPortForwardModal);
await openModal(wrapper);
await flushPromises();
expect(mocks.modalApi.open).toHaveBeenCalledOnce();
expect(mocks.formApi.resetForm).toHaveBeenCalledOnce();
});
it('matches the API name and remark length contract', () => {
mount(NetworkPortForwardModal);
const schema = mocks.formOptions.schema as Array<{

View File

@ -15,8 +15,12 @@ import {
import { $t } from '#/locales';
export interface NetworkPortForwardModalExposed {
openCreate: (targetIpv4: string) => Promise<void>;
openEdit: (row: SystemNetworkApi.PortForward) => Promise<void>;
openCreate: (targetIpv4: string) => void;
openEdit: (row: SystemNetworkApi.PortForward) => void;
}
interface NetworkPortForwardModalData {
values: Partial<SystemNetworkApi.PortForwardInput>;
}
const protocolOptions = [
@ -53,40 +57,52 @@ export default defineComponent({
async onConfirm() {
await submit();
},
/** Resets values only after destroy-on-close content has mounted. */
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const { values } = modalApi.getData<NetworkPortForwardModalData>();
void resetForm(values);
},
});
/**
* Opens a blank form for one new desired mapping.
* @param fixedTargetIpv4 - Server-controlled NAS target shown read-only.
*/
async function openCreate(fixedTargetIpv4: string) {
function openCreate(fixedTargetIpv4: string) {
editingRow.value = undefined;
targetIpv4.value = fixedTargetIpv4;
await resetForm({
modalApi
.setData({
values: {
externalPort: undefined,
internalPort: undefined,
name: '',
protocol: 'udp',
remark: '',
});
modalApi.open();
},
} satisfies NetworkPortForwardModalData)
.open();
}
/**
* Opens an existing row for editing without copying reported/runtime fields.
* @param row - Persisted desired record selected from KtTable.
*/
async function openEdit(row: SystemNetworkApi.PortForward) {
function openEdit(row: SystemNetworkApi.PortForward) {
editingRow.value = row;
targetIpv4.value = row.targetIpv4;
await resetForm({
modalApi
.setData({
values: {
externalPort: row.externalPort,
internalPort: row.internalPort,
name: row.name,
protocol: row.protocol,
remark: row.remark || '',
});
modalApi.open();
},
} satisfies NetworkPortForwardModalData)
.open();
}
/**

View File

@ -0,0 +1,86 @@
import type { SystemNetworkApi } from '#/api/system/network';
import { ref } from 'vue';
import { getNetworkManagementEventsUrl } from '#/api/system/network';
export interface UseNetworkManagementStreamOptions {
onSnapshotRequired: () => void;
onStateChanged: (event: SystemNetworkApi.StateChangeEvent) => void;
}
/**
* Bridges API SSE updates into the network page without exposing MQTT credentials.
* @param options - Page-owned callbacks for committed changes and replay gaps.
* @returns Idempotent start/close controls for route keep-alive lifecycle hooks.
*/
export function useNetworkManagementStream(
options: UseNetworkManagementStreamOptions,
) {
const lastEventId = ref<string>();
let source: EventSource | undefined;
/** Starts one EventSource and relies on native reconnect while the page is active. */
function start() {
if (source) return;
source = new EventSource(getNetworkManagementEventsUrl(lastEventId.value), {
withCredentials: true,
});
source.addEventListener('network-state-changed', handleStateChanged);
source.addEventListener('snapshot-required', handleSnapshotRequired);
}
/** Closes the active route stream and removes its typed listeners. */
function close() {
if (!source) return;
source.removeEventListener('network-state-changed', handleStateChanged);
source.removeEventListener('snapshot-required', handleSnapshotRequired);
source.close();
source = undefined;
}
/**
* Applies one committed MQTT-derived state event exactly once.
* @param event - Browser SSE message containing a safe state-change envelope.
*/
function handleStateChanged(event: Event) {
const payload = parseStateChange(event);
if (!payload || payload.eventId === lastEventId.value) return;
lastEventId.value = payload.eventId;
options.onStateChanged(payload);
}
/** Requests one fresh snapshot only when the API cannot replay a missed topic event. */
function handleSnapshotRequired() {
options.onSnapshotRequired();
}
/**
* Validates the minimum browser payload before it reaches page state.
* @param event - Typed EventSource message with JSON data.
* @returns Parsed state event, or undefined for malformed/unexpected data.
*/
function parseStateChange(
event: Event,
): SystemNetworkApi.StateChangeEvent | undefined {
const data = (event as MessageEvent<string>).data;
if (!data) return undefined;
try {
const payload = JSON.parse(
data,
) as Partial<SystemNetworkApi.StateChangeEvent>;
if (
!payload.eventId ||
!payload.observedAt ||
!['events', 'reported', 'status'].includes(payload.source || '')
) {
return undefined;
}
return payload as SystemNetworkApi.StateChangeEvent;
} catch {
return undefined;
}
}
return { close, start };
}

View File

@ -17,6 +17,46 @@ import NetworkList, {
isDeleting,
} from './list';
type FakeEventSourceListener = (event: Event) => void;
class FakeEventSource {
static instances: FakeEventSource[] = [];
closed = false;
readonly listeners = new Map<string, Set<FakeEventSourceListener>>();
constructor(
readonly url: string,
readonly options?: EventSourceInit,
) {
FakeEventSource.instances.push(this);
}
/** Registers one typed SSE listener for the page test. */
addEventListener(type: string, listener: FakeEventSourceListener) {
const listeners = this.listeners.get(type) || new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}
/** Closes this fake stream and prevents later dispatches. */
close() {
this.closed = true;
}
/** Dispatches one JSON SSE payload to currently registered listeners. */
dispatch(type: string, data: Record<string, unknown>) {
if (this.closed) return;
const event = new MessageEvent(type, { data: JSON.stringify(data) });
this.listeners.get(type)?.forEach((listener) => listener(event));
}
/** Removes one typed SSE listener from the page test. */
removeEventListener(type: string, listener: FakeEventSourceListener) {
this.listeners.get(type)?.delete(listener);
}
}
const mocks = vi.hoisted(() => ({
api: {
deleteMapping: vi.fn(),
@ -28,6 +68,8 @@ const mocks = vi.hoisted(() => ({
retry: vi.fn(),
},
messageSuccess: vi.fn(),
modalOpenCreate: vi.fn(),
modalOpenEdit: vi.fn(),
tableApi: {
getRows: vi.fn(() => []),
reload: vi.fn(),
@ -99,7 +141,10 @@ vi.mock('./components/NetworkPortForwardModal', () => ({
default: defineComponent({
name: 'MockNetworkPortForwardModal',
setup(_, { expose }) {
expose({ openCreate: vi.fn(), openEdit: vi.fn() });
expose({
openCreate: mocks.modalOpenCreate,
openEdit: mocks.modalOpenEdit,
});
return () => h('div');
},
}),
@ -120,6 +165,9 @@ vi.mock('#/api/system/network', () => ({
disableNetworkPortForwardKeeper: mocks.api.disableKeeper,
enableNetworkPortForwardKeeper: mocks.api.enableKeeper,
getNetworkAgentStatus: mocks.api.getAgentStatus,
getNetworkManagementEventsUrl: vi.fn(
() => '/api/system/network/events/stream',
),
getNetworkPortForwardList: mocks.api.getList,
probeNetworkPortForward: mocks.api.probe,
retryNetworkPortForward: mocks.api.retry,
@ -163,6 +211,8 @@ describe('system network persisted list', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
FakeEventSource.instances = [];
vi.stubGlobal('EventSource', FakeEventSource);
mocks.tableOptions = undefined;
mocks.tableApi.getRows.mockReturnValue([]);
mocks.tableApi.reload.mockResolvedValue(undefined);
@ -181,6 +231,7 @@ describe('system network persisted list', () => {
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
@ -283,6 +334,20 @@ describe('system network persisted list', () => {
expect(enable.disabled(row)).toBe(false);
});
it('opens the shared modal from both create and edit table actions', async () => {
mount(NetworkList);
await flushPromises();
const row = createRow();
await mocks.tableOptions.buttons[0].onClick({});
await mocks.tableOptions.rowActions
.find((item: any) => item.key === 'edit')
.onClick(row, {});
expect(mocks.modalOpenCreate).toHaveBeenCalledWith('192.168.31.224');
expect(mocks.modalOpenEdit).toHaveBeenCalledWith(row);
});
it('makes deleting rows immutable while preserving retry', () => {
mount(NetworkList);
const actions = mocks.tableOptions.rowActions;
@ -321,7 +386,7 @@ describe('system network persisted list', () => {
);
});
it('serializes polling and clears its timer when the page unmounts', async () => {
it('refreshes only for subscribed topic events and closes the stream on unmount', async () => {
let finishReload: (() => void) | undefined;
mocks.tableApi.reload.mockImplementation(
() =>
@ -332,18 +397,38 @@ describe('system network persisted list', () => {
const wrapper = mount(NetworkList);
await flushPromises();
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
expect(FakeEventSource.instances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(10_000);
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
finishReload?.();
await flushPromises();
await vi.advanceTimersByTimeAsync(5000);
expect(vi.getTimerCount()).toBe(0);
FakeEventSource.instances[0]?.dispatch('heartbeat', {
observedAt: '2026-07-23T00:00:00.000Z',
});
await flushPromises();
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(1);
FakeEventSource.instances[0]?.dispatch('network-state-changed', {
eventId: 'network-event-1',
observedAt: '2026-07-23T00:00:01.000Z',
source: 'reported',
});
await flushPromises();
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
finishReload?.();
await flushPromises();
wrapper.unmount();
await vi.advanceTimersByTimeAsync(10_000);
expect(FakeEventSource.instances[0]?.closed).toBe(true);
FakeEventSource.instances[0]?.dispatch('network-state-changed', {
eventId: 'network-event-2',
observedAt: '2026-07-23T00:00:02.000Z',
source: 'status',
});
await flushPromises();
expect(mocks.tableApi.reload).toHaveBeenCalledTimes(2);
});
});

View File

@ -38,11 +38,10 @@ import { $t } from '#/locales';
import NetworkEndpointHistoryDrawer from './components/NetworkEndpointHistoryDrawer';
import NetworkPortForwardModal from './components/NetworkPortForwardModal';
import { useNetworkManagementStream } from './composables/useNetworkManagementStream';
const AKtTable = KtTable as any;
const ATypographyText = Typography.Text as any;
const POLL_INTERVAL_MS = 5000;
const protocolOptions = [
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
@ -89,7 +88,7 @@ const keeperStatusLabels: Record<SystemNetworkApi.KeeperStatus, string> = {
export default defineComponent({
name: 'SystemNetworkList',
/**
* Builds the generic persisted network-resource table and serialized poller.
* Builds the generic persisted network-resource table and event-driven refresh stream.
*/
setup() {
const agentStatus = ref<SystemNetworkApi.AgentStatus>();
@ -97,10 +96,13 @@ export default defineComponent({
const busyRowIds = ref<Set<string>>(new Set());
const modalRef = ref<NetworkPortForwardModalExposed>();
const historyDrawerRef = ref<NetworkEndpointHistoryDrawerExposed>();
let pollActive = false;
let pollTimer: ReturnType<typeof setTimeout> | undefined;
let pageActive = false;
let refreshInFlight: Promise<void> | undefined;
let refreshQueued = false;
const managementStream = useNetworkManagementStream({
onSnapshotRequired: handleStreamRefresh,
onStateChanged: handleStreamRefresh,
});
const columns: Array<TableColumnType<SystemNetworkApi.PortForward>> = [
{
@ -411,10 +413,9 @@ export default defineComponent({
}
/**
* Serializes table and Agent polling so stale responses cannot overtake writes.
* Serializes table and Agent refreshes so stale responses cannot overtake writes.
*/
async function requestRefresh(): Promise<void> {
clearPollTimer();
if (refreshInFlight) {
refreshQueued = true;
await refreshInFlight;
@ -433,39 +434,26 @@ export default defineComponent({
if (refreshQueued) {
refreshQueued = false;
await requestRefresh();
return;
}
schedulePoll();
}
/** Schedules the next non-overlapping refresh only while the page is active. */
function schedulePoll() {
clearPollTimer();
if (!pollActive) return;
pollTimer = setTimeout(() => {
void requestRefresh();
}, POLL_INTERVAL_MS);
/** Refreshes persisted facts only for a committed topic event or replay gap. */
function handleStreamRefresh() {
if (pageActive) void requestRefresh();
}
/** Starts polling and immediately requests fresh table and Agent facts. */
function startPolling() {
if (pollActive) return;
pollActive = true;
/** Opens the stream before the one initial page snapshot to avoid a subscription gap. */
function activatePage() {
if (pageActive) return;
pageActive = true;
managementStream.start();
void requestRefresh();
}
/** Stops future polling without changing any locally displayed fact. */
function stopPolling() {
pollActive = false;
clearPollTimer();
}
/** Clears the current one-shot polling timer when present. */
function clearPollTimer() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = undefined;
}
/** Closes the route-owned stream while preserving its replay cursor. */
function deactivatePage() {
pageActive = false;
managementStream.close();
}
/** Renders independent Agent connectivity and revision convergence controls. */
@ -539,10 +527,10 @@ export default defineComponent({
return undefined;
}
onMounted(startPolling);
onActivated(startPolling);
onDeactivated(stopPolling);
onBeforeUnmount(stopPolling);
onMounted(activatePage);
onActivated(activatePage);
onDeactivated(deactivatePage);
onBeforeUnmount(deactivatePage);
return () => (
<Page autoContentHeight>