From 8e5b8f7207fcb70d18ee563726b3c92b6e6f8186 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 7 Jun 2026 08:57:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E4=BC=98=E5=8C=96=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E7=BB=84=E4=BB=B6=E5=8A=A0=E8=BD=BD=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E6=94=AF=E6=8C=81=E5=BC=82=E6=AD=A5=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=92=8C=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/misc/DashboardElement.vue | 179 ++++++++++----- src/pages/dashboard.vue | 210 +++++++++++++----- src/views/dashboard/AnalyticsCpu.vue | 5 - src/views/dashboard/AnalyticsMemory.vue | 6 - src/views/dashboard/AnalyticsNetwork.vue | 5 - .../dashboard/AnalyticsWeeklyOverview.vue | 6 - src/views/dashboard/MediaServerLatest.vue | 4 - src/views/dashboard/MediaServerLibrary.vue | 4 - src/views/dashboard/MediaServerPlaying.vue | 4 - 9 files changed, 282 insertions(+), 141 deletions(-) diff --git a/src/components/misc/DashboardElement.vue b/src/components/misc/DashboardElement.vue index c119438c..031f81e4 100644 --- a/src/components/misc/DashboardElement.vue +++ b/src/components/misc/DashboardElement.vue @@ -6,6 +6,8 @@ import DashboardRender from '@/components/render/DashboardRender.vue' import { isNullOrEmptyObject } from '@/@core/utils' import { loadRemoteComponent } from '@/utils/federationLoader' +type DashboardComponentLoader = () => Promise + const DashboardSkeleton = { setup() { const SkeletonLoader = resolveComponent('VSkeletonLoader') @@ -19,51 +21,59 @@ const asyncDashboardOptions = { loadingComponent: DashboardSkeleton, } +const builtInDashboardComponentLoaders: Record = { + storage: () => import('@/views/dashboard/AnalyticsStorage.vue'), + mediaStatistic: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'), + weeklyOverview: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'), + speed: () => import('@/views/dashboard/AnalyticsSpeed.vue'), + scheduler: () => import('@/views/dashboard/AnalyticsScheduler.vue'), + cpu: () => import('@/views/dashboard/AnalyticsCpu.vue'), + memory: () => import('@/views/dashboard/AnalyticsMemory.vue'), + network: () => import('@/views/dashboard/AnalyticsNetwork.vue'), + library: () => import('@/views/dashboard/MediaServerLibrary.vue'), + playing: () => import('@/views/dashboard/MediaServerPlaying.vue'), + latest: () => import('@/views/dashboard/MediaServerLatest.vue'), +} + +const builtInDashboardComponentPromises = new Map>() + +// 复用内置仪表盘组件加载 Promise,让页面层可以等待异步组件模块真正加载完成。 +function loadBuiltInDashboardComponent(id: string) { + const loader = builtInDashboardComponentLoaders[id] + if (!loader) return Promise.resolve() + + let loadPromise = builtInDashboardComponentPromises.get(id) + if (!loadPromise) { + loadPromise = loader().catch(error => { + builtInDashboardComponentPromises.delete(id) + throw error + }) + builtInDashboardComponentPromises.set(id, loadPromise) + } + + return loadPromise +} + +// 创建内置仪表盘异步组件,并与加载完成上报共享同一份加载 Promise。 +function createAsyncDashboardComponent(id: string) { + return defineAsyncComponent({ + loader: () => loadBuiltInDashboardComponent(id), + ...asyncDashboardOptions, + }) +} + // 内置仪表盘按需加载,关闭的卡片不再挤进 dashboard 首屏 chunk。 -const AnalyticsStorage = defineAsyncComponent({ - loader: () => import('@/views/dashboard/AnalyticsStorage.vue'), - ...asyncDashboardOptions, -}) -const AnalyticsMediaStatistic = defineAsyncComponent({ - loader: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'), - ...asyncDashboardOptions, -}) -const AnalyticsWeeklyOverview = defineAsyncComponent({ - loader: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'), - ...asyncDashboardOptions, -}) -const AnalyticsSpeed = defineAsyncComponent({ - loader: () => import('@/views/dashboard/AnalyticsSpeed.vue'), - ...asyncDashboardOptions, -}) -const AnalyticsScheduler = defineAsyncComponent({ - loader: () => import('@/views/dashboard/AnalyticsScheduler.vue'), - ...asyncDashboardOptions, -}) -const AnalyticsCpu = defineAsyncComponent({ - loader: () => import('@/views/dashboard/AnalyticsCpu.vue'), - ...asyncDashboardOptions, -}) -const AnalyticsMemory = defineAsyncComponent({ - loader: () => import('@/views/dashboard/AnalyticsMemory.vue'), - ...asyncDashboardOptions, -}) -const AnalyticsNetwork = defineAsyncComponent({ - loader: () => import('@/views/dashboard/AnalyticsNetwork.vue'), - ...asyncDashboardOptions, -}) -const MediaServerLibrary = defineAsyncComponent({ - loader: () => import('@/views/dashboard/MediaServerLibrary.vue'), - ...asyncDashboardOptions, -}) -const MediaServerPlaying = defineAsyncComponent({ - loader: () => import('@/views/dashboard/MediaServerPlaying.vue'), - ...asyncDashboardOptions, -}) -const MediaServerLatest = defineAsyncComponent({ - loader: () => import('@/views/dashboard/MediaServerLatest.vue'), - ...asyncDashboardOptions, -}) +const AnalyticsStorage = createAsyncDashboardComponent('storage') +const AnalyticsMediaStatistic = createAsyncDashboardComponent('mediaStatistic') +const AnalyticsWeeklyOverview = createAsyncDashboardComponent('weeklyOverview') +const AnalyticsSpeed = createAsyncDashboardComponent('speed') +const AnalyticsScheduler = createAsyncDashboardComponent('scheduler') +const AnalyticsCpu = createAsyncDashboardComponent('cpu') +const AnalyticsMemory = createAsyncDashboardComponent('memory') +const AnalyticsNetwork = createAsyncDashboardComponent('network') +const MediaServerLibrary = createAsyncDashboardComponent('library') +const MediaServerPlaying = createAsyncDashboardComponent('playing') +const MediaServerLatest = createAsyncDashboardComponent('latest') // 输入参数 const props = defineProps({ @@ -78,27 +88,43 @@ const props = defineProps({ }, }) -const emit = defineEmits(['update:refreshStatus']) +const emit = defineEmits(['update:refreshStatus', 'loaded']) + +// 当前仪表盘节点是否已经向页面层报告过加载完成。 +const isDashboardElementLoaded = ref(false) + +let isDashboardElementUnmounted = false +let pluginDashboardComponentLoadPromise: Promise | null = null // 插件UI渲染模式 ('vuetify' 或 'vue') const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify') +// 加载 Vue 模式的插件仪表盘远程组件,并缓存当前节点的加载 Promise。 +function loadPluginDashboardComponent() { + if (!props.config?.id) return Promise.reject(new Error('插件ID不存在')) + + if (!pluginDashboardComponentLoadPromise) { + pluginDashboardComponentLoadPromise = loadRemoteComponent(props.config.id, 'Dashboard').catch(error => { + pluginDashboardComponentLoadPromise = null + throw error + }) + } + + return pluginDashboardComponentLoadPromise +} + // Vue 模式:动态加载的组件 const dynamicPluginComponent = defineAsyncComponent({ // 工厂函数 loader: async () => { try { - if (!props.config?.id) { - throw new Error('插件ID不存在') - } - - // 动态加载远程组件 - const module = await loadRemoteComponent(props.config.id, 'Dashboard') + const module = await loadPluginDashboardComponent() // 直接返回加载的组件,无需再获取default return module } catch (error) { console.error('加载远程组件失败:', error) + throw error } }, // 加载中显示的组件 @@ -115,7 +141,53 @@ const dynamicPluginComponent = defineAsyncComponent({ }, }) +// 判断当前配置是否对应内置异步仪表盘组件。 +function isBuiltInDashboardElement() { + return !!props.config?.id && !!builtInDashboardComponentLoaders[props.config.id] +} + +// 判断当前配置是否需要等待插件 Vue 远程组件加载。 +function isVuePluginDashboardElement() { + return !isBuiltInDashboardElement() && pluginRenderMode.value === 'vue' && !isNullOrEmptyObject(props.config) +} + +// 向页面层上报当前仪表盘节点已完成首次组件加载。 +function emitDashboardElementLoaded() { + if (isDashboardElementLoaded.value || isDashboardElementUnmounted) return + + isDashboardElementLoaded.value = true + emit('loaded') +} + +// 等待当前仪表盘节点的异步组件加载完成,静态渲染模式则等待一次 DOM 更新。 +async function waitForDashboardElementLoaded() { + if (isDashboardElementLoaded.value) return + + try { + if (isBuiltInDashboardElement() && props.config?.id) { + await loadBuiltInDashboardComponent(props.config.id) + } else if (isVuePluginDashboardElement()) { + await loadPluginDashboardComponent() + } + + await nextTick() + } catch (error) { + console.error(error) + } finally { + emitDashboardElementLoaded() + } +} + +watch( + () => [props.config?.id, props.config?.key, pluginRenderMode.value], + () => { + void waitForDashboardElementLoaded() + }, + { immediate: true }, +) + onUnmounted(() => { + isDashboardElementUnmounted = true // 组件卸载时禁用刷新状态 emit('update:refreshStatus', false) }) @@ -179,9 +251,4 @@ onUnmounted(() => { inline-size: 100%; min-block-size: 0; } - -.dashboard-plugin-vue-renderer :deep(> *) { - flex: 1 1 auto; - min-block-size: 0; -} diff --git a/src/pages/dashboard.vue b/src/pages/dashboard.vue index bb0f3c37..84b85ebd 100644 --- a/src/pages/dashboard.vue +++ b/src/pages/dashboard.vue @@ -61,6 +61,15 @@ const dashboardGridRef = ref(null) // GridStack 实例 const dashboardGrid = shallowRef(null) +// 仪表板配置是否已完成首次加载,包含插件仪表板配置。 +const isDashboardConfigLoaded = ref(false) + +// 仪表板是否已完成首次整体渐现。 +const isDashboardRevealed = ref(false) + +// 已完成组件模块加载的仪表板项目 ID。 +const loadedDashboardGridItemIds = ref>(new Set()) + // 是否正在由 Vue 同步 GridStack,避免初始化写入覆盖用户布局 const isSyncingDashboardGrid = ref(false) @@ -77,6 +86,8 @@ const dashboardGridObservedContentHeights = new Map() let dashboardGridContentObserver: ResizeObserver | null = null let dashboardGridContentResizeFrame: number | null = null let dashboardGridResizeRefreshFrame: number | null = null +let dashboardRevealFrame: number | null = null +let isDashboardRevealPending = false // 是否正在手动缩放组件,避免自动测高抢回用户拖动中的高度。 const isDashboardGridResizing = ref(false) @@ -226,6 +237,79 @@ const dashboardGridItems = computed(() => }), ) +// 获取当前可渲染仪表板项目 ID 列表。 +function getDashboardGridItemIds() { + return dashboardGridItems.value.map(item => item.id) +} + +// 清理已经不在当前仪表板列表中的加载完成标记。 +function syncDashboardLoadedItemIds() { + const currentIds = new Set(getDashboardGridItemIds()) + const nextLoadedIds = new Set([...loadedDashboardGridItemIds.value].filter(id => currentIds.has(id))) + + if (nextLoadedIds.size !== loadedDashboardGridItemIds.value.size) { + loadedDashboardGridItemIds.value = nextLoadedIds + } +} + +// 判断当前启用的仪表板项目是否都已经完成组件加载。 +function areDashboardGridItemsLoaded() { + return getDashboardGridItemIds().every(id => loadedDashboardGridItemIds.value.has(id)) +} + +// 判断 GridStack 是否已经可承载当前仪表板项目。 +function isDashboardGridReadyForReveal() { + return getDashboardGridItemIds().length === 0 || !!dashboardGrid.value +} + +// 在配置、组件和 GridStack 都就绪后安排仪表板整体渐现。 +function scheduleDashboardReveal() { + if ( + isDashboardRevealed.value || + isDashboardRevealPending || + dashboardRevealFrame !== null || + !isDashboardConfigLoaded.value || + !isDashboardGridReadyForReveal() || + !areDashboardGridItemsLoaded() + ) { + return + } + + isDashboardRevealPending = true + void nextTick(() => { + isDashboardRevealPending = false + if ( + isDashboardRevealed.value || + !isDashboardConfigLoaded.value || + !isDashboardGridReadyForReveal() || + !areDashboardGridItemsLoaded() + ) { + return + } + + resizeAutoDashboardItemsToContent() + + if (typeof window === 'undefined') { + isDashboardRevealed.value = true + return + } + + dashboardRevealFrame = window.requestAnimationFrame(() => { + dashboardRevealFrame = null + isDashboardRevealed.value = true + notifyDashboardContentResize() + }) + }) +} + +// 标记单个仪表板项目已经完成首次组件加载。 +function markDashboardGridItemLoaded(id: string) { + if (loadedDashboardGridItemIds.value.has(id)) return + + loadedDashboardGridItemIds.value = new Set([...loadedDashboardGridItemIds.value, id]) + scheduleDashboardReveal() +} + // 将未知数值限制到 GridStack 可接受的整数区间。 function clampGridNumber(value: unknown, min: number, max: number, fallback: number) { const numericValue = Number(value) @@ -486,7 +570,7 @@ async function saveDashboardConfig(payload?: { enabled?: Record console.error(error) } // 保存后重新获取插件仪表板 - getPluginDashboardMeta() + void getPluginDashboardMeta() settingsDialogController?.close() settingsDialogController = null } @@ -501,16 +585,19 @@ function buildPluginDashboardId(plugin_id: string, key: string) { async function getPluginDashboardMeta() { // 只有超级用户才能获取 if (!superUser) return - pluginDashboardMeta.value = await api.get('/plugin/dashboard/meta') + try { + pluginDashboardMeta.value = (await api.get('/plugin/dashboard/meta')) ?? [] if (!isNullOrEmptyObject(pluginDashboardMeta.value)) { // 下载插件仪表板配置 - pluginDashboardMeta.value.forEach(async (pluginDashboard: { id: string; key: string }) => { - const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key) - // 初始化插件仪表板的刷新状态 - pluginDashboardRefreshStatus.value[pluginDashboardId] = true - await getPluginDashboard(pluginDashboard.id, pluginDashboard.key) - }) + await Promise.all( + pluginDashboardMeta.value.map(async (pluginDashboard: { id: string; key: string }) => { + const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key) + // 初始化插件仪表板的刷新状态 + pluginDashboardRefreshStatus.value[pluginDashboardId] = true + await getPluginDashboard(pluginDashboard.id, pluginDashboard.key) + }), + ) } } catch (error) { console.error(error) @@ -535,7 +622,7 @@ function schedulePluginDashboardRefresh(item: DashboardItem) { isRequest.value ) { refreshTimers.value[pluginDashboardId] = setTimeout(() => { - getPluginDashboard(item.id, item.key) + void getPluginDashboard(item.id, item.key) }, item.attrs.refresh * 1000) } } @@ -546,7 +633,7 @@ function refreshEnabledPluginDashboards() { pluginDashboardMeta.value.forEach((pluginDashboard: { id: string; key: string }) => { const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key) if (enableConfig.value[pluginDashboardId]) { - getPluginDashboard(pluginDashboard.id, pluginDashboard.key) + void getPluginDashboard(pluginDashboard.id, pluginDashboard.key) } }) } @@ -555,34 +642,32 @@ function refreshEnabledPluginDashboards() { async function getPluginDashboard(id: string, key: string) { try { const url = key ? `/plugin/dashboard/${id}/${key}` : `/plugin/dashboard/${id}` - api.get(url).then((res: any) => { - if (res) { - // 名称替换为元信息的名称 - const meta = pluginDashboardMeta.value.find( - (item: { id: string; key: string }) => item.id === id && item.key === key, - ) - if (meta) res.name = meta.name - // 保存到仪表板配置中,如果已经存在则替换 - const index = dashboardConfigs.value.findIndex( - (item: { id: string; key: string }) => item.id === id && item.key === key, - ) - if (index !== -1) { - dashboardConfigs.value[index] = res - } else { - dashboardConfigs.value.push(res) - // 为新增的插件仪表板生成颜色 - const pluginDashboardId = buildPluginDashboardId(id, key) - if (!itemColors.value[pluginDashboardId]) { - itemColors.value[pluginDashboardId] = getItemColor(pluginDashboardId) - } - // 排序 - sortDashboardConfigs() - } + const res: DashboardItem | undefined = await api.get(url) + if (res) { + // 名称替换为元信息的名称 + const meta = pluginDashboardMeta.value.find( + (item: { id: string; key: string }) => item.id === id && item.key === key, + ) + if (meta) res.name = meta.name + // 保存到仪表板配置中,如果已经存在则替换 + const index = dashboardConfigs.value.findIndex( + (item: { id: string; key: string }) => item.id === id && item.key === key, + ) + if (index !== -1) { + dashboardConfigs.value[index] = res + } else { + dashboardConfigs.value.push(res) + // 为新增的插件仪表板生成颜色 const pluginDashboardId = buildPluginDashboardId(id, key) - // 定时刷新 - schedulePluginDashboardRefresh(res) + if (!itemColors.value[pluginDashboardId]) { + itemColors.value[pluginDashboardId] = getItemColor(pluginDashboardId) + } + // 排序 + sortDashboardConfigs() } - }) + // 定时刷新 + schedulePluginDashboardRefresh(res) + } } catch (error) { console.error(error) } @@ -689,7 +774,10 @@ async function syncDashboardGrid() { grid.batchUpdate(false) updateDashboardGridEditableState(isLayoutEditing.value) observeDashboardGridContent() - nextTick(resizeAutoDashboardItemsToContent) + nextTick(() => { + resizeAutoDashboardItemsToContent() + scheduleDashboardReveal() + }) } finally { isSyncingDashboardGrid.value = false } @@ -731,8 +819,7 @@ function shouldScheduleDashboardContentResize(element: GridItemHTMLElement, next dashboardGridObservedContentHeights.set(id, nextHeight) return ( - previousHeight === undefined || - Math.abs(nextHeight - previousHeight) >= DASHBOARD_GRID_CONTENT_RESIZE_THRESHOLD + previousHeight === undefined || Math.abs(nextHeight - previousHeight) >= DASHBOARD_GRID_CONTENT_RESIZE_THRESHOLD ) } @@ -865,7 +952,9 @@ watch(isLayoutEditing, value => { watch( dashboardGridItems, () => { + syncDashboardLoadedItemIds() syncDashboardGrid() + scheduleDashboardReveal() }, { deep: true }, ) @@ -873,7 +962,9 @@ watch( onBeforeMount(async () => { await loadDashboardConfig() initializeColors() - getPluginDashboardMeta() + await getPluginDashboardMeta() + isDashboardConfigLoaded.value = true + scheduleDashboardReveal() }) onMounted(() => { @@ -903,6 +994,10 @@ onBeforeUnmount(() => { cancelAnimationFrame(dashboardGridResizeRefreshFrame) dashboardGridResizeRefreshFrame = null } + if (dashboardRevealFrame !== null) { + cancelAnimationFrame(dashboardRevealFrame) + dashboardRevealFrame = null + } dashboardGridPendingContentResize.clear() dashboardGridObservedContentHeights.clear() dashboardGridResizeStartHeights.clear() @@ -913,7 +1008,11 @@ onBeforeUnmount(() => {