mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
修复仪表盘首屏布局跳动与响应式错位 (#571)
This commit is contained in:
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user