From 0fb5bbebc67f1a3989f0225955b7554d111f74a6 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:59:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BB=AA=E8=A1=A8=E7=9B=98?= =?UTF-8?q?=E9=A6=96=E5=B1=8F=E5=B8=83=E5=B1=80=E8=B7=B3=E5=8A=A8=E4=B8=8E?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=BC=8F=E9=94=99=E4=BD=8D=20(#571)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/__tests__/dashboard.spec.ts | 962 ++++++++++++++++++++++++++ src/pages/dashboard.vue | 328 +++++++-- 2 files changed, 1225 insertions(+), 65 deletions(-) create mode 100644 src/pages/__tests__/dashboard.spec.ts diff --git a/src/pages/__tests__/dashboard.spec.ts b/src/pages/__tests__/dashboard.spec.ts new file mode 100644 index 00000000..98d0000a --- /dev/null +++ b/src/pages/__tests__/dashboard.spec.ts @@ -0,0 +1,962 @@ +import DashboardPage from '@/pages/dashboard.vue' +import { DEFAULT_PERMISSIONS } from '@/utils/permission' +import { renderWithProviders } from '@tests/support/render' +import { fireEvent, screen, waitFor } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const grid = { + batchUpdate: vi.fn(), + column: vi.fn(), + destroy: vi.fn(), + enableMove: vi.fn(), + enableResize: vi.fn(), + engine: { nodes: [] as Array> }, + getColumn: vi.fn(() => 12), + load: vi.fn(), + makeWidget: vi.fn((element: HTMLElement, widget: Record) => { + const node = { ...widget, el: element, id: widget.id } + Object.assign(element, { gridstackNode: node }) + grid.engine.nodes.push(node) + }), + on: vi.fn(), + removeAll: vi.fn(() => { + grid.engine.nodes.forEach(node => { + const element = node.el as HTMLElement | undefined + if (element) delete (element as HTMLElement & { gridstackNode?: unknown }).gridstackNode + }) + grid.engine.nodes.length = 0 + }), + removeWidget: vi.fn(), + resizeToContent: vi.fn(), + save: vi.fn(() => []), + setAnimation: vi.fn(), + setStatic: vi.fn(), + update: vi.fn((element: HTMLElement, widget: Record) => { + Object.assign((element as HTMLElement & { gridstackNode?: Record }).gridstackNode ?? {}, widget) + }), + } + + return { + apiGet: vi.fn(), + apiPost: vi.fn(), + displayWidth: undefined as unknown as { value: number }, + grid, + gridInit: vi.fn<(options: unknown, element: unknown) => unknown>(() => grid), + openSharedDialog: vi.fn(), + useDynamicButton: vi.fn(), + } +}) + +class ResizeObserverMock implements ResizeObserver { + private readonly callback: ResizeObserverCallback + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + + disconnect() {} + + observe(target: Element) { + requestAnimationFrame(() => { + this.callback([{ contentRect: { height: 240 }, target } as ResizeObserverEntry], this) + }) + } + + unobserve() {} +} + +vi.mock('@/api', () => ({ + default: { + get: (...args: unknown[]) => mocks.apiGet(...args), + post: (...args: unknown[]) => mocks.apiPost(...args), + }, +})) + +vi.mock('vuetify', async importOriginal => { + const { ref } = await import('vue') + mocks.displayWidth = ref(1512) + + return { + ...(await importOriginal()), + useDisplay: () => ({ width: mocks.displayWidth }), + } +}) + +vi.mock('gridstack', () => ({ + GridStack: { + init: (options: unknown, element: unknown) => mocks.gridInit(options, element), + }, +})) + +vi.mock('@/composables/useDynamicButton', () => ({ + useDynamicButton: (options: unknown) => mocks.useDynamicButton(options), +})) + +vi.mock('@/composables/usePWA', async () => { + const { ref } = await import('vue') + return { + usePWA: () => ({ appMode: ref(false) }), + } +}) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args), +})) + +vi.mock('@/components/misc/DashboardElement.vue', async () => { + const { defineComponent, h, onMounted } = await import('vue') + + return { + default: defineComponent({ + name: 'DashboardElement', + props: { + config: { type: Object, required: true }, + }, + emits: ['loaded'], + setup(props, { emit }) { + onMounted(() => emit('loaded')) + + return () => + h( + 'section', + { + 'data-dashboard-id': (props.config as { id: string }).id, + 'data-testid': 'dashboard-item', + }, + (props.config as { name: string }).name, + ) + }, + }), + } +}) + +const enabledOnlySystemInfo = { + cpu: false, + latest: false, + library: false, + mediaRecommend: false, + mediaStatistic: false, + memory: false, + network: false, + playing: false, + quickActions: false, + recentImports: false, + scheduler: false, + speed: false, + storage: false, + systemInfo: true, + weeklyOverview: false, +} + +const enabledOnlyLibrary = { + ...enabledOnlySystemInfo, + library: true, + systemInfo: false, +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(promiseResolve => { + resolve = promiseResolve + }) + + return { promise, resolve } +} + +async function renderDashboard() { + return renderWithProviders(DashboardPage, { + initialRoute: '/dashboard', + initialState: { + user: { + permissions: { ...DEFAULT_PERMISSIONS, discovery: true }, + superUser: true, + }, + }, + }) +} + +describe('dashboard page initial layout', () => { + beforeEach(() => { + mocks.grid.engine.nodes.length = 0 + mocks.gridInit.mockClear() + mocks.grid.setAnimation.mockClear() + mocks.apiGet.mockReset() + mocks.apiPost.mockReset() + mocks.displayWidth.value = 1512 + vi.stubGlobal('ResizeObserver', ResizeObserverMock) + }) + + it('renders the cached profile on the first frame without waiting for remote validation', async () => { + const remoteOrder = deferred() + const remoteProfile = deferred() + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ + enabled: enabledOnlySystemInfo, + items: { systemInfo: { x: 8, y: 0, w: 4, h: 6 } }, + updatedAt: 10, + }), + ) + localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify([{ id: 'systemInfo', key: '' }])) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') return remoteOrder.promise + if (url === '/user/config/DashboardGridLayout') return remoteProfile.promise + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + const { container } = await renderDashboard() + + expect(screen.getAllByTestId('dashboard-item')).toHaveLength(1) + expect(screen.getByTestId('dashboard-item')).toHaveAttribute('data-dashboard-id', 'systemInfo') + expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement)) + await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true)) + await waitFor(() => expect(container.querySelector('.dashboard-grid')).toHaveClass('is-revealed')) + + remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } }) + remoteProfile.resolve({ + data: { + value: { + enabled: enabledOnlySystemInfo, + items: { systemInfo: { x: 8, y: 0, w: 4, h: 6 } }, + updatedAt: 10, + }, + }, + }) + + await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled()) + }) + + it('keeps the upstream progressive default while an uncached remote profile is loading', async () => { + const remoteOrder = deferred() + const remoteProfile = deferred() + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') return remoteOrder.promise + if (url === '/user/config/DashboardGridLayout') return remoteProfile.promise + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + await renderDashboard() + + expect(screen.getAllByTestId('dashboard-item')).toHaveLength(10) + expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement)) + + remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } }) + remoteProfile.resolve({ + data: { + value: { + enabled: enabledOnlySystemInfo, + items: { systemInfo: { x: 8, y: 0, w: 4, h: 6 } }, + updatedAt: 20, + }, + }, + }) + + expect(await screen.findByTestId('dashboard-item')).toHaveAttribute('data-dashboard-id', 'systemInfo') + }) + + it('applies the shared remote order before profile validation settles', async () => { + const remoteOrder = deferred() + const remoteProfile = deferred() + const enabled = { + ...enabledOnlySystemInfo, + quickActions: true, + recentImports: true, + } + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ + enabled, + items: {}, + updatedAt: 10, + }), + ) + localStorage.setItem( + 'MP_DASHBOARD_ORDER', + JSON.stringify([ + { id: 'systemInfo', key: '' }, + { id: 'recentImports', key: '' }, + { id: 'quickActions', key: '' }, + ]), + ) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') return remoteOrder.promise + if (url === '/user/config/DashboardGridLayout') return remoteProfile.promise + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + await renderDashboard() + expect(screen.getAllByTestId('dashboard-item').map(item => item.getAttribute('data-dashboard-id'))).toEqual([ + 'systemInfo', + 'recentImports', + 'quickActions', + ]) + + remoteOrder.resolve({ + data: { + value: [ + { id: 'quickActions', key: '' }, + { id: 'recentImports', key: '' }, + { id: 'systemInfo', key: '' }, + ], + }, + }) + + await waitFor(() => + expect(screen.getAllByTestId('dashboard-item').map(item => item.getAttribute('data-dashboard-id'))).toEqual([ + 'quickActions', + 'recentImports', + 'systemInfo', + ]), + ) + }) + + it('prepares an automatic card with its last measured profile height without making it manual', async () => { + const remoteOrder = deferred() + const remoteProfile = deferred() + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ + enabled: enabledOnlyLibrary, + items: { library: { x: 0, y: 0, w: 12 } }, + updatedAt: 10, + }), + ) + localStorage.setItem('MP_DASHBOARD_GRID_AUTO_HEIGHTS', JSON.stringify({ library: 27 })) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') return remoteOrder.promise + if (url === '/user/config/DashboardGridLayout') return remoteProfile.promise + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + const { container } = await renderDashboard() + const libraryItem = container.querySelector('.dashboard-grid-item[gs-id="library"]') + + expect(libraryItem).toHaveAttribute('gs-h', '27') + expect(libraryItem).not.toHaveClass('is-manual-height') + + remoteOrder.resolve({ data: { value: [{ id: 'library', key: '' }] } }) + remoteProfile.resolve({ + data: { + value: { + enabled: enabledOnlyLibrary, + items: { library: { x: 0, y: 0, w: 12 } }, + updatedAt: 10, + }, + }, + }) + await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled()) + }) + + it('applies a newer remote profile and refreshes the local first-frame cache', async () => { + const remoteProfile = deferred() + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ + enabled: enabledOnlySystemInfo, + items: { systemInfo: { x: 8, y: 0, w: 4, h: 6 } }, + updatedAt: 10, + }), + ) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') { + return { data: { value: [{ id: 'quickActions', key: '' }] } } + } + if (url === '/user/config/DashboardGridLayout') return remoteProfile.promise + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + await renderDashboard() + + expect(screen.getByTestId('dashboard-item')).toHaveAttribute('data-dashboard-id', 'systemInfo') + remoteProfile.resolve({ + data: { + value: { + enabled: { ...enabledOnlySystemInfo, quickActions: true, systemInfo: false }, + items: { quickActions: { x: 8, y: 0, w: 4, h: 5 } }, + updatedAt: 20, + }, + }, + }) + await waitFor(() => + expect(screen.getByTestId('dashboard-item')).toHaveAttribute('data-dashboard-id', 'quickActions'), + ) + expect(JSON.parse(localStorage.getItem('MP_DASHBOARD_GRID_LAYOUT') || '{}')).toEqual({ + enabled: { ...enabledOnlySystemInfo, quickActions: true, systemInfo: false }, + items: { quickActions: { x: 8, y: 0, w: 4, h: 5 } }, + updatedAt: 20, + }) + }) + + it('restores default coordinates when a newer remote profile clears cached layout overrides', async () => { + const remoteOrder = deferred() + const remoteProfile = deferred() + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ + enabled: enabledOnlySystemInfo, + items: { systemInfo: { x: 0, y: 12, w: 8, h: 6 } }, + updatedAt: 10, + }), + ) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') return remoteOrder.promise + if (url === '/user/config/DashboardGridLayout') return remoteProfile.promise + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + await renderDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(1)) + expect(mocks.grid.makeWidget.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ id: 'systemInfo', x: 0, y: 12, w: 8 }), + ) + mocks.grid.load.mockClear() + + remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } }) + remoteProfile.resolve({ + data: { + value: { + enabled: enabledOnlySystemInfo, + items: {}, + updatedAt: 20, + }, + }, + }) + await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled()) + + let loadedWidgets = mocks.grid.load.mock.calls.at(-1)?.[0] as Array> + expect(loadedWidgets.find(widget => widget.id === 'systemInfo')).toEqual( + expect.objectContaining({ x: 8, y: 27, w: 4 }), + ) + + mocks.grid.load.mockClear() + await fireEvent.click(document.querySelector('.compact-fab--primary') as HTMLElement) + await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled()) + + loadedWidgets = mocks.grid.load.mock.calls.at(-1)?.[0] as Array> + expect(loadedWidgets.find(widget => widget.id === 'systemInfo')).toEqual( + expect.objectContaining({ x: 8, y: 27, w: 4 }), + ) + }) + + it('falls back to the default dashboard after an uncached remote miss', async () => { + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder' || url === '/user/config/DashboardGridLayout') { + return { data: {} } + } + if (url === '/user/config/Dashboard') return { data: {} } + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + await renderDashboard() + + await waitFor(() => expect(screen.getAllByTestId('dashboard-item')).toHaveLength(10)) + }) + + it('registers the default desktop widgets in target position order', async () => { + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder' || url === '/user/config/DashboardGridLayout') { + return { data: {} } + } + if (url === '/user/config/Dashboard') return { data: {} } + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + await renderDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(10)) + + expect(mocks.grid.makeWidget.mock.calls.map(([, widget]) => widget.id)).toEqual([ + 'storage', + 'mediaStatistic', + 'speed', + 'recentImports', + 'scheduler', + 'memory', + 'cpu', + 'quickActions', + 'systemInfo', + 'mediaRecommend', + ]) + const desktopReturnWidgets = mocks.grid.load.mock.calls.at(-1)?.[0] as Array> + expect(desktopReturnWidgets).toHaveLength(10) + expect(desktopReturnWidgets.find(widget => widget.id === 'mediaRecommend')).toEqual( + expect.objectContaining({ x: 0, y: 33, w: 8 }), + ) + }) + + it('registers cached automatic heights without forcing independent columns to share a baseline', async () => { + localStorage.setItem( + 'MP_DASHBOARD_GRID_AUTO_HEIGHTS', + JSON.stringify({ + cpu: 18, + mediaRecommend: 27, + mediaStatistic: 11, + memory: 18, + quickActions: 9, + recentImports: 27, + scheduler: 23, + speed: 19, + storage: 11, + systemInfo: 10, + }), + ) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder' || url === '/user/config/DashboardGridLayout') { + return { data: {} } + } + if (url === '/user/config/Dashboard') return { data: {} } + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + await renderDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(10)) + await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled()) + + const loadedWidgets = mocks.grid.load.mock.calls.at(-1)?.[0] as Array> + const widgets = Object.fromEntries(loadedWidgets.map(widget => [widget.id, widget])) as Record< + string, + Record + > + expect(widgets.storage).toEqual(expect.objectContaining({ x: 0, y: 0, w: 4, h: 11 })) + expect(widgets.mediaStatistic).toEqual(expect.objectContaining({ x: 4, y: 0, w: 8, h: 11 })) + expect(widgets.speed).toEqual(expect.objectContaining({ x: 0, y: 7, w: 4, h: 19 })) + expect(widgets.recentImports).toEqual(expect.objectContaining({ x: 4, y: 7, w: 4, h: 27 })) + expect(widgets.scheduler).toEqual(expect.objectContaining({ x: 8, y: 7, w: 4, h: 23 })) + expect(widgets.memory).toEqual(expect.objectContaining({ x: 0, y: 22, w: 4, h: 18 })) + expect(widgets.cpu).toEqual(expect.objectContaining({ x: 4, y: 22, w: 4, h: 18 })) + expect(widgets.quickActions).toEqual(expect.objectContaining({ x: 8, y: 22, w: 4, h: 9 })) + expect(widgets.systemInfo).toEqual(expect.objectContaining({ x: 8, y: 27, w: 4, h: 10 })) + expect(widgets.mediaRecommend).toEqual(expect.objectContaining({ x: 0, y: 33, w: 8, h: 27 })) + await waitFor(() => { + const autoHeights = JSON.parse(localStorage.getItem('MP_DASHBOARD_GRID_AUTO_HEIGHTS') || '{}') + expect(autoHeights.speed).toBe(19) + expect(autoHeights.scheduler).toBe(23) + expect(widgets.speed.h).toBe(19) + expect(widgets.scheduler.h).toBe(23) + }) + }) + + it('keeps a live automatic height when entering layout editing', async () => { + const remoteOrder = deferred() + const remoteProfile = deferred() + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ + enabled: enabledOnlySystemInfo, + items: { systemInfo: { x: 8, y: 0, w: 4 } }, + updatedAt: 10, + }), + ) + localStorage.setItem('MP_DASHBOARD_GRID_AUTO_HEIGHTS', JSON.stringify({ systemInfo: 6 })) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') return remoteOrder.promise + if (url === '/user/config/DashboardGridLayout') return remoteProfile.promise + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + const { container } = await renderDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(1)) + const element = container.querySelector }>( + '.dashboard-grid-item[gs-id="systemInfo"]', + ) + expect(element?.gridstackNode?.h).toBe(6) + + if (element?.gridstackNode) element.gridstackNode.h = 13 + mocks.grid.load.mockClear() + await fireEvent.click(document.querySelector('.compact-fab--primary') as HTMLElement) + await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled()) + + const loadedWidgets = mocks.grid.load.mock.calls.at(-1)?.[0] as Array> + expect(loadedWidgets.find(widget => widget.id === 'systemInfo')).toEqual(expect.objectContaining({ h: 13 })) + }) + + it('keeps a live automatic height for the default layout when entering editing', async () => { + const remoteOrder = deferred() + const remoteProfile = deferred() + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ enabled: enabledOnlySystemInfo, items: {}, updatedAt: 10 }), + ) + localStorage.setItem('MP_DASHBOARD_GRID_AUTO_HEIGHTS', JSON.stringify({ systemInfo: 6 })) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') return remoteOrder.promise + if (url === '/user/config/DashboardGridLayout') return remoteProfile.promise + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + const { container } = await renderDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(1)) + const element = container.querySelector }>( + '.dashboard-grid-item[gs-id="systemInfo"]', + ) + expect(element?.gridstackNode?.h).toBe(6) + + if (element?.gridstackNode) element.gridstackNode.h = 13 + mocks.grid.load.mockClear() + await fireEvent.click(document.querySelector('.compact-fab--primary') as HTMLElement) + await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled()) + + const loadedWidgets = mocks.grid.load.mock.calls.at(-1)?.[0] as Array> + expect(loadedWidgets.find(widget => widget.id === 'systemInfo')).toEqual(expect.objectContaining({ h: 13 })) + }) + + it('registers an arbitrary saved layout by position instead of settings order', async () => { + const enabled = { + ...enabledOnlySystemInfo, + quickActions: true, + recentImports: true, + } + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ + enabled, + items: { + quickActions: { x: 7, y: 0, w: 5, h: 8 }, + recentImports: { x: 1, y: 12, w: 8, h: 19 }, + systemInfo: { x: 3, y: 40, w: 6, h: 9 }, + }, + updatedAt: 10, + }), + ) + localStorage.setItem( + 'MP_DASHBOARD_ORDER', + JSON.stringify([ + { id: 'systemInfo', key: '' }, + { id: 'recentImports', key: '' }, + { id: 'quickActions', key: '' }, + ]), + ) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') { + return { data: { value: JSON.parse(localStorage.getItem('MP_DASHBOARD_ORDER') || '[]') } } + } + if (url === '/user/config/DashboardGridLayout') { + return { data: { value: JSON.parse(localStorage.getItem('MP_DASHBOARD_GRID_LAYOUT') || '{}') } } + } + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + await renderDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(3)) + + expect(mocks.grid.makeWidget.mock.calls.map(([, widget]) => widget.id)).toEqual([ + 'quickActions', + 'recentImports', + 'systemInfo', + ]) + const widgets = Object.fromEntries( + mocks.grid.makeWidget.mock.calls.map(([, widget]) => [widget.id, widget]), + ) as Record> + expect(widgets.quickActions).toEqual(expect.objectContaining({ x: 7, y: 0, w: 5, h: 8 })) + expect(widgets.recentImports).toEqual(expect.objectContaining({ x: 1, y: 12, w: 8, h: 19 })) + expect(widgets.systemInfo).toEqual(expect.objectContaining({ x: 3, y: 40, w: 6, h: 9 })) + }) + + it('ignores an initial profile response after the viewport switches to another profile', async () => { + const initialOrder = deferred() + const initialDesktopProfile = deferred() + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT_MOBILE', + JSON.stringify({ + enabled: enabledOnlyLibrary, + items: { library: { x: 0, y: 0, w: 1, h: 20 } }, + updatedAt: 20, + }), + ) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') return initialOrder.promise + if (url === '/user/config/DashboardGridLayout') return initialDesktopProfile.promise + if (url === '/user/config/DashboardGridLayoutMobile') { + return { + data: { + value: { + enabled: enabledOnlyLibrary, + items: { library: { x: 0, y: 0, w: 1, h: 20 } }, + updatedAt: 20, + }, + }, + } + } + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + await renderDashboard() + mocks.displayWidth.value = 390 + expect(await screen.findByTestId('dashboard-item')).toHaveAttribute('data-dashboard-id', 'library') + + initialOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } }) + initialDesktopProfile.resolve({ + data: { + value: { + enabled: enabledOnlySystemInfo, + items: { systemInfo: { x: 8, y: 0, w: 4, h: 6 } }, + updatedAt: 30, + }, + }, + }) + + await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('/plugin/dashboard/meta')) + expect(screen.getByTestId('dashboard-item')).toHaveAttribute('data-dashboard-id', 'library') + expect(mocks.apiPost).not.toHaveBeenCalledWith( + '/user/config/DashboardGridLayoutMobile', + expect.objectContaining({ items: { systemInfo: expect.anything() } }), + ) + }) + + it('rebuilds each responsive profile immediately before remote validation', async () => { + const mobileProfile = deferred() + const desktopReturnProfile = deferred() + let desktopProfileReads = 0 + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder' || url === '/user/config/Dashboard') { + return { data: {} } + } + if (url === '/user/config/DashboardGridLayout') { + desktopProfileReads += 1 + + return desktopProfileReads === 1 ? { data: {} } : desktopReturnProfile.promise + } + if (url === '/user/config/DashboardGridLayoutMobile') return mobileProfile.promise + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + + await renderDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(10)) + mocks.grid.column.mockClear() + mocks.grid.makeWidget.mockClear() + mocks.grid.removeAll.mockClear() + mocks.grid.update.mockClear() + + mocks.displayWidth.value = 390 + await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('/user/config/DashboardGridLayoutMobile')) + 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(false) + expect(mocks.grid.makeWidget.mock.calls.map(([, widget]) => widget.id)).toEqual([ + 'storage', + 'mediaStatistic', + 'mediaRecommend', + 'speed', + 'scheduler', + 'cpu', + 'memory', + 'recentImports', + 'quickActions', + 'systemInfo', + ]) + mocks.grid.makeWidget.mockClear() + + mobileProfile.resolve({ data: {} }) + await waitFor(() => expect(mocks.grid.removeAll).toHaveBeenCalledTimes(2)) + expect(mocks.grid.makeWidget.mock.calls.map(([, widget]) => widget.id)).toEqual([ + 'storage', + 'mediaStatistic', + 'mediaRecommend', + 'speed', + 'scheduler', + 'cpu', + 'memory', + 'recentImports', + 'quickActions', + 'systemInfo', + ]) + + mocks.grid.column.mockClear() + mocks.grid.makeWidget.mockClear() + mocks.grid.removeAll.mockClear() + mocks.grid.update.mockClear() + + mocks.displayWidth.value = 1512 + await waitFor(() => expect(desktopProfileReads).toBe(2)) + await waitFor(() => expect(mocks.grid.column).toHaveBeenCalledWith(12, 'moveScale')) + await waitFor(() => expect(mocks.grid.removeAll).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(10)) + expect(mocks.grid.makeWidget.mock.calls.map(([, widget]) => widget.id)).toEqual([ + 'storage', + 'mediaStatistic', + 'speed', + 'recentImports', + 'scheduler', + 'memory', + 'cpu', + 'quickActions', + 'systemInfo', + 'mediaRecommend', + ]) + mocks.grid.makeWidget.mockClear() + + desktopReturnProfile.resolve({ data: {} }) + await waitFor(() => expect(mocks.grid.removeAll).toHaveBeenCalledTimes(2)) + expect(mocks.grid.makeWidget.mock.calls.map(([, widget]) => widget.id)).toEqual([ + 'storage', + 'mediaStatistic', + 'speed', + 'recentImports', + 'scheduler', + 'memory', + 'cpu', + 'quickActions', + 'systemInfo', + 'mediaRecommend', + ]) + }) + + it('rebuilds a legacy responsive profile before its migration save settles', async () => { + const mobileProfile = deferred() + const migrationSave = deferred() + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ + enabled: enabledOnlySystemInfo, + items: { systemInfo: { x: 8, y: 0, w: 4, h: 6 } }, + updatedAt: 10, + }), + ) + localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify([{ id: 'systemInfo', key: '' }])) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') { + return { data: { value: [{ id: 'systemInfo', key: '' }] } } + } + if (url === '/user/config/DashboardGridLayout') { + return { + data: { + value: { + enabled: enabledOnlySystemInfo, + items: { systemInfo: { x: 8, y: 0, w: 4, h: 6 } }, + updatedAt: 20, + }, + }, + } + } + if (url === '/user/config/DashboardGridLayoutMobile') return mobileProfile.promise + if (url === '/user/config/Dashboard') return { data: { value: enabledOnlyLibrary } } + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + mocks.apiPost.mockImplementation((url: string) => { + if (url === '/user/config/DashboardGridLayoutMobile') return migrationSave.promise + throw new Error('Unexpected POST ' + url) + }) + + await renderDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(1)) + + mocks.displayWidth.value = 390 + await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('/user/config/DashboardGridLayoutMobile')) + await waitFor(() => expect(mocks.grid.removeAll).toHaveBeenCalledTimes(1)) + mocks.grid.removeAll.mockClear() + mocks.grid.load.mockClear() + + mobileProfile.resolve({ + data: { + value: { + items: { library: { x: 0, y: 0, w: 1, h: 20 } }, + updatedAt: 30, + }, + }, + }) + + await waitFor(() => + expect(mocks.apiPost).toHaveBeenCalledWith( + '/user/config/DashboardGridLayoutMobile', + expect.objectContaining({ enabled: enabledOnlyLibrary }), + ), + ) + await waitFor(() => expect(mocks.grid.removeAll).toHaveBeenCalledTimes(1)) + expect(mocks.grid.load.mock.calls.at(-1)?.[0]).toEqual([ + expect.objectContaining({ id: 'library', x: 0, y: 0, w: 1, h: 20 }), + ]) + + migrationSave.resolve({ data: {} }) + }) + + it('keeps a newer remote legacy layout when its merged migration save fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT', + JSON.stringify({ + enabled: enabledOnlySystemInfo, + items: { systemInfo: { x: 8, y: 0, w: 4, h: 6 } }, + updatedAt: 10, + }), + ) + localStorage.setItem( + 'MP_DASHBOARD_GRID_LAYOUT_MOBILE', + JSON.stringify({ + enabled: enabledOnlyLibrary, + items: { library: { x: 0, y: 0, w: 1, h: 10 } }, + updatedAt: 10, + }), + ) + localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify([{ id: 'systemInfo', key: '' }])) + mocks.apiGet.mockImplementation((url: string) => { + if (url === '/user/config/DashboardOrder') { + return { data: { value: [{ id: 'systemInfo', key: '' }] } } + } + if (url === '/user/config/DashboardGridLayout') { + return { + data: { + value: { + enabled: enabledOnlySystemInfo, + items: { systemInfo: { x: 8, y: 0, w: 4, h: 6 } }, + updatedAt: 20, + }, + }, + } + } + if (url === '/user/config/DashboardGridLayoutMobile') { + return { + data: { + value: { + items: { library: { x: 0, y: 5, w: 1, h: 20 } }, + updatedAt: 30, + }, + }, + } + } + if (url === '/plugin/dashboard/meta') return [] + throw new Error('Unexpected GET ' + url) + }) + mocks.apiPost.mockImplementation((url: string) => { + if (url === '/user/config/DashboardGridLayoutMobile') { + return Promise.reject(new Error('migration save failed')) + } + throw new Error('Unexpected POST ' + url) + }) + + await renderDashboard() + await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(1)) + + mocks.displayWidth.value = 390 + await waitFor(() => + expect(mocks.apiPost).toHaveBeenCalledWith( + '/user/config/DashboardGridLayoutMobile', + expect.objectContaining({ enabled: enabledOnlyLibrary, updatedAt: 30 }), + ), + ) + await waitFor(() => { + const loadedWidgets = mocks.grid.load.mock.calls.at(-1)?.[0] as Array> | undefined + + expect(loadedWidgets?.find(widget => widget.id === 'library')).toEqual( + expect.objectContaining({ x: 0, y: 5, w: 1, h: 20 }), + ) + }) + await waitFor(() => expect(consoleError).toHaveBeenCalledWith(expect.any(Error))) + consoleError.mockRestore() + }) +}) diff --git a/src/pages/dashboard.vue b/src/pages/dashboard.vue index 9753a65a..239943b7 100644 --- a/src/pages/dashboard.vue +++ b/src/pages/dashboard.vue @@ -25,9 +25,7 @@ const { t } = useI18n() const { appMode } = usePWA() const display = useDisplay() const userStore = useUserStore() -const userPermissionContext = computed(() => - buildUserPermissionContext(userStore.superUser, userStore.permissions), -) +const userPermissionContext = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions)) const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin')) const canDiscovery = computed(() => hasPermission(userPermissionContext.value, 'discovery')) @@ -45,6 +43,7 @@ const DASHBOARD_GRID_CONTENT_RESIZE_THRESHOLD = 4 const DASHBOARD_ENABLE_STORAGE_KEY = 'MP_DASHBOARD' const DASHBOARD_ORDER_STORAGE_KEY = 'MP_DASHBOARD_ORDER' const DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX = 'MP_DASHBOARD_GRID_LAYOUT' +const DASHBOARD_GRID_AUTO_HEIGHT_STORAGE_KEY_PREFIX = 'MP_DASHBOARD_GRID_AUTO_HEIGHTS' const DASHBOARD_ENABLE_CONFIG_KEY = 'Dashboard' const DASHBOARD_ORDER_CONFIG_KEY = 'DashboardOrder' const DASHBOARD_GRID_LAYOUT_CONFIG_KEY = 'DashboardGridLayout' @@ -53,6 +52,7 @@ const DASHBOARD_GRID_LAYOUT_CONFIG_KEY_PREFIX = 'DashboardGridLayout' type DashboardEnableConfig = Record type DashboardOrderConfig = { id: string; key: string }[] type DashboardGridLayoutConfig = Record +type DashboardGridAutoHeightConfig = Record type DashboardConfigNormalizer = (value: unknown) => T | undefined type DashboardConfigRemoteValueBuilder = (value: T) => unknown type DashboardLayoutProfile = 'desktop' | 'tablet' | 'mobile' @@ -97,6 +97,9 @@ interface DashboardGridItem { // 是否处于仪表板布局编辑模式 const isLayoutEditing = ref(false) +// 首次布局完成后触发轻量整体入场,不延迟或隐藏渐进渲染内容。 +const isDashboardGridRevealed = ref(false) + // 是否发送请求的总开关 const isRequest = ref(true) @@ -121,6 +124,9 @@ const isPersistingDashboardGridLayoutFromGrid = ref(false) // 仪表板本地布局覆盖配置 const dashboardGridLayout = ref({}) +// 当前设备档位最近一次测得的自动行高,仅作为下次首屏预排提示,不改变手动高度语义。 +const dashboardGridAutoHeights = shallowRef({}) + // 最近一次已确认持久化的仪表板布局,用于编辑模式下避开临时布局草稿。 let persistedDashboardGridLayout: DashboardGridLayoutConfig = {} @@ -141,9 +147,15 @@ const dashboardGridObservedContentHeights = new Map() let dashboardGridContentObserver: ResizeObserver | null = null let dashboardGridContentResizeFrame: number | null = null let dashboardGridResizeRefreshFrame: number | null = null +let dashboardGridAnimationFrame: number | null = null +let dashboardGridEntranceFrame: number | null = null let dashboardRevealFrame: number | null = null let isDashboardRevealPending = false let dashboardProfileSaveQueue = Promise.resolve() +// 档位切换必须等目标配置就绪后一次性重建,避免响应式列变化与 Vue 深度监听交叉改写节点。 +let isSwitchingDashboardLayoutProfile = false +// 应用档位配置后的首次同步必须恢复缺失覆盖项的默认位置,不能沿用上一份配置的节点坐标。 +let shouldRestoreDashboardGridProfileDefaults = false // 标记最近一次响应式档位切换,避免快速缩放时较早的异步配置覆盖最新档位。 let dashboardLayoutProfileSwitchId = 0 @@ -368,9 +380,7 @@ function scheduleDashboardReveal() { syncDashboardFillContentState() resizeAutoDashboardItemsToContent() - if (typeof window === 'undefined') { - return - } + if (typeof window === 'undefined') return dashboardRevealFrame = window.requestAnimationFrame(() => { dashboardRevealFrame = null @@ -379,6 +389,37 @@ function scheduleDashboardReveal() { }) } +// 首次 GridStack 坐标提交后的下一帧立即入场,不等待卡片内部异步数据。 +function scheduleDashboardGridEntrance() { + if (isDashboardGridRevealed.value || dashboardGridEntranceFrame !== null || typeof window === 'undefined') return + + dashboardGridEntranceFrame = requestAnimationFrame(() => { + dashboardGridEntranceFrame = null + isDashboardGridRevealed.value = true + }) +} + +// 程序化批量布局不播放中间态;稳定后恢复用户拖拽、缩放和让位动画。 +function pauseDashboardGridAnimation() { + if (dashboardGridAnimationFrame !== null) { + cancelAnimationFrame(dashboardGridAnimationFrame) + dashboardGridAnimationFrame = null + } + dashboardGrid.value?.setAnimation(false) +} + +// 动画恢复延后一帧,确保 GridStack 已提交最终坐标后才重新响应交互。 +function scheduleDashboardGridAnimationResume() { + if (typeof window === 'undefined') return + if (dashboardGridAnimationFrame !== null) cancelAnimationFrame(dashboardGridAnimationFrame) + + dashboardGridAnimationFrame = requestAnimationFrame(() => { + dashboardGridAnimationFrame = null + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false + dashboardGrid.value?.setAnimation(!reduceMotion) + }) +} + // 标记单个仪表板项目已经完成首次组件加载。 function markDashboardGridItemLoaded(id: string) { if (loadedDashboardGridItemIds.value.has(id)) return @@ -489,6 +530,19 @@ function normalizeDashboardGridLayout(value: unknown): DashboardGridLayoutConfig return normalizedLayout } +// 校验本地自动行高提示,异常或过期字段不得进入 GridStack 首屏配置。 +function normalizeDashboardGridAutoHeightConfig(value: unknown): DashboardGridAutoHeightConfig | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + + return Object.entries(value).reduce((config, [id, height]) => { + if (!id || !Number.isFinite(Number(height))) return config + + config[id] = clampGridNumber(height, 1, 96, DASHBOARD_GRID_FALLBACK_ROWS) + + return config + }, {}) +} + // 校验并归一化单个设备档位的仪表盘配置,兼容旧版只保存 Grid 布局的数据。 function normalizeDashboardProfileConfig(value: unknown): DashboardProfileConfig | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined @@ -601,6 +655,13 @@ function getDashboardGridLayoutStorageKey(profile: DashboardLayoutProfile) { return `${DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX}_${profile.toUpperCase()}` } +// 自动行高提示按设备档位隔离,避免桌面内容密度覆盖手机和平板的首屏尺寸。 +function getDashboardGridAutoHeightStorageKey(profile: DashboardLayoutProfile) { + if (profile === 'desktop') return DASHBOARD_GRID_AUTO_HEIGHT_STORAGE_KEY_PREFIX + + return `${DASHBOARD_GRID_AUTO_HEIGHT_STORAGE_KEY_PREFIX}_${profile.toUpperCase()}` +} + // 获取布局档位对应的用户配置键,桌面沿用旧键以兼容已同步配置。 function getDashboardGridLayoutConfigKey(profile: DashboardLayoutProfile) { if (profile === 'desktop') return DASHBOARD_GRID_LAYOUT_CONFIG_KEY @@ -635,7 +696,8 @@ async function loadDashboardProfileConfig(profile: DashboardLayoutProfile) { saveLocalDashboardConfig(storageKey, profileConfig) if (remoteConfig.enabled === undefined && localConfig?.enabled !== undefined) { - await queueDashboardProfileRemoteSave(configKey, profileConfig) + // 远端布局已是当前权威结果;兼容字段回填失败不能让本次加载退回旧的本地布局。 + void queueDashboardProfileRemoteSave(configKey, profileConfig).catch(error => console.error(error)) } return profileConfig @@ -670,6 +732,47 @@ function saveLocalDashboardConfig(storageKey: string, value: unknown) { localStorage.setItem(storageKey, JSON.stringify(value)) } +// 将同一份配置状态应用到首屏预水合和远端校验结果,保证两条路径的排序与迁移语义一致。 +function applyDashboardConfig( + profileConfig: DashboardProfileConfig | undefined, + legacyEnable: DashboardEnableConfig | undefined, + order: DashboardOrderConfig | undefined, +) { + shouldRestoreDashboardGridProfileDefaults = true + if (order !== undefined) { + orderConfig.value = order + } + + const loadedLayout = profileConfig?.items ?? {} + dashboardGridLayout.value = loadedLayout + persistedDashboardGridLayout = cloneDashboardGridLayout(loadedLayout) + isDashboardGridLayoutResetDraft.value = false + enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable) + sortDashboardConfigs() +} + +// setup 阶段同步恢复当前设备档位,缓存命中时第一帧直接使用用户实际布局和显示项。 +function hydrateDashboardConfigFromLocal() { + dashboardLayoutProfile.value = resolveDashboardLayoutProfile() + const profileConfig = readLocalDashboardConfig( + getDashboardGridLayoutStorageKey(dashboardLayoutProfile.value), + normalizeDashboardProfileConfig, + ) + const legacyEnable = + profileConfig?.enabled === undefined + ? readLocalDashboardConfig(DASHBOARD_ENABLE_STORAGE_KEY, normalizeDashboardEnableConfig) + : undefined + const order = readLocalDashboardConfig(DASHBOARD_ORDER_STORAGE_KEY, normalizeDashboardOrderConfig) + dashboardGridAutoHeights.value = + readLocalDashboardConfig( + getDashboardGridAutoHeightStorageKey(dashboardLayoutProfile.value), + normalizeDashboardGridAutoHeightConfig, + ) ?? {} + applyDashboardConfig(profileConfig, legacyEnable, order) + + return profileConfig !== undefined +} + // 将仪表板配置写入用户配置,用于跨浏览器共享。 async function saveUserDashboardConfig(configKey: string, value: unknown) { await api.post(`/user/config/${configKey}`, value) @@ -757,7 +860,7 @@ function getDefaultDashboardGridWidth(item: DashboardItem) { if (profile === 'mobile') return 1 const columns = getDashboardGridColumnsForProfile(profile) - const requestedWidth = profile === 'tablet' ? item.cols?.sm ?? item.cols?.md : item.cols?.md ?? item.cols?.cols + const requestedWidth = profile === 'tablet' ? (item.cols?.sm ?? item.cols?.md) : (item.cols?.md ?? item.cols?.cols) return clampGridNumber(requestedWidth, 1, columns, columns) } @@ -773,7 +876,8 @@ function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWid const defaultLayout = dashboardLayoutProfile.value === 'desktop' ? DASHBOARD_DESKTOP_DEFAULT_LAYOUT[id] : undefined const gridColumns = getDashboardGridColumnsForProfile(dashboardLayoutProfile.value) const width = savedLayout?.w ?? defaultLayout?.w ?? getDefaultDashboardGridWidth(item) - const height = savedLayout?.h ?? defaultLayout?.h ?? getDefaultDashboardGridRows(item) + const height = + savedLayout?.h ?? dashboardGridAutoHeights.value[id] ?? defaultLayout?.h ?? getDefaultDashboardGridRows(item) const normalizedWidth = clampGridNumber(width, 1, gridColumns, gridColumns) const widget: GridStackWidget = { id, @@ -922,31 +1026,35 @@ function toggleDashboardLayoutEditing() { // 加载用户监控面板配置,优先使用服务端用户配置以支持跨浏览器同步。 async function loadDashboardConfig() { - dashboardLayoutProfile.value = resolveDashboardLayoutProfile() - // 顺序配置 - const order = await loadSharedDashboardConfig( + const profile = resolveDashboardLayoutProfile() + const profileSwitchId = dashboardLayoutProfileSwitchId + dashboardLayoutProfile.value = profile + // 顺序和当前设备档位互不依赖,并行校验可缩短无本地缓存时的首屏等待时间。 + const orderPromise = loadSharedDashboardConfig( DASHBOARD_ORDER_CONFIG_KEY, DASHBOARD_ORDER_STORAGE_KEY, normalizeDashboardOrderConfig, ) - if (order !== undefined) { + const profileConfigPromise = loadDashboardProfileConfig(profile) + // 共享顺序不得等待单个档位请求,否则设置保存可能把尚未应用的旧顺序写回服务端。 + void orderPromise.then(order => { + if (order === undefined) return + orderConfig.value = order + sortDashboardConfigs() + }) + const profileConfig = await profileConfigPromise + if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== profile) { + return } - // 设备档位配置同时承载 Grid 布局和显示项,显示项缺失时从旧版全局配置迁移。 - const profileConfig = await loadDashboardProfileConfig(dashboardLayoutProfile.value) + const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined - const loadedLayout = profileConfig?.items ?? {} - dashboardGridLayout.value = loadedLayout - persistedDashboardGridLayout = cloneDashboardGridLayout(loadedLayout) - isDashboardGridLayoutResetDraft.value = false - enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable) + if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== profile) return + + applyDashboardConfig(profileConfig, legacyEnable, undefined) if (profileConfig?.enabled === undefined && legacyEnable !== undefined) { await saveDashboardProfileConfig() } - // 排序 - if (orderConfig.value) { - sortDashboardConfigs() - } } // 按order的顺序对dashboardConfigs进行排序 @@ -1084,18 +1192,9 @@ function initializeDashboardGrid() { dashboardGrid.value = GridStack.init( { - animate: true, + animate: false, cellHeight: DASHBOARD_GRID_CELL_HEIGHT, - column: DASHBOARD_GRID_COLUMNS, - columnOpts: { - breakpointForWindow: true, - breakpoints: [ - { w: DASHBOARD_GRID_MOBILE_BREAKPOINT, c: 1, layout: 'list' }, - { w: DASHBOARD_GRID_TABLET_BREAKPOINT, c: 6, layout: 'moveScale' }, - { w: DASHBOARD_GRID_DESKTOP_BREAKPOINT, c: DASHBOARD_GRID_COLUMNS, layout: 'moveScale' }, - ], - layout: 'moveScale', - }, + column: getDashboardGridColumnsForProfile(dashboardLayoutProfile.value), draggable: { cancel: 'input,textarea,button,select,option,a,.dashboard-grid-no-drag', handle: '.dashboard-grid-drag-handle', @@ -1130,18 +1229,32 @@ function updateDashboardGridEditableState(editable: boolean) { } // 将 Vue 渲染出的仪表板节点同步注册到 GridStack。 -async function syncDashboardGrid() { +async function syncDashboardGrid(resumeAnimation = true) { const grid = dashboardGrid.value const gridElement = dashboardGridRef.value if (!grid || !gridElement) return + const restoreProfileDefaults = shouldRestoreDashboardGridProfileDefaults + shouldRestoreDashboardGridProfileDefaults = false + pauseDashboardGridAnimation() isSyncingDashboardGrid.value = true await nextTick() syncDashboardFillContentState() const items = dashboardGridItems.value const itemMap = new Map(items.map(item => [item.id, item])) - const elements = Array.from(gridElement.querySelectorAll('.dashboard-grid-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) + ) + }, + ) try { grid.batchUpdate() @@ -1162,14 +1275,15 @@ async function syncDashboardGrid() { if (!item) return const widget = { ...item.widget } - if (element.gridstackNode && !dashboardGridLayout.value[id]) { + if (element.gridstackNode && !dashboardGridLayout.value[id] && !restoreProfileDefaults) { delete widget.autoPosition - delete widget.x - delete widget.y + widget.x = element.gridstackNode.x + widget.y = element.gridstackNode.y } if (element.gridstackNode && !hasManualDashboardGridHeight(id)) { widget.h = element.gridstackNode.h } + synchronizedWidgets.set(id, widget) if (element.gridstackNode) { grid.update(element, widget) @@ -1179,6 +1293,11 @@ async function syncDashboardGrid() { }) grid.batchUpdate(false) + // 完整档位布局由 GridStack 按坐标统一恢复,避免逐个注册时的页面节点顺序污染跨列和响应式布局。 + grid.load( + items.map(item => ({ ...(synchronizedWidgets.get(item.id) ?? item.widget) })), + false, + ) updateDashboardGridEditableState(isLayoutEditing.value) syncDashboardFillContentState() observeDashboardGridContent() @@ -1187,8 +1306,10 @@ async function syncDashboardGrid() { resizeAutoDashboardItemsToContent() scheduleDashboardReveal() }) + scheduleDashboardGridEntrance() } finally { isSyncingDashboardGrid.value = false + if (resumeAnimation) scheduleDashboardGridAnimationResume() } } @@ -1261,7 +1382,15 @@ function scheduleDashboardItemContentResize(element: GridItemHTMLElement) { function resizeDashboardItemToContent(element: GridItemHTMLElement) { const grid = dashboardGrid.value const id = element.getAttribute('gs-id') ?? '' - if (!grid || !id || isLayoutEditing.value || isDashboardGridResizing.value || hasManualDashboardGridHeight(id)) return + if ( + !grid || + !id || + isSwitchingDashboardLayoutProfile || + isLayoutEditing.value || + isDashboardGridResizing.value || + hasManualDashboardGridHeight(id) + ) + return syncDashboardFillContentState(element) const shouldMeasureFillContent = element.classList.contains('has-fill-content') @@ -1271,6 +1400,18 @@ function resizeDashboardItemToContent(element: GridItemHTMLElement) { try { grid.resizeToContent(element) + const measuredHeight = element.gridstackNode?.h + if ( + measuredHeight !== undefined && + measuredHeight !== dashboardGridAutoHeights.value[id] && + Number.isFinite(measuredHeight) + ) { + dashboardGridAutoHeights.value[id] = clampGridNumber(measuredHeight, 1, 96, getDefaultDashboardGridRows()) + saveLocalDashboardConfig( + getDashboardGridAutoHeightStorageKey(dashboardLayoutProfile.value), + dashboardGridAutoHeights.value, + ) + } } finally { if (shouldMeasureFillContent) { element.classList.remove('is-measuring-content') @@ -1410,7 +1551,7 @@ watch( dashboardGridItems, () => { syncDashboardLoadedItemIds() - if (!isPersistingDashboardGridLayoutFromGrid.value) { + if (!isSwitchingDashboardLayoutProfile && !isPersistingDashboardGridLayoutFromGrid.value) { syncDashboardGrid() } scheduleDashboardReveal() @@ -1426,32 +1567,65 @@ watch( // GridStack 可能已先完成列数压缩;档位切换只读取目标配置,不能保存当前自动重排结果。 const profileSwitchId = ++dashboardLayoutProfileSwitchId + isSwitchingDashboardLayoutProfile = true dashboardLayoutProfile.value = nextProfile - const profileConfig = await loadDashboardProfileConfig(nextProfile) - if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return + try { + const localProfileConfig = readLocalDashboardConfig( + getDashboardGridLayoutStorageKey(nextProfile), + normalizeDashboardProfileConfig, + ) + const localLegacyEnable = + localProfileConfig?.enabled === undefined + ? readLocalDashboardConfig(DASHBOARD_ENABLE_STORAGE_KEY, normalizeDashboardEnableConfig) + : undefined + const nextAutoHeights = + readLocalDashboardConfig( + getDashboardGridAutoHeightStorageKey(nextProfile), + normalizeDashboardGridAutoHeightConfig, + ) ?? {} + 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() + if (profileSwitchId === dashboardLayoutProfileSwitchId) { + isSwitchingDashboardLayoutProfile = false + } - const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined - if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return + const profileConfig = await loadDashboardProfileConfig(nextProfile) + if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return - const loadedLayout = profileConfig?.items ?? {} - dashboardGridLayout.value = loadedLayout - persistedDashboardGridLayout = cloneDashboardGridLayout(loadedLayout) - isDashboardGridLayoutResetDraft.value = false - enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable) - if (profileConfig?.enabled === undefined && legacyEnable !== undefined) { - await saveDashboardProfileConfig() + const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined + if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return + + isSwitchingDashboardLayoutProfile = true + applyDashboardConfig(profileConfig, legacyEnable, undefined) + if (profileConfig?.enabled === undefined && legacyEnable !== undefined) { + // 兼容配置的远端回填不得阻塞当前档位重建,否则慢请求会让 Vue 状态和 GridStack 节点暂时分离。 + void saveDashboardProfileConfig() + } + updateDashboardSettingsDialog() + pauseDashboardGridAnimation() + dashboardGrid.value?.removeAll(false, false) + await syncDashboardGrid(false) + scheduleDashboardGridAnimationResume() + notifyDashboardContentResize() + } finally { + if (profileSwitchId === dashboardLayoutProfileSwitchId) { + isSwitchingDashboardLayoutProfile = false + } } - updateDashboardSettingsDialog() - dashboardGrid.value?.column( - getDashboardGridColumnsForProfile(nextProfile), - getDashboardGridColumnLayout(nextProfile), - ) - dashboardGrid.value?.removeAll(false, false) - await syncDashboardGrid() - notifyDashboardContentResize() }, ) +hydrateDashboardConfigFromLocal() + onBeforeMount(async () => { await loadDashboardConfig() await getPluginDashboardMeta() @@ -1486,6 +1660,14 @@ onBeforeUnmount(() => { cancelAnimationFrame(dashboardGridResizeRefreshFrame) dashboardGridResizeRefreshFrame = null } + if (dashboardGridAnimationFrame !== null) { + cancelAnimationFrame(dashboardGridAnimationFrame) + dashboardGridAnimationFrame = null + } + if (dashboardGridEntranceFrame !== null) { + cancelAnimationFrame(dashboardGridEntranceFrame) + dashboardGridEntranceFrame = null + } if (dashboardRevealFrame !== null) { cancelAnimationFrame(dashboardRevealFrame) dashboardRevealFrame = null @@ -1500,7 +1682,11 @@ onBeforeUnmount(() => {