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