diff --git a/README.md b/README.md index 3779697..28e0536 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/apps/web-antdv-next/src/api/system/network.spec.ts b/apps/web-antdv-next/src/api/system/network.spec.ts index 7ee038e..7330bf0 100644 --- a/apps/web-antdv-next/src/api/system/network.spec.ts +++ b/apps/web-antdv-next/src/api/system/network.spec.ts @@ -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', + ); + }); }); diff --git a/apps/web-antdv-next/src/api/system/network.ts b/apps/web-antdv-next/src/api/system/network.ts index b49b244..004cb9d 100644 --- a/apps/web-antdv-next/src/api/system/network.ts +++ b/apps/web-antdv-next/src/api/system/network.ts @@ -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(/^\/+/, '')}`; +} diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.spec.tsx b/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.spec.tsx index 40d71e2..c68fe05 100644 --- a/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.spec.tsx +++ b/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.spec.tsx @@ -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) => + (wrapper.vm as any).openCreate('192.168.31.224'), + ], + [ + 'edit', + (wrapper: ReturnType) => + (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<{ diff --git a/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.tsx b/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.tsx index b0952db..e39d5c9 100644 --- a/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.tsx +++ b/apps/web-antdv-next/src/views/system/network/components/NetworkPortForwardModal.tsx @@ -15,8 +15,12 @@ import { import { $t } from '#/locales'; export interface NetworkPortForwardModalExposed { - openCreate: (targetIpv4: string) => Promise; - openEdit: (row: SystemNetworkApi.PortForward) => Promise; + openCreate: (targetIpv4: string) => void; + openEdit: (row: SystemNetworkApi.PortForward) => void; +} + +interface NetworkPortForwardModalData { + values: Partial; } 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(); + 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({ - externalPort: undefined, - internalPort: undefined, - name: '', - protocol: 'udp', - remark: '', - }); - modalApi.open(); + modalApi + .setData({ + values: { + externalPort: undefined, + internalPort: undefined, + name: '', + protocol: 'udp', + remark: '', + }, + } 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({ - externalPort: row.externalPort, - internalPort: row.internalPort, - name: row.name, - protocol: row.protocol, - remark: row.remark || '', - }); - modalApi.open(); + modalApi + .setData({ + values: { + externalPort: row.externalPort, + internalPort: row.internalPort, + name: row.name, + protocol: row.protocol, + remark: row.remark || '', + }, + } satisfies NetworkPortForwardModalData) + .open(); } /** diff --git a/apps/web-antdv-next/src/views/system/network/composables/useNetworkManagementStream.ts b/apps/web-antdv-next/src/views/system/network/composables/useNetworkManagementStream.ts new file mode 100644 index 0000000..e08d55d --- /dev/null +++ b/apps/web-antdv-next/src/views/system/network/composables/useNetworkManagementStream.ts @@ -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(); + 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).data; + if (!data) return undefined; + try { + const payload = JSON.parse( + data, + ) as Partial; + if ( + !payload.eventId || + !payload.observedAt || + !['events', 'reported', 'status'].includes(payload.source || '') + ) { + return undefined; + } + return payload as SystemNetworkApi.StateChangeEvent; + } catch { + return undefined; + } + } + + return { close, start }; +} diff --git a/apps/web-antdv-next/src/views/system/network/list.spec.tsx b/apps/web-antdv-next/src/views/system/network/list.spec.tsx index 95f15a1..92968cd 100644 --- a/apps/web-antdv-next/src/views/system/network/list.spec.tsx +++ b/apps/web-antdv-next/src/views/system/network/list.spec.tsx @@ -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>(); + + 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) { + 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); }); }); diff --git a/apps/web-antdv-next/src/views/system/network/list.tsx b/apps/web-antdv-next/src/views/system/network/list.tsx index b1805ab..2846e52 100644 --- a/apps/web-antdv-next/src/views/system/network/list.tsx +++ b/apps/web-antdv-next/src/views/system/network/list.tsx @@ -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 = { 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(); @@ -97,10 +96,13 @@ export default defineComponent({ const busyRowIds = ref>(new Set()); const modalRef = ref(); const historyDrawerRef = ref(); - let pollActive = false; - let pollTimer: ReturnType | undefined; + let pageActive = false; let refreshInFlight: Promise | undefined; let refreshQueued = false; + const managementStream = useNetworkManagementStream({ + onSnapshotRequired: handleStreamRefresh, + onStateChanged: handleStreamRefresh, + }); const columns: Array> = [ { @@ -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 { - 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 () => (