Fix dashboard media grid layout persistence and capacity

This commit is contained in:
jxxghp
2026-06-29 22:07:31 +08:00
parent f54a5285d6
commit bb5299c812
4 changed files with 313 additions and 65 deletions

View File

@@ -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<number>
containerRef: Ref<HTMLElement | null>
itemCount: ComputedRef<number>
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<HTMLElement | null>(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<HTMLElement>(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,
}
}

View File

@@ -111,6 +111,9 @@ const loadedDashboardGridItemIds = ref<Set<string>>(new Set())
// 是否正在由 Vue 同步 GridStack避免初始化写入覆盖用户布局
const isSyncingDashboardGrid = ref(false)
// 是否正在把 GridStack 当前布局写回 Vue 状态,避免同源变更再次反向同步到 GridStack。
const isPersistingDashboardGridLayoutFromGrid = ref(false)
// 仪表板本地布局覆盖配置
const dashboardGridLayout = ref<DashboardGridLayoutConfig>({})
@@ -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 },

View File

@@ -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<MediaServerConf[]>([])
// 最近入库两行网格容量
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()
})
</script>
<template>
<div class="dashboard-media-stack" :class="{ 'dashboard-grid-fill': Object.keys(latestList).length > 0 }">
<div
ref="mediaGridContainerRef"
class="dashboard-media-stack"
:class="{ 'dashboard-grid-fill': Object.keys(latestList).length > 0 }"
>
<VCard v-for="(data, name) in latestList" :key="name" class="dashboard-work-card dashboard-media-card">
<VCardItem class="dashboard-media-header">
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
@@ -72,9 +114,9 @@ onActivated(() => {
<div class="dashboard-media-content px-5 pb-3">
<ProgressiveCardGrid
class="dashboard-media-grid"
:items="data"
:items="data.slice(0, latestItemCount)"
:get-item-key="item => item.id || item.link || item.title"
:min-item-width="144"
:min-item-width="LATEST_CARD_MIN_WIDTH"
:item-aspect-ratio="1.5"
tabindex="0"
>

View File

@@ -3,17 +3,36 @@ import api from '@/api'
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
const PLAYING_CARD_MIN_WIDTH = 240
const MEDIA_GRID_HORIZONTAL_PADDING = 40
// 继续播放列表
const playingList = ref<MediaServerPlayItem[]>([])
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
// 继续观看两行网格容量
const {
containerRef: mediaGridContainerRef,
itemCount: playingItemCount,
refreshCapacity,
} = useDashboardMediaGridCapacity({
contentSelector: '.dashboard-media-content',
horizontalPadding: MEDIA_GRID_HORIZONTAL_PADDING,
minItemWidth: PLAYING_CARD_MIN_WIDTH,
})
const displayedPlayingList = computed(() => playingList.value.slice(0, playingItemCount.value))
let playingLoadId = 0
/**
* 查询媒体服务器设置。
*/
@@ -29,21 +48,17 @@ async function loadMediaServerSetting() {
/**
* 查询指定媒体服务器的继续观看列表。
* @param server 媒体服务器名称
* @param count 需要返回的条目数量
*/
async function loadPlayingList(server: string) {
async function loadPlayingList(server: string, count: number) {
try {
const result: MediaServerPlayItem[] = await api.get('mediaserver/playing', { params: { server } })
if (result && result.length > 0) {
// 不存在时添加
for (const item of result) {
const index = playingList.value.findIndex(i => i.id === item.id)
if (index === -1) {
playingList.value.push(item)
}
}
}
const result: MediaServerPlayItem[] = await api.get('mediaserver/playing', { params: { count, server } })
return result ?? []
} catch (e) {
console.log(e)
return []
}
}
@@ -51,43 +66,70 @@ async function loadPlayingList(server: string) {
* 加载已启用媒体服务器的继续观看数据。
*/
async function loadData() {
const count = playingItemCount.value
if (count <= 0) return
const loadId = ++playingLoadId
await loadMediaServerSetting()
if (loadId !== playingLoadId) return
const enabledServers = mediaServers.value.filter(server => server.enabled)
for (const server of enabledServers) {
loadPlayingList(server.name)
}
const serverItems = await Promise.all(enabledServers.map(server => loadPlayingList(server.name, count)))
if (loadId !== playingLoadId) return
const itemMap = new Map<string, MediaServerPlayItem>()
serverItems.flat().forEach((item, index) => {
const key = String(item.id || item.link || `${item.server_type || 'server'}-${item.title}-${index}`)
if (!itemMap.has(key)) {
itemMap.set(key, item)
}
})
playingList.value = Array.from(itemMap.values()).slice(0, count)
}
onMounted(() => {
watch(playingItemCount, count => {
if (count <= 0) return
loadData()
})
onActivated(() => {
refreshCapacity()
loadData()
})
</script>
<template>
<VCard v-if="playingList.length > 0" class="dashboard-media-card dashboard-grid-fill">
<VCardItem class="dashboard-media-header">
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
</VCardItem>
<div
ref="mediaGridContainerRef"
class="dashboard-media-shell"
:class="{ 'dashboard-grid-fill': displayedPlayingList.length > 0 }"
>
<VCard v-if="displayedPlayingList.length > 0" class="dashboard-media-card">
<VCardItem class="dashboard-media-header">
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
</VCardItem>
<div class="dashboard-media-content px-5 pb-3">
<ProgressiveCardGrid
class="dashboard-media-grid"
:items="playingList"
:get-item-key="item => item.id || item.link || item.title"
:min-item-width="240"
:estimated-item-height="174"
tabindex="0"
>
<template #default="{ item }">
<PlayingBackdropCard :media="item" height="10.875rem" />
</template>
</ProgressiveCardGrid>
</div>
</VCard>
<div class="dashboard-media-content px-5 pb-3">
<ProgressiveCardGrid
class="dashboard-media-grid"
:items="displayedPlayingList"
:get-item-key="item => item.id || item.link || item.title"
:min-item-width="PLAYING_CARD_MIN_WIDTH"
:estimated-item-height="174"
tabindex="0"
>
<template #default="{ item }">
<PlayingBackdropCard :media="item" height="10.875rem" />
</template>
</ProgressiveCardGrid>
</div>
</VCard>
</div>
</template>
<style scoped>
@@ -98,6 +140,11 @@ onActivated(() => {
min-block-size: 0;
}
.dashboard-media-shell {
block-size: 100%;
min-block-size: 0;
}
.dashboard-media-card {
display: flex;
flex-direction: column;