perf(dashboard): stabilize automatic grid sizing (#720)

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