fix(dashboard): remeasure async card content (#625)

This commit is contained in:
InfinityPacer
2026-08-02 07:42:13 +08:00
committed by GitHub
parent 777e30d137
commit 245a1f8a5c
6 changed files with 307 additions and 22 deletions

View File

@@ -1057,7 +1057,8 @@ watch(
<template>
<div ref="containerRef" class="progressive-card-grid">
<div ref="trackRef" class="progressive-card-grid__track">
<!-- 完整高度由虚拟占位与可见网格共同承载供上层自适应布局观察稳定的尺寸语义 -->
<div ref="trackRef" class="progressive-card-grid__track" data-layout-size-source>
<div
v-if="topSpacerHeight > 0"
class="progressive-card-grid__spacer"

View File

@@ -35,6 +35,20 @@ describe('ProgressiveCardGrid scroll target lifecycle', () => {
expect(addScrollListener).toHaveBeenCalledWith('scroll', expect.any(Function), { passive: true })
})
})
it('exposes the complete virtual track as a layout size source', () => {
const { container } = render(ProgressiveCardGrid, {
props: {
items: [{ id: 1 }],
getItemKey: (item: { id: number }) => item.id,
},
slots: {
default: '<div>item</div>',
},
})
expect(container.querySelector('.progressive-card-grid__track')).toHaveAttribute('data-layout-size-source')
})
})
describe('ProgressiveCardGrid mount scheduling', () => {

View File

@@ -67,6 +67,44 @@ class ResizeObserverMock implements ResizeObserver {
unobserve() {}
}
class LayoutSourceResizeObserverMock implements ResizeObserver {
static readonly instances: LayoutSourceResizeObserverMock[] = []
readonly observed = new Set<Element>()
readonly unobserved = new Set<Element>()
private readonly callback: ResizeObserverCallback
constructor(callback: ResizeObserverCallback) {
this.callback = callback
LayoutSourceResizeObserverMock.instances.push(this)
}
disconnect() {
this.observed.clear()
}
observe(target: Element) {
this.observed.add(target)
}
unobserve(target: Element) {
this.observed.delete(target)
this.unobserved.add(target)
}
resize(target: Element, height: number) {
this.callback([{ contentRect: { height }, target } as ResizeObserverEntry], this)
}
}
async function findLayoutSourceObserver(source: Element) {
await waitFor(() => {
expect(LayoutSourceResizeObserverMock.instances.some(observer => observer.observed.has(source))).toBe(true)
})
return LayoutSourceResizeObserverMock.instances.find(observer => observer.observed.has(source))!
}
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
@@ -108,7 +146,7 @@ vi.mock('@/composables/useSharedDialog', () => ({
}))
vi.mock('@/components/misc/DashboardElement.vue', async () => {
const { defineComponent, h, onMounted } = await import('vue')
const { defineComponent, h, onMounted, ref } = await import('vue')
return {
default: defineComponent({
@@ -118,6 +156,7 @@ vi.mock('@/components/misc/DashboardElement.vue', async () => {
},
emits: ['loaded'],
setup(props, { emit }) {
const showLayoutSizeSource = ref(false)
onMounted(() => emit('loaded'))
return () =>
@@ -126,8 +165,16 @@ vi.mock('@/components/misc/DashboardElement.vue', async () => {
{
'data-dashboard-id': (props.config as { id: string }).id,
'data-testid': 'dashboard-item',
onClick: () => {
showLayoutSizeSource.value = !showLayoutSizeSource.value
},
},
(props.config as { name: string }).name,
[
(props.config as { name: string }).name,
showLayoutSizeSource.value
? h('div', { 'data-layout-size-source': '', 'data-testid': 'layout-size-source' }, 'size source')
: null,
],
)
},
}),
@@ -158,6 +205,11 @@ const enabledOnlyLibrary = {
systemInfo: false,
}
const enabledLibraryAndSystemInfo = {
...enabledOnlySystemInfo,
library: true,
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(promiseResolve => {
@@ -181,8 +233,10 @@ async function renderDashboard() {
describe('dashboard page initial layout', () => {
beforeEach(() => {
LayoutSourceResizeObserverMock.instances.length = 0
mocks.grid.engine.nodes.length = 0
mocks.gridInit.mockClear()
mocks.grid.resizeToContent.mockClear()
mocks.grid.setAnimation.mockClear()
mocks.apiGet.mockReset()
mocks.apiPost.mockReset()
@@ -376,6 +430,135 @@ describe('dashboard page initial layout', () => {
await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled())
})
it('observes an async size source and remeasures only its automatic card', 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)
})
const { container } = await renderDashboard()
await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(2))
await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled())
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(undefined))))
mocks.grid.resizeToContent.mockClear()
const libraryDashboard = screen
.getAllByTestId('dashboard-item')
.find(element => element.getAttribute('data-dashboard-id') === 'library')!
await fireEvent.click(libraryDashboard)
const sizeSource = libraryDashboard.querySelector('[data-layout-size-source]')!
const observer = await findLayoutSourceObserver(sizeSource)
const libraryItem = container.querySelector('.dashboard-grid-item[gs-id="library"]')
const systemInfoItem = container.querySelector('.dashboard-grid-item[gs-id="systemInfo"]')
observer.resize(sizeSource, 480)
await waitFor(() => expect(mocks.grid.resizeToContent).toHaveBeenCalledWith(libraryItem))
expect(mocks.grid.resizeToContent).toHaveBeenCalledTimes(1)
expect(mocks.grid.resizeToContent).not.toHaveBeenCalledWith(systemInfoItem)
mocks.grid.resizeToContent.mockClear()
sizeSource.append(document.createElement('span'))
await new Promise(resolve => setTimeout(resolve, 0))
expect(mocks.grid.resizeToContent).not.toHaveBeenCalled()
await fireEvent.click(libraryDashboard)
await waitFor(() => expect(observer.unobserved.has(sizeSource)).toBe(true))
})
it('ignores size source changes for manually sized cards', async () => {
vi.stubGlobal('ResizeObserver', LayoutSourceResizeObserverMock)
localStorage.setItem(
'MP_DASHBOARD_GRID_LAYOUT',
JSON.stringify({
enabled: enabledOnlyLibrary,
items: { library: { x: 0, y: 0, w: 12, h: 20 } },
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, h: 20 } } } },
}
}
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 new Promise(resolve => requestAnimationFrame(() => resolve(undefined)))
mocks.grid.resizeToContent.mockClear()
await fireEvent.click(screen.getByTestId('dashboard-item'))
const sizeSource = container.querySelector('[data-layout-size-source]')!
const observer = await findLayoutSourceObserver(sizeSource)
observer.resize(sizeSource, 480)
await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)))
expect(mocks.grid.resizeToContent).not.toHaveBeenCalled()
})
it('ignores size source changes while editing an automatic card', 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 waitFor(() => expect(mocks.grid.resizeToContent).toHaveBeenCalled())
mocks.grid.resizeToContent.mockClear()
await fireEvent.click(screen.getByTestId('dashboard-item'))
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)))
mocks.grid.resizeToContent.mockClear()
observer.resize(sizeSource, 480)
await new Promise(resolve => requestAnimationFrame(() => resolve(undefined)))
expect(mocks.grid.resizeToContent).not.toHaveBeenCalled()
})
it('applies a newer remote profile and refreshes the local first-frame cache', async () => {
const remoteProfile = deferred<unknown>()
localStorage.setItem(

View File

@@ -41,6 +41,7 @@ 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_SIZE_SOURCE_SELECTOR = '[data-layout-size-source]'
const DASHBOARD_ENABLE_STORAGE_KEY = 'MP_DASHBOARD'
const DASHBOARD_ORDER_STORAGE_KEY = 'MP_DASHBOARD_ORDER'
const DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX = 'MP_DASHBOARD_GRID_LAYOUT'
@@ -141,8 +142,10 @@ let isLegacyDashboardEnableConfigLoaded = false
const dashboardGridResizeStartHeights = new Map<string, number | undefined>()
const dashboardGridPendingContentResize = new Set<GridItemHTMLElement>()
const dashboardGridObservedContentHeights = new Map<string, number>()
const dashboardGridObservedSizeSources = new Set<Element>()
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
@@ -1326,16 +1329,24 @@ function syncDashboardFillContentState(element?: GridItemHTMLElement) {
// 监听仪表板组件内容尺寸变化,让未手动调高的组件按内容高度自适应。
function observeDashboardGridContent() {
const gridElement = dashboardGridRef.value
dashboardGridContentMutationObserver?.disconnect()
dashboardGridContentMutationObserver = null
dashboardGridContentObserver?.disconnect()
dashboardGridContentObserver = null
dashboardGridPendingContentResize.clear()
dashboardGridObservedContentHeights.clear()
dashboardGridObservedSizeSources.clear()
if (!gridElement || typeof ResizeObserver === 'undefined') return
syncDashboardFillContentState()
dashboardGridContentObserver?.disconnect()
dashboardGridPendingContentResize.clear()
dashboardGridObservedContentHeights.clear()
dashboardGridContentObserver = new ResizeObserver(entries => {
entries.forEach(entry => {
const itemElement = entry.target.closest('.dashboard-grid-item') as GridItemHTMLElement | null
if (itemElement && shouldScheduleDashboardContentResize(itemElement, entry.contentRect.height)) {
const isSizeSource = entry.target.matches(DASHBOARD_GRID_SIZE_SOURCE_SELECTOR)
if (
itemElement &&
(isSizeSource || shouldScheduleDashboardContentResize(itemElement, entry.contentRect.height))
) {
scheduleDashboardItemContentResize(itemElement)
}
})
@@ -1344,6 +1355,46 @@ function observeDashboardGridContent() {
gridElement.querySelectorAll<HTMLElement>('.dashboard-grid-auto-size').forEach(element => {
dashboardGridContentObserver?.observe(element)
})
gridElement.querySelectorAll<HTMLElement>(DASHBOARD_GRID_SIZE_SOURCE_SELECTOR).forEach(observeDashboardGridSizeSource)
if (typeof MutationObserver === 'undefined') return
dashboardGridContentMutationObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.removedNodes.forEach(node => {
findDashboardGridSizeSources(node).forEach(unobserveDashboardGridSizeSource)
})
mutation.addedNodes.forEach(node => {
findDashboardGridSizeSources(node).forEach(observeDashboardGridSizeSource)
})
})
})
dashboardGridContentMutationObserver.observe(gridElement, { childList: true, subtree: true })
}
// 返回节点自身及后代声明的真实尺寸源,普通子节点变化不进入测高路径。
function findDashboardGridSizeSources(node: Node) {
if (!(node instanceof Element)) return []
const sources = Array.from(node.querySelectorAll<Element>(DASHBOARD_GRID_SIZE_SOURCE_SELECTOR))
if (node.matches(DASHBOARD_GRID_SIZE_SOURCE_SELECTOR)) sources.unshift(node)
return sources
}
// 注册异步挂载的尺寸源ResizeObserver 的首次回调负责触发实际测高。
function observeDashboardGridSizeSource(element: Element) {
if (!dashboardGridContentObserver || dashboardGridObservedSizeSources.has(element)) return
dashboardGridObservedSizeSources.add(element)
dashboardGridContentObserver.observe(element)
}
// 节点卸载后同步解除观察,避免 KeepAlive 与布局重建保留失效引用。
function unobserveDashboardGridSizeSource(element: Element) {
if (!dashboardGridContentObserver || !dashboardGridObservedSizeSources.delete(element)) return
dashboardGridContentObserver.unobserve(element)
}
// 判断内容高度变化是否足够触发 GridStack 行高重算,避免 hover 级微小波动造成布局抖动。
@@ -1648,6 +1699,8 @@ onDeactivated(() => {
onBeforeUnmount(() => {
Object.keys(refreshTimers.value).forEach(clearPluginDashboardTimer)
dashboardGridContentMutationObserver?.disconnect()
dashboardGridContentMutationObserver = null
dashboardGridContentObserver?.disconnect()
dashboardGridContentObserver = null
if (dashboardGridContentResizeFrame !== null) {
@@ -1668,6 +1721,7 @@ onBeforeUnmount(() => {
}
dashboardGridPendingContentResize.clear()
dashboardGridObservedContentHeights.clear()
dashboardGridObservedSizeSources.clear()
dashboardGridResizeStartHeights.clear()
dashboardGrid.value?.destroy(false)
dashboardGrid.value = null
@@ -1676,11 +1730,7 @@ onBeforeUnmount(() => {
<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 }">
<div
v-for="gridItem in dashboardGridItems"
:key="gridItem.id"
@@ -1876,5 +1926,4 @@ onBeforeUnmount(() => {
inset-block-end: -4px;
inset-inline-end: -4px;
}
</style>

View File

@@ -52,15 +52,20 @@ onActivated(loadRecentImports)
</VCardItem>
<VCardText class="recent-import-list">
<div v-for="item in recentImports" :key="item.id" class="recent-import-item">
<VImg :src="getPosterUrl(item)" :alt="item.title" class="recent-import-poster" cover />
<div class="recent-import-copy">
<div class="recent-import-title">{{ item.title }}<span v-if="item.year"> ({{ item.year }})</span></div>
<div class="recent-import-meta">{{ getImportMeta(item) }}</div>
</div>
<div class="recent-import-time">
{{ item.date ? formatDateDifference(item.date) : '' }}
<VIcon icon="mdi-check-circle" color="success" size="16" />
<!-- 非空列表使用自然内容高度避免外层填充布局隐藏异步增长 -->
<div v-if="recentImports.length > 0" data-layout-size-source>
<div v-for="item in recentImports" :key="item.id" class="recent-import-item">
<VImg :src="getPosterUrl(item)" :alt="item.title" class="recent-import-poster" cover />
<div class="recent-import-copy">
<div class="recent-import-title">
{{ item.title }}<span v-if="item.year"> ({{ item.year }})</span>
</div>
<div class="recent-import-meta">{{ getImportMeta(item) }}</div>
</div>
<div class="recent-import-time">
{{ item.date ? formatDateDifference(item.date) : '' }}
<VIcon icon="mdi-check-circle" color="success" size="16" />
</div>
</div>
</div>

View File

@@ -0,0 +1,33 @@
import DashboardRecentImports from '@/views/dashboard/DashboardRecentImports.vue'
import { renderWithProviders } from '@tests/support/render'
import { screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: (...args: unknown[]) => mocks.apiGet(...args),
},
}))
describe('dashboard recent imports', () => {
beforeEach(() => {
mocks.apiGet.mockReset()
})
it('exposes the rendered list as a layout size source', async () => {
mocks.apiGet.mockResolvedValue({
data: {
list: [{ id: 1, title: '异步入库记录' }],
},
})
const { container } = await renderWithProviders(DashboardRecentImports)
const renderedItem = await screen.findByText('异步入库记录')
expect(container.querySelector('[data-layout-size-source]')).toContainElement(renderedItem)
})
})