diff --git a/src/composables/useDashboardMediaGridCapacity.ts b/src/composables/useDashboardMediaGridCapacity.ts new file mode 100644 index 00000000..4c8bb5db --- /dev/null +++ b/src/composables/useDashboardMediaGridCapacity.ts @@ -0,0 +1,151 @@ +import { computed, nextTick, onActivated, onMounted, onUnmounted, ref, watch, type ComputedRef, type Ref } from 'vue' + +interface DashboardMediaGridCapacityOptions { + contentSelector?: string + gap?: number + horizontalPadding?: number + maxCount?: number + minItemWidth: number + rows?: number +} + +interface DashboardMediaGridCapacityState { + columnCount: ComputedRef + containerRef: Ref + itemCount: ComputedRef + refreshCapacity: () => void +} + +const DEFAULT_DASHBOARD_MEDIA_GRID_GAP = 16 +const DEFAULT_DASHBOARD_MEDIA_GRID_ROWS = 2 + +/** + * 根据仪表盘媒体卡片容器宽度计算可铺满指定行数的请求数量。 + * + * @param options 网格尺寸参数,需要与实际 ProgressiveCardGrid 参数保持一致 + * @returns 容器引用、列数、请求数量和手动刷新方法 + */ +export function useDashboardMediaGridCapacity(options: DashboardMediaGridCapacityOptions): DashboardMediaGridCapacityState { + const containerRef = ref(null) + const measuredWidth = ref(0) + + let resizeObserver: ResizeObserver | null = null + let frameId: number | null = null + + const safeGap = computed(() => Math.max(0, options.gap ?? DEFAULT_DASHBOARD_MEDIA_GRID_GAP)) + const safeHorizontalPadding = computed(() => Math.max(0, options.horizontalPadding ?? 0)) + const safeMaxCount = computed(() => Math.max(0, Math.floor(options.maxCount ?? Number.POSITIVE_INFINITY))) + const safeMinItemWidth = computed(() => Math.max(1, options.minItemWidth)) + const safeRows = computed(() => Math.max(1, Math.floor(options.rows ?? DEFAULT_DASHBOARD_MEDIA_GRID_ROWS))) + + const columnCount = computed(() => { + if (measuredWidth.value <= 0) return 0 + + return Math.max(1, Math.floor((measuredWidth.value + safeGap.value) / (safeMinItemWidth.value + safeGap.value))) + }) + + const itemCount = computed(() => { + if (columnCount.value <= 0) return 0 + + const count = columnCount.value * safeRows.value + + return safeMaxCount.value > 0 ? Math.min(count, safeMaxCount.value) : count + }) + + /** + * 读取元素扣除内边距后的内容宽度。 + * + * @param element 需要测量的元素 + */ + function measureContentBoxWidth(element: HTMLElement) { + const style = window.getComputedStyle(element) + const paddingLeft = Number.parseFloat(style.paddingLeft) || 0 + const paddingRight = Number.parseFloat(style.paddingRight) || 0 + + return Math.max(0, element.clientWidth - paddingLeft - paddingRight) + } + + /** + * 读取容器可用于网格布局的实际宽度。 + */ + function measureContainerWidth() { + const element = containerRef.value + + if (!element) return 0 + + const contentElement = options.contentSelector + ? element.querySelector(options.contentSelector) + : null + if (contentElement) { + return measureContentBoxWidth(contentElement) + } + + const elementWidth = element.getBoundingClientRect().width || element.clientWidth + + return Math.max(0, elementWidth - safeHorizontalPadding.value) + } + + /** + * 同步当前容器宽度到响应式状态。 + */ + function refreshCapacity() { + measuredWidth.value = measureContainerWidth() + } + + /** + * 合并同一帧内的尺寸变更,避免拖拽调整仪表盘时重复计算。 + */ + function queueRefreshCapacity() { + if (typeof window === 'undefined' || frameId !== null) return + + frameId = window.requestAnimationFrame(() => { + frameId = null + refreshCapacity() + }) + } + + /** + * 重新绑定当前容器的 ResizeObserver。 + */ + function observeContainer() { + resizeObserver?.disconnect() + resizeObserver = null + + if (typeof ResizeObserver !== 'undefined' && containerRef.value) { + resizeObserver = new ResizeObserver(queueRefreshCapacity) + resizeObserver.observe(containerRef.value) + } + + queueRefreshCapacity() + } + + watch(containerRef, observeContainer, { flush: 'post' }) + + onMounted(() => { + observeContainer() + window.addEventListener('resize', queueRefreshCapacity, { passive: true }) + void nextTick(refreshCapacity) + }) + + onActivated(() => { + void nextTick(refreshCapacity) + }) + + onUnmounted(() => { + window.removeEventListener('resize', queueRefreshCapacity) + resizeObserver?.disconnect() + resizeObserver = null + + if (frameId !== null) { + window.cancelAnimationFrame(frameId) + frameId = null + } + }) + + return { + columnCount, + containerRef, + itemCount, + refreshCapacity, + } +} diff --git a/src/pages/dashboard.vue b/src/pages/dashboard.vue index ec837fbe..5a5d7a28 100644 --- a/src/pages/dashboard.vue +++ b/src/pages/dashboard.vue @@ -111,6 +111,9 @@ const loadedDashboardGridItemIds = ref>(new Set()) // 是否正在由 Vue 同步 GridStack,避免初始化写入覆盖用户布局 const isSyncingDashboardGrid = ref(false) +// 是否正在把 GridStack 当前布局写回 Vue 状态,避免同源变更再次反向同步到 GridStack。 +const isPersistingDashboardGridLayoutFromGrid = ref(false) + // 仪表板本地布局覆盖配置 const dashboardGridLayout = ref({}) @@ -757,19 +760,18 @@ function updateDashboardSettingsDialog() { } // 退出仪表板布局编辑模式;如果刚恢复默认布局,则跳过本次本地持久化。 -function exitDashboardLayoutEditing() { +async function exitDashboardLayoutEditing() { if (isDashboardGridLayoutResetPending.value) { isDashboardGridLayoutResetPending.value = false } else { - compactAndPersistDashboardGrid() + await persistCurrentDashboardGridLayout() } isLayoutEditing.value = false - nextTick(() => { - syncDashboardFillContentState() - resizeAutoDashboardItemsToContent() - notifyDashboardContentResize() - }) + await nextTick() + syncDashboardFillContentState() + resizeAutoDashboardItemsToContent() + notifyDashboardContentResize() } // 清除用户本地布局覆盖,并恢复内置组件和插件声明的默认占位,然后退出编辑模式。 @@ -780,7 +782,7 @@ async function resetDashboardGridLayout() { isDashboardGridLayoutResetPending.value = true await syncDashboardGrid() if (isLayoutEditing.value) { - exitDashboardLayoutEditing() + await exitDashboardLayoutEditing() } else { await nextTick() syncDashboardFillContentState() @@ -831,10 +833,10 @@ useDynamicButton({ show: computed(() => appMode.value && route.path === '/dashboard'), }) -// 切换仪表板布局编辑模式,退出编辑时压实并保存当前布局。 +// 切换仪表板布局编辑模式,退出编辑时保存当前布局。 function toggleDashboardLayoutEditing() { if (isLayoutEditing.value) { - exitDashboardLayoutEditing() + void exitDashboardLayoutEditing() return } @@ -1225,7 +1227,7 @@ function handleDashboardGridResize() { // 保存用户拖动后的位置,并保持未手动调高组件继续按内容自适应。 function handleDashboardGridDragStop() { - compactAndPersistDashboardGrid(false) + void persistCurrentDashboardGridLayout(false) } // 保存用户缩放后的布局,只有高度发生变化时才把高度标记为手动固定。 @@ -1238,7 +1240,7 @@ function handleDashboardGridResizeStop(_event: Event, element: GridItemHTMLEleme dashboardGridResizeStartHeights.delete(id) isDashboardGridResizing.value = false notifyDashboardContentResize() - compactAndPersistDashboardGrid(heightChanged ? id : false) + void persistCurrentDashboardGridLayout(heightChanged ? id : false) } // 合并连续 resize 通知,模拟浏览器窗口变化让组件内部内容自适配新尺寸。 @@ -1279,9 +1281,13 @@ function persistDashboardGridLayout(manualHeightId: string | false = false) { nextLayout[id] = nextItemLayout }) + isPersistingDashboardGridLayoutFromGrid.value = true dashboardGridLayout.value = nextLayout saveDashboardGridLayout(nextLayout) - nextTick(resizeAutoDashboardItemsToContent) + nextTick(() => { + isPersistingDashboardGridLayoutFromGrid.value = false + resizeAutoDashboardItemsToContent() + }) } // 根据组件 ID 查找默认宽度,保存布局时用于兜底。 @@ -1291,13 +1297,13 @@ function getDefaultDashboardGridWidthById(id: string, maxColumns = DASHBOARD_GRI return item ? Math.min(getDefaultDashboardGridWidth(item), maxColumns) : maxColumns } -// 压实 GridStack 布局并保存本地占位信息。 -function compactAndPersistDashboardGrid(manualHeightId: string | false = false) { +// 等待 GridStack 当前拖拽/缩放事件收尾后,保存用户当前看到的布局。 +async function persistCurrentDashboardGridLayout(manualHeightId: string | false = false) { if (!dashboardGrid.value || isSyncingDashboardGrid.value) return isDashboardGridLayoutResetPending.value = false - dashboardGrid.value.compact('compact') - nextTick(() => persistDashboardGridLayout(manualHeightId)) + await nextTick() + persistDashboardGridLayout(manualHeightId) } watch(isLayoutEditing, value => { @@ -1308,7 +1314,9 @@ watch( dashboardGridItems, () => { syncDashboardLoadedItemIds() - syncDashboardGrid() + if (!isPersistingDashboardGridLayoutFromGrid.value) { + syncDashboardGrid() + } scheduleDashboardReveal() }, { deep: true }, diff --git a/src/views/dashboard/MediaServerLatest.vue b/src/views/dashboard/MediaServerLatest.vue index e1046710..28a4f669 100644 --- a/src/views/dashboard/MediaServerLatest.vue +++ b/src/views/dashboard/MediaServerLatest.vue @@ -3,17 +3,34 @@ import api from '@/api' import type { MediaServerConf, MediaServerPlayItem } from '@/api/types' import PosterCard from '@/components/cards/PosterCard.vue' import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue' +import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity' import { useI18n } from 'vue-i18n' // 国际化 const { t } = useI18n() +const LATEST_CARD_MIN_WIDTH = 144 +const MEDIA_GRID_HORIZONTAL_PADDING = 40 + // 最近入库列表 const latestList = ref<{ [key: string]: MediaServerPlayItem[] }>({}) // 所有媒体服务器设置 const mediaServers = ref([]) +// 最近入库两行网格容量 +const { + containerRef: mediaGridContainerRef, + itemCount: latestItemCount, + refreshCapacity, +} = useDashboardMediaGridCapacity({ + contentSelector: '.dashboard-media-content', + horizontalPadding: MEDIA_GRID_HORIZONTAL_PADDING, + minItemWidth: LATEST_CARD_MIN_WIDTH, +}) + +let latestLoadId = 0 + /** * 查询媒体服务器设置。 */ @@ -29,16 +46,17 @@ async function loadMediaServerSetting() { /** * 查询指定媒体服务器的最近入库列表。 * @param server 媒体服务器名称 + * @param count 需要返回的条目数量 */ -async function loadLatest(server: string) { +async function loadLatest(server: string, count: number) { try { - const response: MediaServerPlayItem[] = await api.get('mediaserver/latest', { params: { server } }) - // 仅在有数据时赋值 - if (response && response.length > 0) { - latestList.value[server] = response - } + const response: MediaServerPlayItem[] = await api.get('mediaserver/latest', { params: { count, server } }) + + return response ?? [] } catch (e) { console.log(t('dashboard.errors.loadLatest', { server }), e) + + return [] } } @@ -46,24 +64,48 @@ async function loadLatest(server: string) { * 加载已启用媒体服务器的最近入库数据。 */ async function loadData() { + const count = latestItemCount.value + if (count <= 0) return + + const loadId = ++latestLoadId + await loadMediaServerSetting() + if (loadId !== latestLoadId) return + const enabledServers = mediaServers.value.filter(server => server.enabled) - for (const server of enabledServers) { - loadLatest(server.name) - } + const entries = await Promise.all( + enabledServers.map(async server => [server.name, await loadLatest(server.name, count)] as const), + ) + + if (loadId !== latestLoadId) return + + latestList.value = entries.reduce<{ [key: string]: MediaServerPlayItem[] }>((result, [name, data]) => { + if (data.length > 0) { + result[name] = data.slice(0, count) + } + + return result + }, {}) } -onMounted(() => { +watch(latestItemCount, count => { + if (count <= 0) return + loadData() }) onActivated(() => { + refreshCapacity() loadData() })