diff --git a/src/pages/__tests__/dashboard.spec.ts b/src/pages/__tests__/dashboard.spec.ts index 1087bc0f..ba7032e1 100644 --- a/src/pages/__tests__/dashboard.spec.ts +++ b/src/pages/__tests__/dashboard.spec.ts @@ -3,7 +3,7 @@ import { usePluginRuntimeStore } from '@/stores/pluginRuntime' import { DEFAULT_PERMISSIONS } from '@/utils/permission' import { renderWithProviders } from '@tests/support/render' import { fireEvent, screen, waitFor } from '@testing-library/vue' -import { toRaw } from 'vue' +import { KeepAlive, defineComponent, h, ref, toRaw } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => { @@ -97,6 +97,13 @@ class LayoutSourceResizeObserverMock implements ResizeObserver { resize(target: Element, height: number) { this.callback([{ contentRect: { height }, target } as ResizeObserverEntry], this) } + + resizeMany(entries: Array<{ height: number; target: Element }>) { + this.callback( + entries.map(({ height, target }) => ({ contentRect: { height }, target }) as ResizeObserverEntry), + this, + ) + } } async function findLayoutSourceObserver(source: Element) { @@ -159,6 +166,7 @@ vi.mock('@/components/misc/DashboardElement.vue', async () => { emits: ['loaded'], setup(props, { emit }) { const showLayoutSizeSource = ref(false) + const markExistingLayoutSizeSource = ref(false) onMounted(() => emit('loaded')) return () => @@ -170,9 +178,16 @@ vi.mock('@/components/misc/DashboardElement.vue', async () => { onClick: () => { showLayoutSizeSource.value = !showLayoutSizeSource.value }, + onDblclick: () => { + markExistingLayoutSizeSource.value = !markExistingLayoutSizeSource.value + }, }, [ (props.config as { name: string }).name, + h('div', { + 'data-layout-size-source': markExistingLayoutSizeSource.value ? '' : undefined, + 'data-testid': 'existing-layout-source', + }), showLayoutSizeSource.value ? h('div', { 'data-layout-size-source': '', 'data-testid': 'layout-size-source' }, 'size source') : null, @@ -238,6 +253,33 @@ async function renderDashboard( }) } +function dashboardKeepAliveHarness() { + return defineComponent({ + setup() { + const active = ref(true) + + return () => + h('main', [ + h('button', { onClick: () => (active.value = false) }, '停用仪表盘'), + h('button', { onClick: () => (active.value = true) }, '启用仪表盘'), + h(KeepAlive, null, () => (active.value ? h(DashboardPage) : null)), + ]) + }, + }) +} + +async function renderKeptAliveDashboard() { + return renderWithProviders(dashboardKeepAliveHarness(), { + initialRoute: '/dashboard', + initialState: { + user: { + permissions: { ...DEFAULT_PERMISSIONS, discovery: true }, + superUser: true, + }, + }, + }) +} + describe('dashboard page initial layout', () => { beforeEach(() => { LayoutSourceResizeObserverMock.instances.length = 0 @@ -275,9 +317,13 @@ describe('dashboard page initial layout', () => { expect(screen.getAllByTestId('dashboard-item')).toHaveLength(1) expect(screen.getByTestId('dashboard-item')).toHaveAttribute('data-dashboard-id', 'systemInfo') - expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: true }), expect.any(HTMLElement)) + expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement)) expect(container.querySelector('.dashboard-grid')).not.toHaveClass('is-revealed') await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true)) + const firstAnimationResume = mocks.grid.setAnimation.mock.calls.findIndex(([enabled]) => enabled) + expect(mocks.grid.setAnimation.mock.invocationCallOrder[firstAnimationResume]).toBeGreaterThan( + mocks.grid.resizeToContent.mock.invocationCallOrder.at(-1) ?? 0, + ) remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } }) remoteProfile.resolve({ @@ -592,7 +638,7 @@ describe('dashboard page initial layout', () => { await renderDashboard() expect(screen.getAllByTestId('dashboard-item')).toHaveLength(10) - expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: true }), expect.any(HTMLElement)) + expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement)) remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } }) remoteProfile.resolve({ @@ -767,6 +813,88 @@ describe('dashboard page initial layout', () => { await waitFor(() => expect(observer.unobserved.has(sizeSource)).toBe(true)) }) + it('batches multiple size-source changes into one GridStack geometry commit', async () => { + vi.stubGlobal('ResizeObserver', LayoutSourceResizeObserverMock) + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ + enabled: enabledLibraryAndSystemInfo, + items: { library: { x: 0, y: 0, w: 6 }, systemInfo: { x: 6, y: 0, w: 6 } }, + updatedAt: 10, + }), + ) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') { + return { + data: { + value: [ + { id: 'library', key: '' }, + { id: 'systemInfo', key: '' }, + ], + }, + } + } + if (url === '/user/config/DashboardGridLayout') { + return { + data: { + value: { + enabled: enabledLibraryAndSystemInfo, + items: { library: { x: 0, y: 0, w: 6 }, systemInfo: { x: 6, y: 0, w: 6 } }, + }, + }, + } + } + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + await renderDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(2)) + for (const dashboard of screen.getAllByTestId('dashboard-item')) await fireEvent.click(dashboard) + const sources = screen.getAllByTestId('layout-size-source') + const observer = await findLayoutSourceObserver(sources[0]) + await waitFor(() => expect(observer.observed.has(sources[1])).toBe(true)) + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(undefined)))) + mocks.grid.batchUpdate.mockClear() + mocks.grid.resizeToContent.mockClear() + + observer.resizeMany([ + { target: sources[0], height: 480 }, + { target: sources[1], height: 320 }, + ]) + + await waitFor(() => expect(mocks.grid.resizeToContent).toHaveBeenCalledTimes(2)) + expect(mocks.grid.batchUpdate.mock.calls).toEqual([[], [false]]) + }) + + it('observes a size source activated after asynchronous content becomes ready', async () => { + vi.stubGlobal('ResizeObserver', LayoutSourceResizeObserverMock) + 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 dashboard = screen.getByTestId('dashboard-item') + const source = screen.getByTestId('existing-layout-source') + expect(LayoutSourceResizeObserverMock.instances.some(observer => observer.observed.has(source))).toBe(false) + + await fireEvent.dblClick(dashboard) + const observer = await findLayoutSourceObserver(source) + mocks.grid.resizeToContent.mockClear() + observer.resize(source, 320) + + await waitFor(() => expect(mocks.grid.resizeToContent).toHaveBeenCalled()) + }) + it('ignores size source changes for manually sized cards', async () => { vi.stubGlobal('ResizeObserver', LayoutSourceResizeObserverMock) localStorage.setItem( @@ -792,6 +920,8 @@ describe('dashboard page initial layout', () => { await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(1)) await new Promise(resolve => requestAnimationFrame(() => resolve(undefined))) mocks.grid.resizeToContent.mockClear() + mocks.grid.batchUpdate.mockClear() + mocks.grid.setAnimation.mockClear() await fireEvent.click(screen.getByTestId('dashboard-item')) const sizeSource = container.querySelector('[data-layout-size-source]')! @@ -799,6 +929,8 @@ describe('dashboard page initial layout', () => { observer.resize(sizeSource, 480) await new Promise(resolve => requestAnimationFrame(() => resolve(undefined))) expect(mocks.grid.resizeToContent).not.toHaveBeenCalled() + expect(mocks.grid.batchUpdate).not.toHaveBeenCalled() + expect(mocks.grid.setAnimation).not.toHaveBeenCalled() }) it('ignores size source changes while editing an automatic card', async () => { @@ -825,11 +957,76 @@ describe('dashboard page initial layout', () => { const sizeSource = container.querySelector('[data-layout-size-source]')! const observer = await findLayoutSourceObserver(sizeSource) await fireEvent.click(document.querySelector('.compact-fab--primary') as HTMLElement) - await new Promise(resolve => requestAnimationFrame(() => resolve(undefined))) + await waitFor(() => expect(container.querySelector('.dashboard-grid')).toHaveClass('is-editing')) + await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true)) mocks.grid.resizeToContent.mockClear() + mocks.grid.batchUpdate.mockClear() + mocks.grid.setAnimation.mockClear() observer.resize(sizeSource, 480) await new Promise(resolve => requestAnimationFrame(() => resolve(undefined))) expect(mocks.grid.resizeToContent).not.toHaveBeenCalled() + expect(mocks.grid.batchUpdate).not.toHaveBeenCalled() + expect(mocks.grid.setAnimation).not.toHaveBeenCalled() + }) + + it('remeasures automatic cards after layout editing ends', async () => { + vi.stubGlobal('ResizeObserver', LayoutSourceResizeObserverMock) + 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) + }) + + const { container } = await renderDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(1)) + await fireEvent.click(document.querySelector('.compact-fab--primary') as HTMLElement) + await waitFor(() => expect(container.querySelector('.dashboard-grid')).toHaveClass('is-editing')) + mocks.grid.resizeToContent.mockClear() + + await fireEvent.click(document.querySelector('.compact-fab--primary') as HTMLElement) + + await waitFor(() => expect(container.querySelector('.dashboard-grid')).not.toHaveClass('is-editing')) + await waitFor(() => expect(mocks.grid.resizeToContent).toHaveBeenCalled()) + }) + + it('does not measure detached KeepAlive content and remeasures without rebuilding after activation', async () => { + vi.stubGlobal('ResizeObserver', LayoutSourceResizeObserverMock) + 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 renderKeptAliveDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(1)) + await fireEvent.click(screen.getByTestId('dashboard-item')) + const source = screen.getByTestId('layout-size-source') + const observer = await findLayoutSourceObserver(source) + await fireEvent.click(screen.getByRole('button', { name: '停用仪表盘' })) + mocks.grid.resizeToContent.mockClear() + mocks.grid.load.mockClear() + + observer.resize(source, 480) + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(undefined)))) + expect(mocks.grid.resizeToContent).not.toHaveBeenCalled() + + await fireEvent.click(screen.getByRole('button', { name: '启用仪表盘' })) + await waitFor(() => expect(mocks.grid.resizeToContent).toHaveBeenCalled()) + expect(mocks.grid.load).not.toHaveBeenCalled() }) it('applies a newer remote profile and refreshes the local first-frame cache', async () => { @@ -1226,7 +1423,7 @@ describe('dashboard page initial layout', () => { await waitFor(() => expect(mocks.grid.column).toHaveBeenCalledWith(1, 'list')) await waitFor(() => expect(mocks.grid.removeAll).toHaveBeenCalledTimes(1)) await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(10)) - expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true) + await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true)) expect(mocks.grid.makeWidget.mock.calls.map(([, widget]) => widget.id)).toEqual([ 'storage', 'mediaStatistic', diff --git a/src/pages/dashboard.vue b/src/pages/dashboard.vue index 6081296d..fa62b28a 100644 --- a/src/pages/dashboard.vue +++ b/src/pages/dashboard.vue @@ -42,6 +42,8 @@ const DASHBOARD_GRID_CELL_HEIGHT = 16 const DASHBOARD_GRID_FALLBACK_ROWS = 4 const DASHBOARD_GRID_MARGIN = 8 const DASHBOARD_GRID_CONTENT_RESIZE_THRESHOLD = 4 +const DASHBOARD_GRID_ANIMATION_STABLE_FRAMES = 2 +const DASHBOARD_GRID_ANIMATION_MAX_WAIT_FRAMES = 12 const DASHBOARD_GRID_SIZE_SOURCE_SELECTOR = '[data-layout-size-source]' const DASHBOARD_ENABLE_STORAGE_KEY = 'MP_DASHBOARD' const DASHBOARD_ORDER_STORAGE_KEY = 'MP_DASHBOARD_ORDER' @@ -158,7 +160,10 @@ let dashboardGridContentObserver: ResizeObserver | null = null let dashboardGridContentMutationObserver: MutationObserver | null = null let dashboardGridContentResizeFrame: number | null = null let dashboardGridResizeRefreshFrame: number | null = null -let dashboardGridAnimationFrame: number | null = null +let dashboardGridAnimationResumeFrame: number | null = null +let dashboardGridGeometryTransactionDepth = 0 +let dashboardGridGeometryGeneration = 0 +let isDashboardPageActive = true let dashboardRevealFrame: number | null = null let isDashboardRevealPending = false let dashboardProfileSaveQueue = Promise.resolve() @@ -403,15 +408,6 @@ function scheduleDashboardReveal() { }) } -// 程序化批量布局不播放中间态;稳定后恢复用户拖拽、缩放和让位动画。 -function pauseDashboardGridAnimation() { - if (dashboardGridAnimationFrame !== null) { - cancelAnimationFrame(dashboardGridAnimationFrame) - dashboardGridAnimationFrame = null - } - dashboardGrid.value?.setAnimation(false) -} - // 玻璃浏览态直接提交自动布局,普通主题和显式编辑交互保留 GridStack 过渡。 function shouldAnimateDashboardGrid(editable = isLayoutEditing.value) { const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false @@ -419,15 +415,92 @@ function shouldAnimateDashboardGrid(editable = isLayoutEditing.value) { return !reduceMotion && (editable || vuetifyTheme.global.name.value !== 'glass') } -// 批量布局同步期间暂停几何过渡,稳定后恢复浏览与编辑状态的卡片动画。 +// 动画状态只由此函数写入,避免结构同步、主题和编辑状态各自提前恢复过渡。 +function applyDashboardGridAnimationPolicy(forceDisabled = false) { + const shouldAnimate = + !forceDisabled && + isDashboardPageActive && + dashboardGridGeometryTransactionDepth === 0 && + shouldAnimateDashboardGrid() + + dashboardGrid.value?.setAnimation(shouldAnimate) +} + +function refreshDashboardGridAnimationPolicy() { + applyDashboardGridAnimationPolicy( + dashboardGridGeometryTransactionDepth > 0 || dashboardGridAnimationResumeFrame !== null, + ) +} + +function cancelDashboardGridAnimationResume() { + if (dashboardGridAnimationResumeFrame === null) return + + cancelAnimationFrame(dashboardGridAnimationResumeFrame) + dashboardGridAnimationResumeFrame = null +} + +// 只比较 GridStack 几何属性;业务内容变化只有真正改变网格时才延长稳定期。 +function getDashboardGridGeometryFingerprint() { + return Array.from(dashboardGridRef.value?.querySelectorAll('.dashboard-grid-item') ?? []) + .map(element => + ['gs-id', 'gs-x', 'gs-y', 'gs-w', 'gs-h'].map(attribute => element.getAttribute(attribute) ?? '').join(':'), + ) + .sort() + .join('|') +} + +// 连续稳定帧后恢复交互动画;有限等待上限避免异常内容让动画策略永久停留在禁用态。 function scheduleDashboardGridAnimationResume() { if (typeof window === 'undefined') return - if (dashboardGridAnimationFrame !== null) cancelAnimationFrame(dashboardGridAnimationFrame) - dashboardGridAnimationFrame = requestAnimationFrame(() => { - dashboardGridAnimationFrame = null - dashboardGrid.value?.setAnimation(shouldAnimateDashboardGrid()) - }) + cancelDashboardGridAnimationResume() + const generation = dashboardGridGeometryGeneration + let previousFingerprint = getDashboardGridGeometryFingerprint() + let stableFrames = 0 + let waitedFrames = 0 + + const checkStability = () => { + dashboardGridAnimationResumeFrame = null + if (generation !== dashboardGridGeometryGeneration || dashboardGridGeometryTransactionDepth > 0) return + if (!isDashboardPageActive) { + applyDashboardGridAnimationPolicy(true) + return + } + + waitedFrames += 1 + const fingerprint = getDashboardGridGeometryFingerprint() + const hasPendingResize = dashboardGridContentResizeFrame !== null || dashboardGridPendingContentResize.size > 0 + stableFrames = !hasPendingResize && fingerprint === previousFingerprint ? stableFrames + 1 : 0 + previousFingerprint = fingerprint + + if ( + stableFrames >= DASHBOARD_GRID_ANIMATION_STABLE_FRAMES || + waitedFrames >= DASHBOARD_GRID_ANIMATION_MAX_WAIT_FRAMES + ) { + applyDashboardGridAnimationPolicy() + return + } + + dashboardGridAnimationResumeFrame = requestAnimationFrame(checkStability) + } + + dashboardGridAnimationResumeFrame = requestAnimationFrame(checkStability) +} + +// 可嵌套的程序化几何事务统一暂停过渡,最后一个事务结束后进入稳定帧检查。 +function beginDashboardGridGeometryTransaction() { + dashboardGridGeometryTransactionDepth += 1 + dashboardGridGeometryGeneration += 1 + cancelDashboardGridAnimationResume() + applyDashboardGridAnimationPolicy(true) + let ended = false + + return () => { + if (ended) return + ended = true + dashboardGridGeometryTransactionDepth = Math.max(0, dashboardGridGeometryTransactionDepth - 1) + if (dashboardGridGeometryTransactionDepth === 0) scheduleDashboardGridAnimationResume() + } } // 标记单个仪表板项目已经完成首次组件加载。 @@ -955,29 +1028,38 @@ async function exitDashboardLayoutEditing() { await persistCurrentDashboardGridLayout() } - isLayoutEditing.value = false - await nextTick() - await reloadDashboardGridWidgetsFromLayout() - syncDashboardFillContentState() - resizeAutoDashboardItemsToContent() - notifyDashboardContentResize() + const endGeometryTransaction = beginDashboardGridGeometryTransaction() + try { + isLayoutEditing.value = false + await nextTick() + await reloadDashboardGridWidgetsFromLayout() + syncDashboardFillContentState() + resizeAutoDashboardItemsToContent() + notifyDashboardContentResize() + } finally { + endGeometryTransaction() + } } // 清除用户布局覆盖并恢复默认占位;编辑中仅写入临时草稿并等待确认持久化。 async function resetDashboardGridLayout() { + const endGeometryTransaction = beginDashboardGridGeometryTransaction() const shouldPersistImmediately = !isLayoutEditing.value - - dashboardGridLayout.value = {} - if (shouldPersistImmediately) { - await saveDashboardGridLayout({}) + try { + dashboardGridLayout.value = {} + if (shouldPersistImmediately) { + await saveDashboardGridLayout({}) + } + isDashboardGridLayoutResetDraft.value = isLayoutEditing.value + dashboardGrid.value?.removeAll(false, false) + await syncDashboardGrid() + await nextTick() + syncDashboardFillContentState() + resizeAutoDashboardItemsToContent() + notifyDashboardContentResize() + } finally { + endGeometryTransaction() } - isDashboardGridLayoutResetDraft.value = isLayoutEditing.value - dashboardGrid.value?.removeAll(false, false) - await syncDashboardGrid() - await nextTick() - syncDashboardFillContentState() - resizeAutoDashboardItemsToContent() - notifyDashboardContentResize() } // 生成 appMode 底部动态按钮菜单,普通 Web 模式由页面内 FAB 承接。 @@ -1029,9 +1111,16 @@ function toggleDashboardLayoutEditing() { return } + const endGeometryTransaction = beginDashboardGridGeometryTransaction() isDashboardGridLayoutResetDraft.value = false isLayoutEditing.value = true - nextTick(syncDashboardGrid) + void nextTick(async () => { + try { + await syncDashboardGrid() + } finally { + endGeometryTransaction() + } + }) } // 加载用户监控面板配置,优先使用服务端用户配置以支持跨浏览器同步。 @@ -1243,7 +1332,7 @@ function initializeDashboardGrid() { dashboardGrid.value = GridStack.init( { - animate: shouldAnimateDashboardGrid(), + animate: false, cellHeight: DASHBOARD_GRID_CELL_HEIGHT, column: getDashboardGridColumnsForProfile(dashboardLayoutProfile.value), draggable: { @@ -1273,7 +1362,6 @@ function updateDashboardGridEditableState(editable: boolean) { if (!dashboardGrid.value) return dashboardGrid.value.setStatic(!editable) - dashboardGrid.value.setAnimation(shouldAnimateDashboardGrid(editable)) if (editable) { dashboardGrid.value.enableMove(true) dashboardGrid.value.enableResize(true) @@ -1281,35 +1369,38 @@ function updateDashboardGridEditableState(editable: boolean) { } // 将 Vue 渲染出的仪表板节点同步注册到 GridStack。 -async function syncDashboardGrid(resumeAnimation = true) { +async function syncDashboardGrid() { const grid = dashboardGrid.value const gridElement = dashboardGridRef.value if (!grid || !gridElement) return const restoreProfileDefaults = shouldRestoreDashboardGridProfileDefaults shouldRestoreDashboardGridProfileDefaults = false - pauseDashboardGridAnimation() + const endGeometryTransaction = beginDashboardGridGeometryTransaction() isSyncingDashboardGrid.value = true - await nextTick() - syncDashboardFillContentState() - - const items = dashboardGridItems.value - const itemMap = new Map(items.map(item => [item.id, item])) - const synchronizedWidgets = new Map() - const elements = Array.from(gridElement.querySelectorAll('.dashboard-grid-item')).sort( - (a, b) => { - const aWidget = itemMap.get(a.getAttribute('gs-id') ?? '')?.widget - const bWidget = itemMap.get(b.getAttribute('gs-id') ?? '')?.widget - - return ( - (aWidget?.y ?? Number.MAX_SAFE_INTEGER) - (bWidget?.y ?? Number.MAX_SAFE_INTEGER) || - (aWidget?.x ?? Number.MAX_SAFE_INTEGER) - (bWidget?.x ?? Number.MAX_SAFE_INTEGER) - ) - }, - ) + let gridBatchOpen = false try { + await nextTick() + syncDashboardFillContentState() + + const items = dashboardGridItems.value + const itemMap = new Map(items.map(item => [item.id, item])) + const synchronizedWidgets = new Map() + const elements = Array.from(gridElement.querySelectorAll('.dashboard-grid-item')).sort( + (a, b) => { + const aWidget = itemMap.get(a.getAttribute('gs-id') ?? '')?.widget + const bWidget = itemMap.get(b.getAttribute('gs-id') ?? '')?.widget + + return ( + (aWidget?.y ?? Number.MAX_SAFE_INTEGER) - (bWidget?.y ?? Number.MAX_SAFE_INTEGER) || + (aWidget?.x ?? Number.MAX_SAFE_INTEGER) - (bWidget?.x ?? Number.MAX_SAFE_INTEGER) + ) + }, + ) + grid.batchUpdate() + gridBatchOpen = true grid.engine.nodes .filter(node => { @@ -1345,6 +1436,7 @@ async function syncDashboardGrid(resumeAnimation = true) { }) grid.batchUpdate(false) + gridBatchOpen = false // 完整档位布局由 GridStack 按坐标统一恢复,避免逐个注册时的页面节点顺序污染跨列和响应式布局。 grid.load( items.map(item => ({ ...(synchronizedWidgets.get(item.id) ?? item.widget) })), @@ -1353,14 +1445,19 @@ async function syncDashboardGrid(resumeAnimation = true) { updateDashboardGridEditableState(isLayoutEditing.value) syncDashboardFillContentState() observeDashboardGridContent() - nextTick(() => { + await nextTick() + if (isDashboardPageActive) { syncDashboardFillContentState() resizeAutoDashboardItemsToContent() scheduleDashboardReveal() - }) + } } finally { - isSyncingDashboardGrid.value = false - if (resumeAnimation) scheduleDashboardGridAnimationResume() + try { + if (gridBatchOpen) grid.batchUpdate(false) + } finally { + isSyncingDashboardGrid.value = false + endGeometryTransaction() + } } } @@ -1416,6 +1513,13 @@ function observeDashboardGridContent() { dashboardGridContentMutationObserver = new MutationObserver(mutations => { mutations.forEach(mutation => { + if (mutation.type === 'attributes') { + const source = mutation.target as Element + if (source.matches(DASHBOARD_GRID_SIZE_SOURCE_SELECTOR)) observeDashboardGridSizeSource(source) + else unobserveDashboardGridSizeSource(source) + return + } + mutation.removedNodes.forEach(node => { findDashboardGridSizeSources(node).forEach(unobserveDashboardGridSizeSource) }) @@ -1424,7 +1528,12 @@ function observeDashboardGridContent() { }) }) }) - dashboardGridContentMutationObserver.observe(gridElement, { childList: true, subtree: true }) + dashboardGridContentMutationObserver.observe(gridElement, { + attributeFilter: ['data-layout-size-source'], + attributes: true, + childList: true, + subtree: true, + }) } // 返回节点自身及后代声明的真实尺寸源,普通子节点变化不进入测高路径。 @@ -1472,24 +1581,31 @@ function scheduleDashboardItemContentResize(element: GridItemHTMLElement) { dashboardGridContentResizeFrame = requestAnimationFrame(() => { dashboardGridContentResizeFrame = null - dashboardGridPendingContentResize.forEach(itemElement => resizeDashboardItemToContent(itemElement)) + const elements = [...dashboardGridPendingContentResize] dashboardGridPendingContentResize.clear() + resizeDashboardItemsToContent(elements) }) } +// 判断当前项目是否允许自动测高;编辑、用户缩放和手动高度均保持用户几何优先。 +function canResizeDashboardItemToContent(element: GridItemHTMLElement) { + const id = element.getAttribute('gs-id') ?? '' + return ( + Boolean(dashboardGrid.value) && + Boolean(id) && + !isSwitchingDashboardLayoutProfile && + !isLayoutEditing.value && + !isDashboardGridResizing.value && + !hasManualDashboardGridHeight(id) + ) +} + // 将未手动固定高度的单个组件高度调整到内容实际高度。 function resizeDashboardItemToContent(element: GridItemHTMLElement) { const grid = dashboardGrid.value + if (!grid || !canResizeDashboardItemToContent(element)) return + const id = element.getAttribute('gs-id') ?? '' - if ( - !grid || - !id || - isSwitchingDashboardLayoutProfile || - isLayoutEditing.value || - isDashboardGridResizing.value || - hasManualDashboardGridHeight(id) - ) - return syncDashboardFillContentState(element) const shouldMeasureFillContent = element.classList.contains('has-fill-content') @@ -1518,15 +1634,36 @@ function resizeDashboardItemToContent(element: GridItemHTMLElement) { } } +// 同一绘制帧的自动测高在一个 GridStack batch 中提交,避免逐项 pack 产生中间几何。 +function resizeDashboardItemsToContent(elements: GridItemHTMLElement[]) { + const grid = dashboardGrid.value + if (!grid || elements.length === 0 || !isDashboardPageActive) return + + const uniqueElements = [...new Set(elements)].filter(canResizeDashboardItemToContent) + if (uniqueElements.length === 0) return + + const endGeometryTransaction = beginDashboardGridGeometryTransaction() + let gridBatchOpen = false + try { + grid.batchUpdate() + gridBatchOpen = true + uniqueElements.forEach(resizeDashboardItemToContent) + } finally { + try { + if (gridBatchOpen) grid.batchUpdate(false) + } finally { + endGeometryTransaction() + } + } +} + // 将所有未手动固定高度的组件高度调整到内容实际高度。 function resizeAutoDashboardItemsToContent() { const gridElement = dashboardGridRef.value if (!gridElement) return syncDashboardFillContentState() - gridElement.querySelectorAll('.dashboard-grid-item').forEach(element => { - resizeDashboardItemToContent(element) - }) + resizeDashboardItemsToContent(Array.from(gridElement.querySelectorAll('.dashboard-grid-item'))) } // 记录缩放开始前的高度,用于区分用户是否真的手动改过高度。 @@ -1644,11 +1781,12 @@ async function reloadDashboardGridWidgetsFromLayout() { watch(isLayoutEditing, value => { updateDashboardGridEditableState(value) + refreshDashboardGridAnimationPolicy() }) watch( () => vuetifyTheme.global.name.value, - () => updateDashboardGridEditableState(isLayoutEditing.value), + () => refreshDashboardGridAnimationPolicy(), ) watch( @@ -1690,16 +1828,20 @@ watch( dashboardGridAutoHeights.value = nextAutoHeights applyDashboardConfig(localProfileConfig, localLegacyEnable, undefined) updateDashboardSettingsDialog() - pauseDashboardGridAnimation() - dashboardGrid.value?.column( - getDashboardGridColumnsForProfile(nextProfile), - getDashboardGridColumnLayout(nextProfile), - ) - dashboardGrid.value?.removeAll(false, false) - await syncDashboardGrid(false) - scheduleDashboardGridAnimationResume() + const endLocalGeometryTransaction = beginDashboardGridGeometryTransaction() + try { + dashboardGrid.value?.column( + getDashboardGridColumnsForProfile(nextProfile), + getDashboardGridColumnLayout(nextProfile), + ) + dashboardGrid.value?.removeAll(false, false) + await syncDashboardGrid() + } finally { + endLocalGeometryTransaction() + } if (profileSwitchId === dashboardLayoutProfileSwitchId) { isSwitchingDashboardLayoutProfile = false + resizeAutoDashboardItemsToContent() } const profileConfig = await loadDashboardProfileConfig(nextProfile) @@ -1715,14 +1857,18 @@ watch( void saveDashboardProfileConfig() } updateDashboardSettingsDialog() - pauseDashboardGridAnimation() - dashboardGrid.value?.removeAll(false, false) - await syncDashboardGrid(false) - scheduleDashboardGridAnimationResume() - notifyDashboardContentResize() + const endRemoteGeometryTransaction = beginDashboardGridGeometryTransaction() + try { + dashboardGrid.value?.removeAll(false, false) + await syncDashboardGrid() + notifyDashboardContentResize() + } finally { + endRemoteGeometryTransaction() + } } finally { if (profileSwitchId === dashboardLayoutProfileSwitchId) { isSwitchingDashboardLayoutProfile = false + resizeAutoDashboardItemsToContent() } } }, @@ -1752,12 +1898,30 @@ onMounted(() => { onActivated(() => { isRequest.value = true + isDashboardPageActive = true refreshEnabledPluginDashboards() - nextTick(syncDashboardGrid) + const endGeometryTransaction = beginDashboardGridGeometryTransaction() + void nextTick(async () => { + try { + resizeAutoDashboardItemsToContent() + notifyDashboardContentResize() + } finally { + endGeometryTransaction() + } + }) }) onDeactivated(() => { isRequest.value = false + isDashboardPageActive = false + dashboardGridGeometryGeneration += 1 + cancelDashboardGridAnimationResume() + if (dashboardGridContentResizeFrame !== null) { + cancelAnimationFrame(dashboardGridContentResizeFrame) + dashboardGridContentResizeFrame = null + } + dashboardGridPendingContentResize.clear() + applyDashboardGridAnimationPolicy(true) Object.keys(refreshTimers.value).forEach(clearPluginDashboardTimer) }) @@ -1775,10 +1939,7 @@ onBeforeUnmount(() => { cancelAnimationFrame(dashboardGridResizeRefreshFrame) dashboardGridResizeRefreshFrame = null } - if (dashboardGridAnimationFrame !== null) { - cancelAnimationFrame(dashboardGridAnimationFrame) - dashboardGridAnimationFrame = null - } + cancelDashboardGridAnimationResume() if (dashboardRevealFrame !== null) { cancelAnimationFrame(dashboardRevealFrame) dashboardRevealFrame = null diff --git a/src/views/dashboard/AnalyticsWeeklyOverview.vue b/src/views/dashboard/AnalyticsWeeklyOverview.vue index 473d2463..5174cde3 100644 --- a/src/views/dashboard/AnalyticsWeeklyOverview.vue +++ b/src/views/dashboard/AnalyticsWeeklyOverview.vue @@ -123,6 +123,8 @@ const options = controlledComputed( // 图表数据 const series = ref([{ data: [0, 0, 0, 0, 0, 0, 0] }]) +const hasWeeklyData = ref(false) +const isWeeklyLayoutReady = ref(false) // 总数 const totalCount = computed(() => series.value[0].data.reduce((a, b) => a + b, 0)) @@ -136,16 +138,24 @@ const animatedTotalCountText = computed(() => formatDashboardCount(animatedTotal * 调用 API 接口获取近 7 天入库数据。 */ async function getWeeklyData() { + hasWeeklyData.value = false + isWeeklyLayoutReady.value = false try { const res: number[] = await api.get('dashboard/transfer') // 使用nextTick确保DOM更新完成后再更新图表数据 await nextTick() series.value = [{ data: res }] + hasWeeklyData.value = true } catch (e) { console.log(e) } } +// 数据与 ApexCharts 渲染均完成后才暴露真实尺寸,覆盖两者先后顺序不同的竞态。 +function markWeeklyLayoutReady() { + if (hasWeeklyData.value) isWeeklyLayoutReady.value = true +} + onMounted(() => { // 延迟启动,确保组件完全挂载 nextTick(() => { @@ -167,9 +177,16 @@ onActivated(() => { {{ t('dashboard.weeklyOverview') }} - -
- + +
+
diff --git a/src/views/dashboard/__tests__/AnalyticsWeeklyOverview.spec.ts b/src/views/dashboard/__tests__/AnalyticsWeeklyOverview.spec.ts new file mode 100644 index 00000000..80207fd9 --- /dev/null +++ b/src/views/dashboard/__tests__/AnalyticsWeeklyOverview.spec.ts @@ -0,0 +1,49 @@ +import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue' +import { renderWithProviders } from '@tests/support/render' +import { waitFor } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, onMounted, watch } from 'vue' + +const mocks = vi.hoisted(() => ({ + apiGet: vi.fn(), +})) + +vi.mock('@/api', () => ({ + default: createDataApiMock({ + get: (...args: unknown[]) => mocks.apiGet(...args), + }), +})) + +const ApexChartStub = defineComponent({ + props: { series: { type: Array, required: true } }, + emits: ['mounted', 'updated'], + setup(props, { emit }) { + onMounted(() => emit('mounted')) + watch( + () => props.series, + () => emit('updated'), + ) + + return () => h('div', { 'data-testid': 'weekly-chart' }) + }, +}) + +describe('analytics weekly overview', () => { + beforeEach(() => { + mocks.apiGet.mockReset() + }) + + it('activates its layout size source after weekly data reaches the rendered chart', async () => { + mocks.apiGet.mockResolvedValue([1, 2, 3, 4, 5, 6, 7]) + + const { container } = await renderWithProviders(AnalyticsWeeklyOverview, { + global: { stubs: { VApexChart: ApexChartStub } }, + }) + + await waitFor(() => { + expect(container.querySelector('.dashboard-work-content')).toHaveAttribute('data-layout-size-source') + }) + expect(container.querySelector('.dashboard-work-chart')).toHaveClass('dashboard-chart-plot') + expect(mocks.apiGet).toHaveBeenCalledWith('dashboard/transfer') + }) +})