fix(dashboard): deactivate charts before route leave (#733)

This commit is contained in:
InfinityPacer
2026-08-31 01:18:09 +08:00
committed by GitHub
parent 1f2b3e908f
commit 31ec7d3ad6
12 changed files with 204 additions and 38 deletions
-15
View File
@@ -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
+14 -6
View File
@@ -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(() => {
<AnalyticsStorage v-if="config?.id === 'storage'" />
<AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" />
<MediaRecommend v-else-if="config?.id === 'mediaRecommend'" />
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" />
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" :allow-refresh="props.allowRefresh" />
<AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" />
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
<AnalyticsCpu v-else-if="config?.id === 'cpu'" :allowRefresh="props.allowRefresh" />
@@ -274,7 +272,12 @@ onUnmounted(() => {
<div v-if="props.config?.attrs.border === false">
<VCard>
<VCardText class="p-0">
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
<DashboardRender
v-for="(item, index) in props.config?.elements"
:key="index"
:active="props.allowRefresh"
:config="item"
/>
</VCardText>
</VCard>
</div>
@@ -287,7 +290,12 @@ onUnmounted(() => {
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
</VCardItem>
<VCardText>
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
<DashboardRender
v-for="(item, index) in props.config?.elements"
:key="index"
:active="props.allowRefresh"
:config="item"
/>
</VCardText>
</VCard>
</template>
+16 -3
View File
@@ -4,31 +4,44 @@ import { type PropType } from 'vue'
// 输入参数
const elementProps = defineProps({
// 仪表盘失活时仅卸载依赖已连接 DOM 的图表,保留静态结构和其他控件状态。
active: {
type: Boolean,
default: true,
},
config: Object as PropType<RenderProps>,
})
const canRenderComponent = computed(() => elementProps.active || elementProps.config?.component !== 'VApexChart')
</script>
<template>
<Component
:is="elementProps.config?.component"
v-if="!elementProps.config?.html"
v-if="canRenderComponent && !elementProps.config?.html"
v-bind="elementProps.config?.props"
>
{{ elementProps.config?.text }}
<template v-for="(content, name) in elementProps.config?.slots || []" :key="name" v-slot:[name]="{ _props }">
<slot :name="name" v-bind="_props">
<DashboardRender v-for="(slotItem, slotIndex) in content || []" :key="slotIndex" :config="slotItem" />
<DashboardRender
v-for="(slotItem, slotIndex) in content || []"
:key="slotIndex"
:active="elementProps.active"
:config="slotItem"
/>
</slot>
</template>
<DashboardRender
v-for="(innerItem, innerIndex) in elementProps.config?.content || []"
:key="innerIndex"
:active="elementProps.active"
:config="innerItem"
/>
</Component>
<Component
:is="elementProps.config?.component"
v-if="elementProps.config?.html"
v-if="canRenderComponent && elementProps.config?.html"
v-bind="elementProps.config?.props"
v-html="elementProps.config?.html"
/>
@@ -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()
})
})
+35
View File
@@ -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<void>),
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<typeof import('vue-router')>()),
onBeforeRouteLeave: (guard: () => Promise<void>) => {
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<unknown>()
localStorage.setItem(
+7
View File
@@ -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
+8 -5
View File
@@ -172,7 +172,7 @@ const { loading, refresh } = useDataRefresh(
'analytics-cpu',
loadCpuData,
2000, // 2秒间隔
true // 立即执行
true, // 立即执行
)
useKeepAliveRefresh(refresh)
@@ -183,15 +183,19 @@ useKeepAliveRefresh(refresh)
<VCardItem>
<template #prepend><VIcon icon="mdi-cpu-64-bit" size="20" class="me-2" /></template>
<VCardTitle>{{ t('dashboard.cpuUsage') }}</VCardTitle>
<template #append><strong class="dashboard-chart-current">{{ animatedCurrentText }}%</strong></template>
<template #append
><strong class="dashboard-chart-current">{{ animatedCurrentText }}%</strong></template
>
</VCardItem>
<VCardText class="dashboard-chart-content">
<div class="dashboard-chart-plot">
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
<VApexChart v-if="props.allowRefresh" type="area" :options="chartOptions" :series="series" height="100%" />
</div>
<div class="dashboard-chart-footer">
<span>{{ t('dashboard.averageUsage') }}</span>
<span v-for="item in averageUsages" :key="item.label"><strong>{{ item.value }}</strong> {{ item.label }}</span>
<span v-for="item in averageUsages" :key="item.label"
><strong>{{ item.value }}</strong> {{ item.label }}</span
>
</div>
</VCardText>
</VCard>
@@ -236,5 +240,4 @@ useKeepAliveRefresh(refresh)
gap: 0.6rem;
padding-block-start: 0.55rem;
}
</style>
+16 -6
View File
@@ -186,7 +186,7 @@ const { loading, refresh } = useDataRefresh(
'analytics-memory',
loadMemoryData,
3000, // 3秒间隔
true // 立即执行
true, // 立即执行
)
useKeepAliveRefresh(refresh)
@@ -197,7 +197,9 @@ useKeepAliveRefresh(refresh)
<VCardItem>
<template #prepend><VIcon icon="mdi-memory" size="20" class="me-2" /></template>
<VCardTitle>{{ t('dashboard.memoryUsage') }}</VCardTitle>
<template #append><strong class="dashboard-chart-current">{{ memoryUsage.toFixed(1) }}%</strong></template>
<template #append
><strong class="dashboard-chart-current">{{ memoryUsage.toFixed(1) }}%</strong></template
>
</VCardItem>
<VCardText class="dashboard-chart-content">
<div class="dashboard-memory-value">
@@ -205,12 +207,20 @@ useKeepAliveRefresh(refresh)
<span>/ {{ totalMemoryText }}</span>
</div>
<div class="dashboard-chart-plot">
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
<VApexChart v-if="props.allowRefresh" type="area" :options="chartOptions" :series="series" height="100%" />
</div>
<div class="dashboard-chart-footer">
<span><i class="memory-dot memory-dot--used" />{{ t('dashboard.memoryUsed') }} {{ animatedUsedMemoryText }}</span>
<span><i class="memory-dot memory-dot--cached" />{{ t('dashboard.memoryCached') }} {{ animatedCachedMemoryText }}</span>
<span><i class="memory-dot memory-dot--available" />{{ t('dashboard.memoryAvailable') }} {{ animatedAvailableMemoryText }}</span>
<span
><i class="memory-dot memory-dot--used" />{{ t('dashboard.memoryUsed') }} {{ animatedUsedMemoryText }}</span
>
<span
><i class="memory-dot memory-dot--cached" />{{ t('dashboard.memoryCached') }}
{{ animatedCachedMemoryText }}</span
>
<span
><i class="memory-dot memory-dot--available" />{{ t('dashboard.memoryAvailable') }}
{{ animatedAvailableMemoryText }}</span
>
</div>
</VCardText>
</VCard>
+4 -1
View File
@@ -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)
</VCardItem>
<VCardText class="dashboard-chart-content">
<div class="dashboard-chart-plot">
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
<VApexChart v-if="props.allowRefresh" type="area" :options="chartOptions" :series="series" height="100%" />
</div>
<div class="dashboard-chart-footer">
<span
@@ -8,6 +8,14 @@ import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
const props = defineProps({
/** 页面失活时卸载图表实例,卡片数据仍由 KeepAlive 保留。 */
allowRefresh: {
type: Boolean,
default: true,
},
})
const vuetifyTheme = useTheme()
const WEEKLY_BAR_RADIUS = 8
@@ -180,6 +188,7 @@ onActivated(() => {
<VCardText class="dashboard-work-content" :data-layout-size-source="isWeeklyLayoutReady ? '' : undefined">
<div class="dashboard-work-chart dashboard-chart-plot">
<VApexChart
v-if="props.allowRefresh"
type="bar"
:options="options"
:series="series"
@@ -98,6 +98,15 @@ function getSeries() {
return JSON.parse(serialized) as NetworkSeries[]
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>(promiseResolve => {
resolve = promiseResolve
})
return { promise, resolve }
}
async function renderNetwork(props: { allowRefresh?: boolean } = {}) {
return renderWithProviders(AnalyticsNetwork, {
props,
@@ -129,7 +138,7 @@ describe('AnalyticsNetwork', () => {
await registration.refresh()
expect(mocks.apiGet).not.toHaveBeenCalled()
expect(getSeries().map(item => item.data)).toEqual([[0], [0]])
expect(screen.queryByTestId('network-series')).not.toBeInTheDocument()
})
it('normalizes rates and updates current values and series through refresh and KeepAlive', async () => {
@@ -170,6 +179,32 @@ describe('AnalyticsNetwork', () => {
})
})
it('discards a response that finishes while the dashboard is inactive and refreshes after reactivation', async () => {
const inactiveResponse = deferred<[number, number]>()
mocks.apiGet.mockReturnValueOnce(inactiveResponse.promise).mockResolvedValueOnce([4096, 8192])
const view = await renderNetwork()
const registration = getRefreshRegistration()
const pendingRefresh = registration.refresh()
await view.rerender({ allowRefresh: false })
inactiveResponse.resolve([1024, 2048])
await pendingRefresh
expect(screen.queryByTestId('network-series')).not.toBeInTheDocument()
await view.rerender({ allowRefresh: true })
expect(getSeries().map(item => item.data)).toEqual([[0], [0]])
if (!mocks.keepAliveHandler) throw new Error('未注册 KeepAlive 刷新回调')
await mocks.keepAliveHandler()
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
expect(getSeries().map(item => item.data)).toEqual([
[0, 4096],
[0, 8192],
])
})
it('keeps only the latest 30 samples in both network series', async () => {
let sample = 0
mocks.apiGet.mockImplementation(async () => {
@@ -1,6 +1,6 @@
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
import { renderWithProviders } from '@tests/support/render'
import { waitFor } from '@testing-library/vue'
import { screen, waitFor } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, onMounted, watch } from 'vue'
@@ -46,4 +46,22 @@ describe('analytics weekly overview', () => {
expect(container.querySelector('.dashboard-work-chart')).toHaveClass('dashboard-chart-plot')
expect(mocks.apiGet).toHaveBeenCalledWith('dashboard/transfer')
})
it('recreates the chart after the dashboard becomes active while retaining card state', async () => {
mocks.apiGet.mockResolvedValue([1, 2, 3, 4, 5, 6, 7])
const view = await renderWithProviders(AnalyticsWeeklyOverview, {
props: { allowRefresh: false },
global: { stubs: { VApexChart: ApexChartStub } },
})
expect(screen.queryByTestId('weekly-chart')).not.toBeInTheDocument()
await view.rerender({ allowRefresh: true })
expect(await screen.findByTestId('weekly-chart')).toBeInTheDocument()
await waitFor(() => {
expect(view.container.querySelector('.dashboard-work-content')).toHaveAttribute('data-layout-size-source')
})
})
})