From 31ec7d3ad60c5546d6768e92e50774c5ab1363b8 Mon Sep 17 00:00:00 2001
From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com>
Date: Mon, 31 Aug 2026 01:18:09 +0800
Subject: [PATCH] fix(dashboard): deactivate charts before route leave (#733)
---
eslint-suppressions.json | 15 -------
src/components/misc/DashboardElement.vue | 20 +++++++---
src/components/render/DashboardRender.vue | 19 +++++++--
.../render/__tests__/DashboardRender.spec.ts | 40 +++++++++++++++++++
src/pages/__tests__/dashboard.spec.ts | 35 ++++++++++++++++
src/pages/dashboard.vue | 7 ++++
src/views/dashboard/AnalyticsCpu.vue | 13 +++---
src/views/dashboard/AnalyticsMemory.vue | 22 +++++++---
src/views/dashboard/AnalyticsNetwork.vue | 5 ++-
.../dashboard/AnalyticsWeeklyOverview.vue | 9 +++++
.../__tests__/AnalyticsNetwork.spec.ts | 37 ++++++++++++++++-
.../__tests__/AnalyticsWeeklyOverview.spec.ts | 20 +++++++++-
12 files changed, 204 insertions(+), 38 deletions(-)
create mode 100644 src/components/render/__tests__/DashboardRender.spec.ts
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 9dfc4ce5..fd6cc7b7 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -547,11 +547,6 @@
"count": 3
}
},
- "src/composables/useSetupWizard.ts": {
- "@typescript-eslint/no-explicit-any": {
- "count": 1
- }
- },
"src/composables/useSharedDialog.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -823,16 +818,6 @@
"count": 1
}
},
- "src/views/setup/MediaServerSettingsStep.vue": {
- "@typescript-eslint/no-explicit-any": {
- "count": 1
- }
- },
- "src/views/setup/PreferencesSettingsStep.vue": {
- "@typescript-eslint/no-explicit-any": {
- "count": 1
- }
- },
"src/views/subscribe/FullCalendarView.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 1
diff --git a/src/components/misc/DashboardElement.vue b/src/components/misc/DashboardElement.vue
index 883e53fc..42b79223 100644
--- a/src/components/misc/DashboardElement.vue
+++ b/src/components/misc/DashboardElement.vue
@@ -130,9 +130,7 @@ let dashboardLoadGeneration = 0
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
// 仪表盘远程组件共享源码,但所有插件动态 API 保持实例级隔离。
-const scopedPluginApi = computed(() =>
- createPluginInstanceApi(props.config?.id || '', props.config?.source_plugin_id),
-)
+const scopedPluginApi = computed(() => createPluginInstanceApi(props.config?.id || '', props.config?.source_plugin_id))
// 插件节点身份变化时重建异步组件,使失败后的远程模块可以再次加载。
const pluginDashboardIdentity = computed(
@@ -241,7 +239,7 @@ onUnmounted(() => {
-
+
@@ -274,7 +272,12 @@ onUnmounted(() => {
-
+
@@ -287,7 +290,12 @@ onUnmounted(() => {
{{ props.config?.attrs?.subtitle }}
-
+
diff --git a/src/components/render/DashboardRender.vue b/src/components/render/DashboardRender.vue
index 5fc53900..0a3f2082 100644
--- a/src/components/render/DashboardRender.vue
+++ b/src/components/render/DashboardRender.vue
@@ -4,31 +4,44 @@ import { type PropType } from 'vue'
// 输入参数
const elementProps = defineProps({
+ // 仪表盘失活时仅卸载依赖已连接 DOM 的图表,保留静态结构和其他控件状态。
+ active: {
+ type: Boolean,
+ default: true,
+ },
config: Object as PropType,
})
+
+const canRenderComponent = computed(() => elementProps.active || elementProps.config?.component !== 'VApexChart')
{{ elementProps.config?.text }}
-
+
diff --git a/src/components/render/__tests__/DashboardRender.spec.ts b/src/components/render/__tests__/DashboardRender.spec.ts
new file mode 100644
index 00000000..ee748010
--- /dev/null
+++ b/src/components/render/__tests__/DashboardRender.spec.ts
@@ -0,0 +1,40 @@
+import DashboardRender from '@/components/render/DashboardRender.vue'
+import { renderWithProviders } from '@tests/support/render'
+import { screen } from '@testing-library/vue'
+import { defineComponent, h } from 'vue'
+import { describe, expect, it } from 'vitest'
+
+const ApexChartStub = defineComponent({
+ name: 'VApexChart',
+ setup: () => () => h('div', { 'data-testid': 'plugin-chart' }),
+})
+
+describe('DashboardRender', () => {
+ it('recreates nested plugin charts across dashboard activation without removing surrounding content', async () => {
+ const view = await renderWithProviders(DashboardRender, {
+ props: {
+ active: true,
+ config: {
+ component: 'VRow',
+ content: [
+ { component: 'VCol', content: [{ component: 'VApexChart' }] },
+ { component: 'VCol', text: '保留的插件内容' },
+ ],
+ },
+ },
+ global: { stubs: { VApexChart: ApexChartStub } },
+ })
+
+ expect(screen.getByTestId('plugin-chart')).toBeInTheDocument()
+ expect(screen.getByText('保留的插件内容')).toBeInTheDocument()
+
+ await view.rerender({ active: false })
+
+ expect(screen.queryByTestId('plugin-chart')).not.toBeInTheDocument()
+ expect(screen.getByText('保留的插件内容')).toBeInTheDocument()
+
+ await view.rerender({ active: true })
+
+ expect(screen.getByTestId('plugin-chart')).toBeInTheDocument()
+ })
+})
diff --git a/src/pages/__tests__/dashboard.spec.ts b/src/pages/__tests__/dashboard.spec.ts
index ba7032e1..0b7ad9f0 100644
--- a/src/pages/__tests__/dashboard.spec.ts
+++ b/src/pages/__tests__/dashboard.spec.ts
@@ -46,6 +46,7 @@ const mocks = vi.hoisted(() => {
grid,
gridInit: vi.fn<(options: unknown, element: unknown) => unknown>(() => grid),
openSharedDialog: vi.fn(),
+ routeLeaveGuard: undefined as undefined | (() => Promise),
themeName: undefined as unknown as { value: string },
useDynamicButton: vi.fn(),
}
@@ -121,6 +122,13 @@ vi.mock('@/api', () => ({
}),
}))
+vi.mock('vue-router', async importOriginal => ({
+ ...(await importOriginal()),
+ onBeforeRouteLeave: (guard: () => Promise) => {
+ mocks.routeLeaveGuard = guard
+ },
+}))
+
vi.mock('vuetify', async importOriginal => {
const { ref } = await import('vue')
mocks.displayWidth = ref(1512)
@@ -161,6 +169,7 @@ vi.mock('@/components/misc/DashboardElement.vue', async () => {
default: defineComponent({
name: 'DashboardElement',
props: {
+ allowRefresh: Boolean,
config: { type: Object, required: true },
},
emits: ['loaded'],
@@ -174,6 +183,7 @@ vi.mock('@/components/misc/DashboardElement.vue', async () => {
'section',
{
'data-dashboard-id': (props.config as { id: string }).id,
+ 'data-refresh-enabled': String(props.allowRefresh),
'data-testid': 'dashboard-item',
onClick: () => {
showLayoutSizeSource.value = !showLayoutSizeSource.value
@@ -289,6 +299,7 @@ describe('dashboard page initial layout', () => {
mocks.grid.setAnimation.mockClear()
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
+ mocks.routeLeaveGuard = undefined
mocks.displayWidth.value = 1512
mocks.themeName.value = 'light'
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
@@ -1029,6 +1040,30 @@ describe('dashboard page initial layout', () => {
expect(mocks.grid.load).not.toHaveBeenCalled()
})
+ it('disables dashboard refreshers before the router detaches the cached page', async () => {
+ localStorage.setItem(
+ 'MP_DASHBOARD_GRID_LAYOUT',
+ JSON.stringify({ enabled: enabledOnlyLibrary, items: { library: { x: 0, y: 0, w: 12 } }, updatedAt: 10 }),
+ )
+ mocks.apiGet.mockImplementation((url: string) => {
+ if (url === '/user/config/DashboardOrder') return { data: { value: [{ id: 'library', key: '' }] } }
+ if (url === '/user/config/DashboardGridLayout') {
+ return { data: { value: { enabled: enabledOnlyLibrary, items: { library: { x: 0, y: 0, w: 12 } } } } }
+ }
+ if (url === '/plugin/dashboard/meta') return []
+ throw new Error('Unexpected GET ' + url)
+ })
+
+ await renderDashboard()
+ const dashboardItem = await screen.findByTestId('dashboard-item')
+ expect(dashboardItem).toHaveAttribute('data-refresh-enabled', 'true')
+ if (!mocks.routeLeaveGuard) throw new Error('未注册仪表盘路由离开守卫')
+
+ await mocks.routeLeaveGuard()
+
+ expect(dashboardItem).toHaveAttribute('data-refresh-enabled', 'false')
+ })
+
it('applies a newer remote profile and refreshes the local first-frame cache', async () => {
const remoteProfile = deferred()
localStorage.setItem(
diff --git a/src/pages/dashboard.vue b/src/pages/dashboard.vue
index fa62b28a..a95c2e19 100644
--- a/src/pages/dashboard.vue
+++ b/src/pages/dashboard.vue
@@ -13,6 +13,7 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
import { usePluginRuntimeStore, useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
import { useDisplay, useTheme } from 'vuetify'
+import { onBeforeRouteLeave } from 'vue-router'
const ContentToggleSettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
@@ -1896,6 +1897,12 @@ onMounted(() => {
initializeDashboardGrid()
})
+// KeepAlive 会先移动页面 DOM 再触发 deactivated;图表必须在容器仍连接时完成卸载,避免异步 watcher 访问失效根节点。
+onBeforeRouteLeave(async () => {
+ isRequest.value = false
+ await nextTick()
+})
+
onActivated(() => {
isRequest.value = true
isDashboardPageActive = true
diff --git a/src/views/dashboard/AnalyticsCpu.vue b/src/views/dashboard/AnalyticsCpu.vue
index 5ef4cf22..ac79f1ae 100644
--- a/src/views/dashboard/AnalyticsCpu.vue
+++ b/src/views/dashboard/AnalyticsCpu.vue
@@ -172,7 +172,7 @@ const { loading, refresh } = useDataRefresh(
'analytics-cpu',
loadCpuData,
2000, // 2秒间隔
- true // 立即执行
+ true, // 立即执行
)
useKeepAliveRefresh(refresh)
@@ -183,15 +183,19 @@ useKeepAliveRefresh(refresh)
{{ t('dashboard.cpuUsage') }}
- {{ animatedCurrentText }}%
+ {{ animatedCurrentText }}%
-
+
@@ -236,5 +240,4 @@ useKeepAliveRefresh(refresh)
gap: 0.6rem;
padding-block-start: 0.55rem;
}
-
diff --git a/src/views/dashboard/AnalyticsMemory.vue b/src/views/dashboard/AnalyticsMemory.vue
index fe9ba38b..d438ec7f 100644
--- a/src/views/dashboard/AnalyticsMemory.vue
+++ b/src/views/dashboard/AnalyticsMemory.vue
@@ -186,7 +186,7 @@ const { loading, refresh } = useDataRefresh(
'analytics-memory',
loadMemoryData,
3000, // 3秒间隔
- true // 立即执行
+ true, // 立即执行
)
useKeepAliveRefresh(refresh)
@@ -197,7 +197,9 @@ useKeepAliveRefresh(refresh)
{{ t('dashboard.memoryUsage') }}
- {{ memoryUsage.toFixed(1) }}%
+ {{ memoryUsage.toFixed(1) }}%
@@ -205,12 +207,20 @@ useKeepAliveRefresh(refresh)
/ {{ totalMemoryText }}
-
+
diff --git a/src/views/dashboard/AnalyticsNetwork.vue b/src/views/dashboard/AnalyticsNetwork.vue
index 3ab44be8..3e6b3076 100644
--- a/src/views/dashboard/AnalyticsNetwork.vue
+++ b/src/views/dashboard/AnalyticsNetwork.vue
@@ -174,6 +174,9 @@ async function getNetworkUsage() {
feedback: 'silent',
skipNavigationCancellation: true,
})) ?? [0, 0]
+ // 网络采样允许跨路由完成,但失活页面不得把在途响应提交给已脱离 DOM 的图表。
+ if (!props.allowRefresh) return
+
currentUpload.value = Number(data[0]) || 0
currentDownload.value = Number(data[1]) || 0
@@ -213,7 +216,7 @@ useKeepAliveRefresh(refresh)
-
+