feat(dashboard): 优化仪表盘组件加载逻辑,支持异步加载和状态管理

This commit is contained in:
jxxghp
2026-06-07 08:57:25 +08:00
parent 5e3e106d91
commit 8e5b8f7207
9 changed files with 282 additions and 141 deletions

View File

@@ -6,6 +6,8 @@ import DashboardRender from '@/components/render/DashboardRender.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import { loadRemoteComponent } from '@/utils/federationLoader'
type DashboardComponentLoader = () => Promise<any>
const DashboardSkeleton = {
setup() {
const SkeletonLoader = resolveComponent('VSkeletonLoader')
@@ -19,51 +21,59 @@ const asyncDashboardOptions = {
loadingComponent: DashboardSkeleton,
}
const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader> = {
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<string, Promise<any>>()
// 复用内置仪表盘组件加载 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<any> | 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;
}
</style>

View File

@@ -61,6 +61,15 @@ const dashboardGridRef = ref<HTMLElement | null>(null)
// GridStack 实例
const dashboardGrid = shallowRef<GridStack | null>(null)
// 仪表板配置是否已完成首次加载,包含插件仪表板配置。
const isDashboardConfigLoaded = ref(false)
// 仪表板是否已完成首次整体渐现。
const isDashboardRevealed = ref(false)
// 已完成组件模块加载的仪表板项目 ID。
const loadedDashboardGridItemIds = ref<Set<string>>(new Set())
// 是否正在由 Vue 同步 GridStack避免初始化写入覆盖用户布局
const isSyncingDashboardGrid = ref(false)
@@ -77,6 +86,8 @@ const dashboardGridObservedContentHeights = new Map<string, number>()
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<DashboardGridItem[]>(() =>
}),
)
// 获取当前可渲染仪表板项目 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<string, boolean>
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(() => {
<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, 'is-ready': isDashboardRevealed }"
>
<div
v-for="gridItem in dashboardGridItems"
:key="gridItem.id"
@@ -935,6 +1034,7 @@ onBeforeUnmount(() => {
:config="gridItem.config"
:allow-refresh="isRequest"
v-model:refreshStatus="pluginDashboardRefreshStatus[gridItem.id]"
@loaded="markDashboardGridItemLoaded(gridItem.id)"
/>
</div>
<span v-if="isLayoutEditing" class="dashboard-grid-drag-handle" :aria-label="t('dashboard.dragHandle')">
@@ -979,18 +1079,19 @@ onBeforeUnmount(() => {
/* stylelint-disable selector-pseudo-class-no-unknown */
.dashboard-grid {
margin-block: -6px 0;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition:
opacity 0.45s cubic-bezier(0.25, 1, 0.5, 1),
transform 0.45s cubic-bezier(0.25, 1, 0.5, 1);
will-change: opacity, transform;
}
.dashboard-grid :deep(.v-card) {
overflow: hidden;
box-shadow: var(--app-surface-shadow) !important;
}
@media (hover: hover) {
.dashboard-grid :deep(.v-card:hover) {
box-shadow: var(--app-surface-hover-shadow) !important;
}
.dashboard-grid.is-ready {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.dashboard-grid-item.is-manual-height :deep(.v-card) {
@@ -1090,4 +1191,11 @@ onBeforeUnmount(() => {
inset-block-end: -4px;
inset-inline-end: -4px;
}
@media (prefers-reduced-motion: reduce) {
.dashboard-grid {
transform: none;
transition: none;
}
}
</style>

View File

@@ -159,9 +159,4 @@ useKeepAliveRefresh(refresh)
min-block-size: 0;
}
.dashboard-chart-plot :deep(.vue-apexcharts),
.dashboard-chart-plot :deep(.apexcharts-canvas),
.dashboard-chart-plot :deep(svg) {
block-size: 100% !important;
}
</style>

View File

@@ -163,10 +163,4 @@ useKeepAliveRefresh(refresh)
flex: 1 1 auto;
min-block-size: 0;
}
.dashboard-chart-plot :deep(.vue-apexcharts),
.dashboard-chart-plot :deep(.apexcharts-canvas),
.dashboard-chart-plot :deep(svg) {
block-size: 100% !important;
}
</style>

View File

@@ -206,9 +206,4 @@ useKeepAliveRefresh(refresh)
min-block-size: 0;
}
.dashboard-chart-plot :deep(.vue-apexcharts),
.dashboard-chart-plot :deep(.apexcharts-canvas),
.dashboard-chart-plot :deep(svg) {
block-size: 100% !important;
}
</style>

View File

@@ -164,10 +164,4 @@ onActivated(() => {
flex: 1 1 auto;
min-block-size: 0;
}
.dashboard-work-chart :deep(.vue-apexcharts),
.dashboard-work-chart :deep(.apexcharts-canvas),
.dashboard-work-chart :deep(svg) {
block-size: 100% !important;
}
</style>

View File

@@ -100,8 +100,4 @@ onActivated(() => {
flex: 1 1 auto;
min-block-size: 0;
}
.dashboard-media-grid :deep(.progressive-card-grid__track) {
min-block-size: 100%;
}
</style>

View File

@@ -89,8 +89,4 @@ onActivated(() => {
flex: 1 1 auto;
min-block-size: 0;
}
.dashboard-media-grid :deep(.progressive-card-grid__track) {
min-block-size: 100%;
}
</style>

View File

@@ -90,8 +90,4 @@ onActivated(() => {
flex: 1 1 auto;
min-block-size: 0;
}
.dashboard-media-grid :deep(.progressive-card-grid__track) {
min-block-size: 100%;
}
</style>