修复仪表盘首屏布局跳动与响应式错位 (#571)

This commit is contained in:
InfinityPacer
2026-07-22 06:59:12 +08:00
committed by GitHub
parent 9f375e8413
commit 0fb5bbebc6
2 changed files with 1225 additions and 65 deletions
+962
View File
@@ -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<Record<string, unknown>> },
getColumn: vi.fn(() => 12),
load: vi.fn(),
makeWidget: vi.fn((element: HTMLElement, widget: Record<string, unknown>) => {
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<string, unknown>) => {
Object.assign((element as HTMLElement & { gridstackNode?: Record<string, unknown> }).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<typeof import('vuetify')>()),
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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(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<unknown>()
const remoteProfile = deferred<unknown>()
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<unknown>()
const remoteProfile = deferred<unknown>()
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<unknown>()
const remoteProfile = deferred<unknown>()
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<unknown>()
const remoteProfile = deferred<unknown>()
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<unknown>()
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<unknown>()
const remoteProfile = deferred<unknown>()
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<Record<string, unknown>>
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<Record<string, unknown>>
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<Record<string, unknown>>
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<Record<string, unknown>>
const widgets = Object.fromEntries(loadedWidgets.map(widget => [widget.id, widget])) as Record<
string,
Record<string, unknown>
>
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<unknown>()
const remoteProfile = deferred<unknown>()
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<HTMLElement & { gridstackNode?: Record<string, unknown> }>(
'.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<Record<string, unknown>>
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<unknown>()
const remoteProfile = deferred<unknown>()
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<HTMLElement & { gridstackNode?: Record<string, unknown> }>(
'.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<Record<string, unknown>>
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<string, Record<string, unknown>>
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<unknown>()
const initialDesktopProfile = deferred<unknown>()
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<unknown>()
const desktopReturnProfile = deferred<unknown>()
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<unknown>()
const migrationSave = deferred<unknown>()
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<Record<string, unknown>> | 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()
})
})
+263 -65
View File
@@ -25,9 +25,7 @@ const { t } = useI18n()
const { appMode } = usePWA() const { appMode } = usePWA()
const display = useDisplay() const display = useDisplay()
const userStore = useUserStore() const userStore = useUserStore()
const userPermissionContext = computed(() => const userPermissionContext = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
buildUserPermissionContext(userStore.superUser, userStore.permissions),
)
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin')) const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
const canDiscovery = computed(() => hasPermission(userPermissionContext.value, 'discovery')) 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_ENABLE_STORAGE_KEY = 'MP_DASHBOARD'
const DASHBOARD_ORDER_STORAGE_KEY = 'MP_DASHBOARD_ORDER' const DASHBOARD_ORDER_STORAGE_KEY = 'MP_DASHBOARD_ORDER'
const DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX = 'MP_DASHBOARD_GRID_LAYOUT' 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_ENABLE_CONFIG_KEY = 'Dashboard'
const DASHBOARD_ORDER_CONFIG_KEY = 'DashboardOrder' const DASHBOARD_ORDER_CONFIG_KEY = 'DashboardOrder'
const DASHBOARD_GRID_LAYOUT_CONFIG_KEY = 'DashboardGridLayout' const DASHBOARD_GRID_LAYOUT_CONFIG_KEY = 'DashboardGridLayout'
@@ -53,6 +52,7 @@ const DASHBOARD_GRID_LAYOUT_CONFIG_KEY_PREFIX = 'DashboardGridLayout'
type DashboardEnableConfig = Record<string, boolean> type DashboardEnableConfig = Record<string, boolean>
type DashboardOrderConfig = { id: string; key: string }[] type DashboardOrderConfig = { id: string; key: string }[]
type DashboardGridLayoutConfig = Record<string, DashboardGridLayoutItem> type DashboardGridLayoutConfig = Record<string, DashboardGridLayoutItem>
type DashboardGridAutoHeightConfig = Record<string, number>
type DashboardConfigNormalizer<T> = (value: unknown) => T | undefined type DashboardConfigNormalizer<T> = (value: unknown) => T | undefined
type DashboardConfigRemoteValueBuilder<T> = (value: T) => unknown type DashboardConfigRemoteValueBuilder<T> = (value: T) => unknown
type DashboardLayoutProfile = 'desktop' | 'tablet' | 'mobile' type DashboardLayoutProfile = 'desktop' | 'tablet' | 'mobile'
@@ -97,6 +97,9 @@ interface DashboardGridItem {
// 是否处于仪表板布局编辑模式 // 是否处于仪表板布局编辑模式
const isLayoutEditing = ref(false) const isLayoutEditing = ref(false)
// 首次布局完成后触发轻量整体入场,不延迟或隐藏渐进渲染内容。
const isDashboardGridRevealed = ref(false)
// 是否发送请求的总开关 // 是否发送请求的总开关
const isRequest = ref(true) const isRequest = ref(true)
@@ -121,6 +124,9 @@ const isPersistingDashboardGridLayoutFromGrid = ref(false)
// 仪表板本地布局覆盖配置 // 仪表板本地布局覆盖配置
const dashboardGridLayout = ref<DashboardGridLayoutConfig>({}) const dashboardGridLayout = ref<DashboardGridLayoutConfig>({})
// 当前设备档位最近一次测得的自动行高,仅作为下次首屏预排提示,不改变手动高度语义。
const dashboardGridAutoHeights = shallowRef<DashboardGridAutoHeightConfig>({})
// 最近一次已确认持久化的仪表板布局,用于编辑模式下避开临时布局草稿。 // 最近一次已确认持久化的仪表板布局,用于编辑模式下避开临时布局草稿。
let persistedDashboardGridLayout: DashboardGridLayoutConfig = {} let persistedDashboardGridLayout: DashboardGridLayoutConfig = {}
@@ -141,9 +147,15 @@ const dashboardGridObservedContentHeights = new Map<string, number>()
let dashboardGridContentObserver: ResizeObserver | null = null let dashboardGridContentObserver: ResizeObserver | null = null
let dashboardGridContentResizeFrame: number | null = null let dashboardGridContentResizeFrame: number | null = null
let dashboardGridResizeRefreshFrame: number | null = null let dashboardGridResizeRefreshFrame: number | null = null
let dashboardGridAnimationFrame: number | null = null
let dashboardGridEntranceFrame: number | null = null
let dashboardRevealFrame: number | null = null let dashboardRevealFrame: number | null = null
let isDashboardRevealPending = false let isDashboardRevealPending = false
let dashboardProfileSaveQueue = Promise.resolve() let dashboardProfileSaveQueue = Promise.resolve()
// 档位切换必须等目标配置就绪后一次性重建,避免响应式列变化与 Vue 深度监听交叉改写节点。
let isSwitchingDashboardLayoutProfile = false
// 应用档位配置后的首次同步必须恢复缺失覆盖项的默认位置,不能沿用上一份配置的节点坐标。
let shouldRestoreDashboardGridProfileDefaults = false
// 标记最近一次响应式档位切换,避免快速缩放时较早的异步配置覆盖最新档位。 // 标记最近一次响应式档位切换,避免快速缩放时较早的异步配置覆盖最新档位。
let dashboardLayoutProfileSwitchId = 0 let dashboardLayoutProfileSwitchId = 0
@@ -368,9 +380,7 @@ function scheduleDashboardReveal() {
syncDashboardFillContentState() syncDashboardFillContentState()
resizeAutoDashboardItemsToContent() resizeAutoDashboardItemsToContent()
if (typeof window === 'undefined') { if (typeof window === 'undefined') return
return
}
dashboardRevealFrame = window.requestAnimationFrame(() => { dashboardRevealFrame = window.requestAnimationFrame(() => {
dashboardRevealFrame = null 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) { function markDashboardGridItemLoaded(id: string) {
if (loadedDashboardGridItemIds.value.has(id)) return if (loadedDashboardGridItemIds.value.has(id)) return
@@ -489,6 +530,19 @@ function normalizeDashboardGridLayout(value: unknown): DashboardGridLayoutConfig
return normalizedLayout return normalizedLayout
} }
// 校验本地自动行高提示,异常或过期字段不得进入 GridStack 首屏配置。
function normalizeDashboardGridAutoHeightConfig(value: unknown): DashboardGridAutoHeightConfig | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
return Object.entries(value).reduce<DashboardGridAutoHeightConfig>((config, [id, height]) => {
if (!id || !Number.isFinite(Number(height))) return config
config[id] = clampGridNumber(height, 1, 96, DASHBOARD_GRID_FALLBACK_ROWS)
return config
}, {})
}
// 校验并归一化单个设备档位的仪表盘配置,兼容旧版只保存 Grid 布局的数据。 // 校验并归一化单个设备档位的仪表盘配置,兼容旧版只保存 Grid 布局的数据。
function normalizeDashboardProfileConfig(value: unknown): DashboardProfileConfig | undefined { function normalizeDashboardProfileConfig(value: unknown): DashboardProfileConfig | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return 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()}` 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) { function getDashboardGridLayoutConfigKey(profile: DashboardLayoutProfile) {
if (profile === 'desktop') return DASHBOARD_GRID_LAYOUT_CONFIG_KEY if (profile === 'desktop') return DASHBOARD_GRID_LAYOUT_CONFIG_KEY
@@ -635,7 +696,8 @@ async function loadDashboardProfileConfig(profile: DashboardLayoutProfile) {
saveLocalDashboardConfig(storageKey, profileConfig) saveLocalDashboardConfig(storageKey, profileConfig)
if (remoteConfig.enabled === undefined && localConfig?.enabled !== undefined) { if (remoteConfig.enabled === undefined && localConfig?.enabled !== undefined) {
await queueDashboardProfileRemoteSave(configKey, profileConfig) // 远端布局已是当前权威结果;兼容字段回填失败不能让本次加载退回旧的本地布局。
void queueDashboardProfileRemoteSave(configKey, profileConfig).catch(error => console.error(error))
} }
return profileConfig return profileConfig
@@ -670,6 +732,47 @@ function saveLocalDashboardConfig(storageKey: string, value: unknown) {
localStorage.setItem(storageKey, JSON.stringify(value)) 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) { async function saveUserDashboardConfig(configKey: string, value: unknown) {
await api.post(`/user/config/${configKey}`, value) await api.post(`/user/config/${configKey}`, value)
@@ -757,7 +860,7 @@ function getDefaultDashboardGridWidth(item: DashboardItem) {
if (profile === 'mobile') return 1 if (profile === 'mobile') return 1
const columns = getDashboardGridColumnsForProfile(profile) 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) 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 defaultLayout = dashboardLayoutProfile.value === 'desktop' ? DASHBOARD_DESKTOP_DEFAULT_LAYOUT[id] : undefined
const gridColumns = getDashboardGridColumnsForProfile(dashboardLayoutProfile.value) const gridColumns = getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
const width = savedLayout?.w ?? defaultLayout?.w ?? getDefaultDashboardGridWidth(item) 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 normalizedWidth = clampGridNumber(width, 1, gridColumns, gridColumns)
const widget: GridStackWidget = { const widget: GridStackWidget = {
id, id,
@@ -922,31 +1026,35 @@ function toggleDashboardLayoutEditing() {
// 加载用户监控面板配置,优先使用服务端用户配置以支持跨浏览器同步。 // 加载用户监控面板配置,优先使用服务端用户配置以支持跨浏览器同步。
async function loadDashboardConfig() { async function loadDashboardConfig() {
dashboardLayoutProfile.value = resolveDashboardLayoutProfile() const profile = resolveDashboardLayoutProfile()
// 顺序配置 const profileSwitchId = dashboardLayoutProfileSwitchId
const order = await loadSharedDashboardConfig( dashboardLayoutProfile.value = profile
// 顺序和当前设备档位互不依赖,并行校验可缩短无本地缓存时的首屏等待时间。
const orderPromise = loadSharedDashboardConfig(
DASHBOARD_ORDER_CONFIG_KEY, DASHBOARD_ORDER_CONFIG_KEY,
DASHBOARD_ORDER_STORAGE_KEY, DASHBOARD_ORDER_STORAGE_KEY,
normalizeDashboardOrderConfig, normalizeDashboardOrderConfig,
) )
if (order !== undefined) { const profileConfigPromise = loadDashboardProfileConfig(profile)
// 共享顺序不得等待单个档位请求,否则设置保存可能把尚未应用的旧顺序写回服务端。
void orderPromise.then(order => {
if (order === undefined) return
orderConfig.value = order 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 legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
const loadedLayout = profileConfig?.items ?? {} if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== profile) return
dashboardGridLayout.value = loadedLayout
persistedDashboardGridLayout = cloneDashboardGridLayout(loadedLayout) applyDashboardConfig(profileConfig, legacyEnable, undefined)
isDashboardGridLayoutResetDraft.value = false
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable)
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) { if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
await saveDashboardProfileConfig() await saveDashboardProfileConfig()
} }
// 排序
if (orderConfig.value) {
sortDashboardConfigs()
}
} }
// 按order的顺序对dashboardConfigs进行排序 // 按order的顺序对dashboardConfigs进行排序
@@ -1084,18 +1192,9 @@ function initializeDashboardGrid() {
dashboardGrid.value = GridStack.init( dashboardGrid.value = GridStack.init(
{ {
animate: true, animate: false,
cellHeight: DASHBOARD_GRID_CELL_HEIGHT, cellHeight: DASHBOARD_GRID_CELL_HEIGHT,
column: DASHBOARD_GRID_COLUMNS, column: getDashboardGridColumnsForProfile(dashboardLayoutProfile.value),
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',
},
draggable: { draggable: {
cancel: 'input,textarea,button,select,option,a,.dashboard-grid-no-drag', cancel: 'input,textarea,button,select,option,a,.dashboard-grid-no-drag',
handle: '.dashboard-grid-drag-handle', handle: '.dashboard-grid-drag-handle',
@@ -1130,18 +1229,32 @@ function updateDashboardGridEditableState(editable: boolean) {
} }
// 将 Vue 渲染出的仪表板节点同步注册到 GridStack。 // 将 Vue 渲染出的仪表板节点同步注册到 GridStack。
async function syncDashboardGrid() { async function syncDashboardGrid(resumeAnimation = true) {
const grid = dashboardGrid.value const grid = dashboardGrid.value
const gridElement = dashboardGridRef.value const gridElement = dashboardGridRef.value
if (!grid || !gridElement) return if (!grid || !gridElement) return
const restoreProfileDefaults = shouldRestoreDashboardGridProfileDefaults
shouldRestoreDashboardGridProfileDefaults = false
pauseDashboardGridAnimation()
isSyncingDashboardGrid.value = true isSyncingDashboardGrid.value = true
await nextTick() await nextTick()
syncDashboardFillContentState() syncDashboardFillContentState()
const items = dashboardGridItems.value const items = dashboardGridItems.value
const itemMap = new Map(items.map(item => [item.id, item])) const itemMap = new Map(items.map(item => [item.id, item]))
const elements = Array.from(gridElement.querySelectorAll<GridItemHTMLElement>('.dashboard-grid-item')) const synchronizedWidgets = new Map<string, GridStackWidget>()
const elements = Array.from(gridElement.querySelectorAll<GridItemHTMLElement>('.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 { try {
grid.batchUpdate() grid.batchUpdate()
@@ -1162,14 +1275,15 @@ async function syncDashboardGrid() {
if (!item) return if (!item) return
const widget = { ...item.widget } const widget = { ...item.widget }
if (element.gridstackNode && !dashboardGridLayout.value[id]) { if (element.gridstackNode && !dashboardGridLayout.value[id] && !restoreProfileDefaults) {
delete widget.autoPosition delete widget.autoPosition
delete widget.x widget.x = element.gridstackNode.x
delete widget.y widget.y = element.gridstackNode.y
} }
if (element.gridstackNode && !hasManualDashboardGridHeight(id)) { if (element.gridstackNode && !hasManualDashboardGridHeight(id)) {
widget.h = element.gridstackNode.h widget.h = element.gridstackNode.h
} }
synchronizedWidgets.set(id, widget)
if (element.gridstackNode) { if (element.gridstackNode) {
grid.update(element, widget) grid.update(element, widget)
@@ -1179,6 +1293,11 @@ async function syncDashboardGrid() {
}) })
grid.batchUpdate(false) grid.batchUpdate(false)
// 完整档位布局由 GridStack 按坐标统一恢复,避免逐个注册时的页面节点顺序污染跨列和响应式布局。
grid.load(
items.map(item => ({ ...(synchronizedWidgets.get(item.id) ?? item.widget) })),
false,
)
updateDashboardGridEditableState(isLayoutEditing.value) updateDashboardGridEditableState(isLayoutEditing.value)
syncDashboardFillContentState() syncDashboardFillContentState()
observeDashboardGridContent() observeDashboardGridContent()
@@ -1187,8 +1306,10 @@ async function syncDashboardGrid() {
resizeAutoDashboardItemsToContent() resizeAutoDashboardItemsToContent()
scheduleDashboardReveal() scheduleDashboardReveal()
}) })
scheduleDashboardGridEntrance()
} finally { } finally {
isSyncingDashboardGrid.value = false isSyncingDashboardGrid.value = false
if (resumeAnimation) scheduleDashboardGridAnimationResume()
} }
} }
@@ -1261,7 +1382,15 @@ function scheduleDashboardItemContentResize(element: GridItemHTMLElement) {
function resizeDashboardItemToContent(element: GridItemHTMLElement) { function resizeDashboardItemToContent(element: GridItemHTMLElement) {
const grid = dashboardGrid.value const grid = dashboardGrid.value
const id = element.getAttribute('gs-id') ?? '' 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) syncDashboardFillContentState(element)
const shouldMeasureFillContent = element.classList.contains('has-fill-content') const shouldMeasureFillContent = element.classList.contains('has-fill-content')
@@ -1271,6 +1400,18 @@ function resizeDashboardItemToContent(element: GridItemHTMLElement) {
try { try {
grid.resizeToContent(element) 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 { } finally {
if (shouldMeasureFillContent) { if (shouldMeasureFillContent) {
element.classList.remove('is-measuring-content') element.classList.remove('is-measuring-content')
@@ -1410,7 +1551,7 @@ watch(
dashboardGridItems, dashboardGridItems,
() => { () => {
syncDashboardLoadedItemIds() syncDashboardLoadedItemIds()
if (!isPersistingDashboardGridLayoutFromGrid.value) { if (!isSwitchingDashboardLayoutProfile && !isPersistingDashboardGridLayoutFromGrid.value) {
syncDashboardGrid() syncDashboardGrid()
} }
scheduleDashboardReveal() scheduleDashboardReveal()
@@ -1426,32 +1567,65 @@ watch(
// GridStack 可能已先完成列数压缩;档位切换只读取目标配置,不能保存当前自动重排结果。 // GridStack 可能已先完成列数压缩;档位切换只读取目标配置,不能保存当前自动重排结果。
const profileSwitchId = ++dashboardLayoutProfileSwitchId const profileSwitchId = ++dashboardLayoutProfileSwitchId
isSwitchingDashboardLayoutProfile = true
dashboardLayoutProfile.value = nextProfile dashboardLayoutProfile.value = nextProfile
const profileConfig = await loadDashboardProfileConfig(nextProfile) try {
if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return 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 const profileConfig = await loadDashboardProfileConfig(nextProfile)
if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return
const loadedLayout = profileConfig?.items ?? {} const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
dashboardGridLayout.value = loadedLayout if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return
persistedDashboardGridLayout = cloneDashboardGridLayout(loadedLayout)
isDashboardGridLayoutResetDraft.value = false isSwitchingDashboardLayoutProfile = true
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable) applyDashboardConfig(profileConfig, legacyEnable, undefined)
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) { if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
await saveDashboardProfileConfig() // 兼容配置的远端回填不得阻塞当前档位重建,否则慢请求会让 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 () => { onBeforeMount(async () => {
await loadDashboardConfig() await loadDashboardConfig()
await getPluginDashboardMeta() await getPluginDashboardMeta()
@@ -1486,6 +1660,14 @@ onBeforeUnmount(() => {
cancelAnimationFrame(dashboardGridResizeRefreshFrame) cancelAnimationFrame(dashboardGridResizeRefreshFrame)
dashboardGridResizeRefreshFrame = null dashboardGridResizeRefreshFrame = null
} }
if (dashboardGridAnimationFrame !== null) {
cancelAnimationFrame(dashboardGridAnimationFrame)
dashboardGridAnimationFrame = null
}
if (dashboardGridEntranceFrame !== null) {
cancelAnimationFrame(dashboardGridEntranceFrame)
dashboardGridEntranceFrame = null
}
if (dashboardRevealFrame !== null) { if (dashboardRevealFrame !== null) {
cancelAnimationFrame(dashboardRevealFrame) cancelAnimationFrame(dashboardRevealFrame)
dashboardRevealFrame = null dashboardRevealFrame = null
@@ -1500,7 +1682,11 @@ onBeforeUnmount(() => {
<template> <template>
<!-- 仪表板 --> <!-- 仪表板 -->
<div ref="dashboardGridRef" class="grid-stack dashboard-grid" :class="{ 'is-editing': isLayoutEditing }"> <div
ref="dashboardGridRef"
class="grid-stack dashboard-grid"
:class="{ 'is-editing': isLayoutEditing, 'is-revealed': isDashboardGridRevealed }"
>
<div <div
v-for="gridItem in dashboardGridItems" v-for="gridItem in dashboardGridItems"
:key="gridItem.id" :key="gridItem.id"
@@ -1567,11 +1753,17 @@ onBeforeUnmount(() => {
/* stylelint-disable selector-pseudo-class-no-unknown */ /* stylelint-disable selector-pseudo-class-no-unknown */
.dashboard-grid { .dashboard-grid {
opacity: 0.92;
pointer-events: auto; pointer-events: auto;
transform: translateY(4px);
transition: transition:
opacity 0.45s cubic-bezier(0.25, 1, 0.5, 1), opacity 0.18s ease-out,
transform 0.45s cubic-bezier(0.25, 1, 0.5, 1); transform 0.18s ease-out;
will-change: opacity, transform; }
.dashboard-grid.is-revealed {
opacity: 1;
transform: none;
} }
.dashboard-grid :deep(.v-card) { .dashboard-grid :deep(.v-card) {
@@ -1652,6 +1844,11 @@ onBeforeUnmount(() => {
.dashboard-grid.is-editing :deep(.v-card) { .dashboard-grid.is-editing :deep(.v-card) {
block-size: 100%; block-size: 100%;
min-block-size: 0;
}
.dashboard-grid-item.is-manual-height :deep(.v-card) {
min-block-size: 0;
} }
.dashboard-grid.is-editing :deep(.v-card-text), .dashboard-grid.is-editing :deep(.v-card-text),
@@ -1698,6 +1895,7 @@ onBeforeUnmount(() => {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.dashboard-grid { .dashboard-grid {
opacity: 1;
transform: none; transform: none;
transition: none; transition: none;
} }