Files
MoviePilot-Frontend/src/pages/dashboard.vue

1280 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { GridStack } from 'gridstack'
import type { GridItemHTMLElement, GridStackWidget } from 'gridstack'
import 'gridstack/dist/gridstack.min.css'
import api from '@/api'
import { isNullOrEmptyObject } from '@/@core/utils'
import type { DashboardItem } from '@/api/types'
import DashboardElement from '@/components/misc/DashboardElement.vue'
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
const ContentToggleSettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
)
// 国际化
const { t } = useI18n()
// PWA模式检测
const { appMode } = usePWA()
const userStore = useUserStore()
const canAdmin = computed(() =>
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
)
// 路由
const route = useRoute()
const DASHBOARD_GRID_COLUMNS = 12
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_ENABLE_STORAGE_KEY = 'MP_DASHBOARD'
const DASHBOARD_ORDER_STORAGE_KEY = 'MP_DASHBOARD_ORDER'
const DASHBOARD_GRID_LAYOUT_STORAGE_KEY = 'MP_DASHBOARD_GRID_LAYOUT'
const DASHBOARD_ENABLE_CONFIG_KEY = 'Dashboard'
const DASHBOARD_ORDER_CONFIG_KEY = 'DashboardOrder'
const DASHBOARD_GRID_LAYOUT_CONFIG_KEY = 'DashboardGridLayout'
type DashboardEnableConfig = Record<string, boolean>
type DashboardOrderConfig = { id: string; key: string }[]
type DashboardGridLayoutConfig = Record<string, DashboardGridLayoutItem>
type DashboardConfigNormalizer<T> = (value: unknown) => T | undefined
type DashboardConfigRemoteValueBuilder<T> = (value: T) => unknown
interface DashboardGridLayoutItem {
x?: number
y?: number
w?: number
h?: number
}
interface DashboardGridItem {
config: DashboardItem
id: string
widget: GridStackWidget
}
// 是否处于仪表板布局编辑模式
const isLayoutEditing = ref(false)
// 是否发送请求的总开关
const isRequest = ref(true)
// GridStack 容器引用
const dashboardGridRef = ref<HTMLElement | null>(null)
// GridStack 实例
const dashboardGrid = shallowRef<GridStack | null>(null)
// 仪表板配置是否已完成首次加载,包含插件仪表板配置。
const isDashboardConfigLoaded = ref(false)
// 已完成组件模块加载的仪表板项目 ID。
const loadedDashboardGridItemIds = ref<Set<string>>(new Set())
// 是否正在由 Vue 同步 GridStack避免初始化写入覆盖用户布局
const isSyncingDashboardGrid = ref(false)
// 仪表板本地布局覆盖配置
const dashboardGridLayout = ref<Record<string, DashboardGridLayoutItem>>({})
// 是否刚恢复过默认布局,用于避免退出编辑时立即把默认布局写回本地覆盖。
const isDashboardGridLayoutResetPending = ref(false)
const dashboardGridResizeStartHeights = new Map<string, number | undefined>()
const dashboardGridPendingContentResize = new Set<GridItemHTMLElement>()
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)
// 所有组件刷新定时器的句柄
const refreshTimers = ref<{ [key: string]: NodeJS.Timeout }>({})
// 仪表板启用配置
const enableConfig = ref<{ [key: string]: boolean }>({
mediaStatistic: true,
scheduler: false,
speed: false,
storage: true,
weeklyOverview: false,
cpu: false,
memory: false,
network: false,
library: true,
playing: true,
latest: true,
})
// 仪表板顺序配置
const orderConfig = ref<{ id: string; key: string }[]>([])
// 仪表板配置
const dashboardConfigs = ref<DashboardItem[]>([
{
id: 'storage',
name: t('dashboard.storage'),
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 5,
elements: [],
},
{
id: 'mediaStatistic',
name: t('dashboard.mediaStatistic'),
key: '',
attrs: {},
cols: { cols: 12, md: 8 },
rows: 5,
elements: [],
},
{
id: 'weeklyOverview',
name: t('dashboard.weeklyOverview'),
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 11,
elements: [],
},
{
id: 'speed',
name: t('dashboard.realTimeSpeed'),
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 11,
elements: [],
},
{
id: 'scheduler',
name: t('dashboard.scheduler'),
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 11,
elements: [],
},
{
id: 'cpu',
name: t('dashboard.cpu'),
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
rows: 8,
elements: [],
},
{
id: 'memory',
name: t('dashboard.memory'),
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
rows: 8,
elements: [],
},
{
id: 'network',
name: t('dashboard.network'),
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
rows: 8,
elements: [],
},
{
id: 'library',
name: t('dashboard.library'),
key: '',
attrs: {},
cols: { cols: 12 },
elements: [],
},
{
id: 'playing',
name: t('dashboard.playing'),
key: '',
attrs: {},
cols: { cols: 12 },
elements: [],
},
{
id: 'latest',
name: t('dashboard.latest'),
key: '',
attrs: {},
cols: { cols: 12 },
elements: [],
},
])
// 插件的仪表板元信息
const pluginDashboardMeta = ref<any[]>([])
// 插件仪表板的刷新状态
const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
// 为每个项目生成随机颜色
const itemColors = ref<{ [key: string]: string }>({})
// 当前启用且可渲染的仪表板 Grid 项。
const dashboardGridItems = computed<DashboardGridItem[]>(() =>
dashboardConfigs.value
.filter(item => enableConfig.value[buildPluginDashboardId(item.id, item.key)] && item.cols)
.map(item => {
const id = buildPluginDashboardId(item.id, item.key)
return {
config: item,
id,
widget: buildDashboardGridWidget(item, id),
}
}),
)
// 获取当前可渲染仪表板项目 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 (
isDashboardRevealPending ||
dashboardRevealFrame !== null ||
!isDashboardConfigLoaded.value ||
!isDashboardGridReadyForReveal() ||
!areDashboardGridItemsLoaded()
) {
return
}
isDashboardRevealPending = true
void nextTick(() => {
isDashboardRevealPending = false
if (!isDashboardConfigLoaded.value || !isDashboardGridReadyForReveal() || !areDashboardGridItemsLoaded()) {
return
}
resizeAutoDashboardItemsToContent()
if (typeof window === 'undefined') {
return
}
dashboardRevealFrame = window.requestAnimationFrame(() => {
dashboardRevealFrame = null
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)
if (!Number.isFinite(numericValue)) return fallback
return Math.min(max, Math.max(min, Math.round(numericValue)))
}
// 校验并归一化仪表板显示配置,避免异常用户配置影响页面渲染。
function normalizeDashboardEnableConfig(value: unknown): DashboardEnableConfig | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
return Object.entries(value).reduce<DashboardEnableConfig>((config, [key, enabled]) => {
config[key] = Boolean(enabled)
return config
}, {})
}
// 校验并归一化仪表板顺序配置,只保留具备组件 ID 的项目。
function normalizeDashboardOrderConfig(value: unknown): DashboardOrderConfig | undefined {
if (!Array.isArray(value)) return undefined
return value.reduce<DashboardOrderConfig>((config, item) => {
if (!item || typeof item !== 'object') return config
const rawItem = item as { id?: unknown; key?: unknown }
if (typeof rawItem.id !== 'string' || !rawItem.id) return config
config.push({
id: rawItem.id,
key: typeof rawItem.key === 'string' ? rawItem.key : '',
})
return config
}, [])
}
// 校验并归一化仪表板 Grid 布局覆盖配置,兼容旧版裸布局和新版服务端包装结构。
function normalizeDashboardGridLayout(value: unknown): DashboardGridLayoutConfig | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const configValue = value as { items?: unknown }
const layoutValue = configValue.items && typeof configValue.items === 'object' ? configValue.items : value
const normalizedLayout: DashboardGridLayoutConfig = {}
Object.entries(layoutValue).forEach(([id, layout]) => {
if (!layout || typeof layout !== 'object') return
const rawLayout = layout as DashboardGridLayoutItem
const width = clampGridNumber(rawLayout.w, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
const normalizedItemLayout: DashboardGridLayoutItem = {
x: clampGridNumber(rawLayout.x, 0, DASHBOARD_GRID_COLUMNS - width, 0),
y: clampGridNumber(rawLayout.y, 0, 999, 0),
w: width,
}
if (rawLayout.h !== undefined) {
normalizedItemLayout.h = clampGridNumber(rawLayout.h, 1, 96, getDefaultDashboardGridRows())
}
normalizedLayout[id] = normalizedItemLayout
})
return normalizedLayout
}
// 构造服务端 Grid 布局配置,避免空布局被后端按空值删除后又被其他浏览器旧缓存回填。
function buildRemoteDashboardGridLayout(layout: DashboardGridLayoutConfig) {
return { items: layout }
}
// 从本地存储读取并归一化指定的仪表板配置。
function readLocalDashboardConfig<T>(storageKey: string, normalize: DashboardConfigNormalizer<T>) {
const rawConfig = localStorage.getItem(storageKey)
if (!rawConfig) return undefined
try {
return normalize(JSON.parse(rawConfig))
} catch (error) {
console.error(error)
return undefined
}
}
// 将仪表板配置写入本地存储,保留离线和接口失败时的兜底能力。
function saveLocalDashboardConfig(storageKey: string, value: unknown) {
localStorage.setItem(storageKey, JSON.stringify(value))
}
// 将仪表板配置写入用户配置,用于跨浏览器共享。
async function saveUserDashboardConfig(configKey: string, value: unknown) {
await api.post(`/user/config/${configKey}`, value)
}
// 优先加载用户配置;服务端缺失时使用本地历史配置并回填到用户配置。
async function loadSharedDashboardConfig<T>(
configKey: string,
storageKey: string,
normalize: DashboardConfigNormalizer<T>,
buildRemoteValue: DashboardConfigRemoteValueBuilder<T> = value => value,
) {
const localConfig = readLocalDashboardConfig(storageKey, normalize)
try {
const response = await api.get(`/user/config/${configKey}`)
const remoteConfig = normalize(response?.data?.value)
if (remoteConfig !== undefined) {
saveLocalDashboardConfig(storageKey, remoteConfig)
return remoteConfig
}
if (localConfig !== undefined) {
await saveUserDashboardConfig(configKey, buildRemoteValue(localConfig))
}
} catch (error) {
console.error(error)
}
return localConfig
}
// 将当前仪表板布局覆盖配置保存到本地和用户配置。
function saveDashboardGridLayout(layout: Record<string, DashboardGridLayoutItem>) {
saveLocalDashboardConfig(DASHBOARD_GRID_LAYOUT_STORAGE_KEY, layout)
void saveUserDashboardConfig(DASHBOARD_GRID_LAYOUT_CONFIG_KEY, buildRemoteDashboardGridLayout(layout)).catch(error =>
console.error(error),
)
}
// 获取仪表板组件的默认宽度,优先兼容插件旧版 cols.md / cols.cols 配置。
function getDefaultDashboardGridWidth(item: DashboardItem) {
return clampGridNumber(item.cols?.md ?? item.cols?.cols, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
}
// 获取仪表板组件测量前的兜底高度,兼容未来 rows 字段和插件 attrs.rows。
function getDefaultDashboardGridRows(item?: DashboardItem) {
return clampGridNumber(item?.rows ?? item?.attrs?.rows, 1, 96, DASHBOARD_GRID_FALLBACK_ROWS)
}
// 合并插件/内置组件默认尺寸与用户本地布局覆盖。
function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWidget {
const savedLayout = dashboardGridLayout.value[id]
const width = savedLayout?.w ?? getDefaultDashboardGridWidth(item)
const height = savedLayout?.h ?? getDefaultDashboardGridRows(item)
const normalizedWidth = clampGridNumber(width, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
const widget: GridStackWidget = {
id,
w: normalizedWidth,
h: clampGridNumber(height, 1, 96, getDefaultDashboardGridRows(item)),
minW: 1,
minH: 1,
}
if (savedLayout?.x !== undefined && savedLayout?.y !== undefined) {
widget.x = clampGridNumber(savedLayout.x, 0, DASHBOARD_GRID_COLUMNS - normalizedWidth, 0)
widget.y = clampGridNumber(savedLayout.y, 0, 999, 0)
} else {
widget.autoPosition = true
}
return widget
}
// 初始化颜色。
function initializeColors() {
initializeItemColors(dashboardConfigs.value, item => buildPluginDashboardId(item.id, item.key))
dashboardConfigs.value.forEach(item => {
const itemId = buildPluginDashboardId(item.id, item.key)
itemColors.value[itemId] = getItemColor(itemId)
})
}
// 使用动态按钮钩子
let settingsDialogController: ReturnType<typeof openSharedDialog> | null = null
// 打开仪表板共享设置弹窗。
function openDashboardSettings() {
settingsDialogController?.close()
settingsDialogController = openSharedDialog(
ContentToggleSettingsDialog,
{
colors: itemColors.value,
enabled: enableConfig.value,
hint: t('dashboard.chooseContent'),
items: dashboardConfigs.value,
labelGetter: (item: DashboardItem) => item.attrs?.title ?? item.name,
title: t('dashboard.settings'),
valueGetter: (item: DashboardItem) => buildPluginDashboardId(item.id, item.key),
},
{
close: () => {
settingsDialogController = null
},
save: saveDashboardConfig,
'update:modelValue': (value: boolean) => {
if (!value) settingsDialogController = null
},
},
{ closeOn: ['close', 'update:modelValue'] },
)
}
// 退出仪表板布局编辑模式;如果刚恢复默认布局,则跳过本次本地持久化。
function exitDashboardLayoutEditing() {
if (isDashboardGridLayoutResetPending.value) {
isDashboardGridLayoutResetPending.value = false
} else {
compactAndPersistDashboardGrid()
}
isLayoutEditing.value = false
}
// 清除用户本地布局覆盖,并恢复内置组件和插件声明的默认占位,然后退出编辑模式。
async function resetDashboardGridLayout() {
dashboardGridLayout.value = {}
saveDashboardGridLayout({})
dashboardGrid.value?.removeAll(false, false)
isDashboardGridLayoutResetPending.value = true
await syncDashboardGrid()
if (isLayoutEditing.value) {
exitDashboardLayoutEditing()
}
}
// 生成 appMode 底部动态按钮菜单,普通 Web 模式由页面内 FAB 承接。
const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undefined>(() => {
if (!appMode.value) return undefined
const items: DynamicButtonMenuItem[] = [
{
title: isLayoutEditing.value ? t('dashboard.exitEditMode') : t('dashboard.editLayout'),
icon: isLayoutEditing.value ? 'mdi-check' : 'mdi-view-dashboard-edit',
color: 'primary',
permission: 'admin',
action: toggleDashboardLayoutEditing,
},
]
if (isLayoutEditing.value) {
items.push({
title: t('dashboard.resetLayout'),
icon: 'mdi-restore',
color: 'warning',
permission: 'admin',
action: resetDashboardGridLayout,
})
}
items.push({
title: t('dashboard.settings'),
icon: 'mdi-tune',
color: 'info',
permission: 'admin',
action: openDashboardSettings,
})
return items
})
useDynamicButton({
icon: 'mdi-view-dashboard-edit',
menuItems: dashboardDynamicButtonMenuItems,
permission: 'admin',
show: computed(() => appMode.value && route.path === '/dashboard'),
})
// 切换仪表板布局编辑模式,退出编辑时压实并保存当前布局。
function toggleDashboardLayoutEditing() {
if (isLayoutEditing.value) {
exitDashboardLayoutEditing()
return
}
isDashboardGridLayoutResetPending.value = false
isLayoutEditing.value = true
nextTick(syncDashboardGrid)
}
// 加载用户监控面板配置,优先使用服务端用户配置以支持跨浏览器同步。
async function loadDashboardConfig() {
// 显示配置
const enable = await loadSharedDashboardConfig(
DASHBOARD_ENABLE_CONFIG_KEY,
DASHBOARD_ENABLE_STORAGE_KEY,
normalizeDashboardEnableConfig,
)
if (enable !== undefined) {
enableConfig.value = enable
}
// 顺序配置
const order = await loadSharedDashboardConfig(
DASHBOARD_ORDER_CONFIG_KEY,
DASHBOARD_ORDER_STORAGE_KEY,
normalizeDashboardOrderConfig,
)
if (order !== undefined) {
orderConfig.value = order
}
// Grid 布局覆盖
const gridLayout = await loadSharedDashboardConfig(
DASHBOARD_GRID_LAYOUT_CONFIG_KEY,
DASHBOARD_GRID_LAYOUT_STORAGE_KEY,
normalizeDashboardGridLayout,
buildRemoteDashboardGridLayout,
)
dashboardGridLayout.value = gridLayout ?? {}
// 排序
if (orderConfig.value) {
sortDashboardConfigs()
}
}
// 按order的顺序对dashboardConfigs进行排序
function sortDashboardConfigs() {
dashboardConfigs.value.sort((a, b) => {
const aIndex = orderConfig.value.findIndex(
(item: { id: string; key: string }) => item.id === a.id && item.key === a.key,
)
const bIndex = orderConfig.value.findIndex(
(item: { id: string; key: string }) => item.id === b.id && item.key === b.key,
)
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
})
}
// 设置项目
async function saveDashboardConfig(payload?: { enabled?: Record<string, boolean> }) {
if (payload?.enabled) {
enableConfig.value = payload.enabled
}
// 启用配置
saveLocalDashboardConfig(DASHBOARD_ENABLE_STORAGE_KEY, enableConfig.value)
// 顺序配置从dashboardConfigs中提取
const orderObj = dashboardConfigs.value.map(item => ({ id: item.id, key: item.key }))
saveLocalDashboardConfig(DASHBOARD_ORDER_STORAGE_KEY, orderObj)
// 保存到服务端
try {
await saveUserDashboardConfig(DASHBOARD_ENABLE_CONFIG_KEY, enableConfig.value)
await saveUserDashboardConfig(DASHBOARD_ORDER_CONFIG_KEY, orderObj)
} catch (error) {
console.error(error)
}
// 保存后重新获取插件仪表板
void getPluginDashboardMeta()
settingsDialogController?.close()
settingsDialogController = null
}
// 构造插件仪表板主ID
function buildPluginDashboardId(plugin_id: string, key: string) {
if (!key) return plugin_id
return plugin_id + ':' + key
}
// 调用API获取所有插件的仪表板元信息
async function getPluginDashboardMeta() {
try {
pluginDashboardMeta.value = (await api.get('/plugin/dashboard/meta')) ?? []
if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {
// 下载插件仪表板配置
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)
}
}
function clearPluginDashboardTimer(pluginDashboardId: string) {
if (!refreshTimers.value[pluginDashboardId]) return
clearTimeout(refreshTimers.value[pluginDashboardId])
delete refreshTimers.value[pluginDashboardId]
}
function schedulePluginDashboardRefresh(item: DashboardItem) {
const pluginDashboardId = buildPluginDashboardId(item.id, item.key)
clearPluginDashboardTimer(pluginDashboardId)
if (
item.attrs?.refresh &&
pluginDashboardRefreshStatus.value[pluginDashboardId] &&
enableConfig.value[pluginDashboardId] &&
isRequest.value
) {
refreshTimers.value[pluginDashboardId] = setTimeout(() => {
void getPluginDashboard(item.id, item.key)
}, item.attrs.refresh * 1000)
}
}
function refreshEnabledPluginDashboards() {
if (isNullOrEmptyObject(pluginDashboardMeta.value)) return
pluginDashboardMeta.value.forEach((pluginDashboard: { id: string; key: string }) => {
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
if (enableConfig.value[pluginDashboardId]) {
void getPluginDashboard(pluginDashboard.id, pluginDashboard.key)
}
})
}
// 获取一个插件的仪表板配置项
async function getPluginDashboard(id: string, key: string) {
try {
const url = key ? `/plugin/dashboard/${id}/${key}` : `/plugin/dashboard/${id}`
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)
if (!itemColors.value[pluginDashboardId]) {
itemColors.value[pluginDashboardId] = getItemColor(pluginDashboardId)
}
// 排序
sortDashboardConfigs()
}
// 定时刷新
schedulePluginDashboardRefresh(res)
}
} catch (error) {
console.error(error)
}
}
// 初始化 GridStack 仪表板实例。
function initializeDashboardGrid() {
if (!dashboardGridRef.value || dashboardGrid.value) return
dashboardGrid.value = GridStack.init(
{
animate: true,
cellHeight: DASHBOARD_GRID_CELL_HEIGHT,
column: DASHBOARD_GRID_COLUMNS,
columnOpts: {
breakpoints: [
{ w: 640, c: 1, layout: 'list' },
{ w: 960, c: 6, layout: 'moveScale' },
{ w: 1280, c: DASHBOARD_GRID_COLUMNS, layout: 'moveScale' },
],
layout: 'moveScale',
},
draggable: {
cancel: 'input,textarea,button,select,option,a,.dashboard-grid-no-drag',
handle: '.dashboard-grid-drag-handle',
},
float: false,
margin: DASHBOARD_GRID_MARGIN,
resizable: {
handles: 'e,s,se',
},
staticGrid: !isLayoutEditing.value,
},
dashboardGridRef.value,
)
dashboardGrid.value.on('dragstop', handleDashboardGridDragStop)
dashboardGrid.value.on('resizestart', handleDashboardGridResizeStart)
dashboardGrid.value.on('resize', handleDashboardGridResize)
dashboardGrid.value.on('resizestop', handleDashboardGridResizeStop)
updateDashboardGridEditableState(isLayoutEditing.value)
syncDashboardGrid()
}
// 根据编辑状态启用或禁用 GridStack 拖拽和缩放能力。
function updateDashboardGridEditableState(editable: boolean) {
if (!dashboardGrid.value) return
dashboardGrid.value.setStatic(!editable)
if (editable) {
dashboardGrid.value.enableMove(true)
dashboardGrid.value.enableResize(true)
}
}
// 将 Vue 渲染出的仪表板节点同步注册到 GridStack。
async function syncDashboardGrid() {
const grid = dashboardGrid.value
const gridElement = dashboardGridRef.value
if (!grid || !gridElement) return
isSyncingDashboardGrid.value = true
await nextTick()
const items = dashboardGridItems.value
const itemMap = new Map(items.map(item => [item.id, item]))
const elements = Array.from(gridElement.querySelectorAll<GridItemHTMLElement>('.dashboard-grid-item'))
try {
grid.batchUpdate()
grid.engine.nodes
.filter(node => {
const nodeId = String(node.id ?? node.el?.getAttribute('gs-id') ?? '')
return Boolean(node.el) && !itemMap.has(nodeId)
})
.forEach(node => {
if (node.el) grid.removeWidget(node.el, false, false)
})
elements.forEach(element => {
const id = element.getAttribute('gs-id') ?? ''
const item = itemMap.get(id)
if (!item) return
const widget = { ...item.widget }
if (element.gridstackNode && !dashboardGridLayout.value[id]) {
delete widget.autoPosition
delete widget.x
delete widget.y
}
if (element.gridstackNode && !hasManualDashboardGridHeight(id)) {
widget.h = element.gridstackNode.h
}
if (element.gridstackNode) {
grid.update(element, widget)
} else {
grid.makeWidget(element, widget)
}
})
grid.batchUpdate(false)
updateDashboardGridEditableState(isLayoutEditing.value)
observeDashboardGridContent()
nextTick(() => {
resizeAutoDashboardItemsToContent()
scheduleDashboardReveal()
})
} finally {
isSyncingDashboardGrid.value = false
}
}
// 判断仪表板组件高度是否已被用户手动固定。
function hasManualDashboardGridHeight(id: string) {
return dashboardGridLayout.value[id]?.h !== undefined
}
// 监听仪表板组件内容尺寸变化,让未手动调高的组件按内容高度自适应。
function observeDashboardGridContent() {
const gridElement = dashboardGridRef.value
if (!gridElement || typeof ResizeObserver === 'undefined') return
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)) {
scheduleDashboardItemContentResize(itemElement)
}
})
})
gridElement.querySelectorAll<HTMLElement>('.dashboard-grid-auto-size').forEach(element => {
dashboardGridContentObserver?.observe(element)
})
}
// 判断内容高度变化是否足够触发 GridStack 行高重算,避免 hover 级微小波动造成布局抖动。
function shouldScheduleDashboardContentResize(element: GridItemHTMLElement, nextHeight: number) {
const id = element.getAttribute('gs-id') ?? ''
if (!id) return true
const previousHeight = dashboardGridObservedContentHeights.get(id)
dashboardGridObservedContentHeights.set(id, nextHeight)
return (
previousHeight === undefined || Math.abs(nextHeight - previousHeight) >= DASHBOARD_GRID_CONTENT_RESIZE_THRESHOLD
)
}
// 延迟执行单个组件内容测高,合并连续 ResizeObserver 回调。
function scheduleDashboardItemContentResize(element: GridItemHTMLElement) {
dashboardGridPendingContentResize.add(element)
if (dashboardGridContentResizeFrame !== null) return
dashboardGridContentResizeFrame = requestAnimationFrame(() => {
dashboardGridContentResizeFrame = null
dashboardGridPendingContentResize.forEach(itemElement => resizeDashboardItemToContent(itemElement))
dashboardGridPendingContentResize.clear()
})
}
// 将未手动固定高度的单个组件高度调整到内容实际高度。
function resizeDashboardItemToContent(element: GridItemHTMLElement) {
const grid = dashboardGrid.value
const id = element.getAttribute('gs-id') ?? ''
if (!grid || !id || isLayoutEditing.value || isDashboardGridResizing.value || hasManualDashboardGridHeight(id)) return
grid.resizeToContent(element)
}
// 将所有未手动固定高度的组件高度调整到内容实际高度。
function resizeAutoDashboardItemsToContent() {
const gridElement = dashboardGridRef.value
if (!gridElement) return
gridElement.querySelectorAll<GridItemHTMLElement>('.dashboard-grid-item').forEach(element => {
resizeDashboardItemToContent(element)
})
}
// 记录缩放开始前的高度,用于区分用户是否真的手动改过高度。
function handleDashboardGridResizeStart(_event: Event, element: GridItemHTMLElement) {
const id = element.getAttribute('gs-id') ?? ''
if (!id) return
isDashboardGridResizing.value = true
dashboardGridResizeStartHeights.set(id, element.gridstackNode?.h)
notifyDashboardContentResize()
}
// 在用户缩放过程中通知图表、虚拟网格等内容重新读取容器尺寸。
function handleDashboardGridResize() {
notifyDashboardContentResize()
}
// 保存用户拖动后的位置,并保持未手动调高组件继续按内容自适应。
function handleDashboardGridDragStop() {
compactAndPersistDashboardGrid(false)
}
// 保存用户缩放后的布局,只有高度发生变化时才把高度标记为手动固定。
function handleDashboardGridResizeStop(_event: Event, element: GridItemHTMLElement) {
const id = element.getAttribute('gs-id') ?? ''
const previousHeight = dashboardGridResizeStartHeights.get(id)
const nextHeight = element.gridstackNode?.h
const heightChanged = previousHeight !== undefined && nextHeight !== undefined && previousHeight !== nextHeight
dashboardGridResizeStartHeights.delete(id)
isDashboardGridResizing.value = false
notifyDashboardContentResize()
compactAndPersistDashboardGrid(heightChanged ? id : false)
}
// 合并连续 resize 通知,模拟浏览器窗口变化让组件内部内容自适配新尺寸。
function notifyDashboardContentResize() {
if (typeof window === 'undefined' || dashboardGridResizeRefreshFrame !== null) return
dashboardGridResizeRefreshFrame = window.requestAnimationFrame(() => {
dashboardGridResizeRefreshFrame = null
window.dispatchEvent(new Event('resize'))
})
}
// 将 GridStack 保存结果归一化为本地布局覆盖表。
function persistDashboardGridLayout(manualHeightId: string | false = false) {
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
const savedWidgets = dashboardGrid.value.save(false, false, undefined, DASHBOARD_GRID_COLUMNS)
const widgets = Array.isArray(savedWidgets) ? savedWidgets : (savedWidgets.children ?? [])
const nextLayout = { ...dashboardGridLayout.value }
widgets.forEach(widget => {
if (!widget.id) return
const id = String(widget.id)
const width = clampGridNumber(widget.w, 1, DASHBOARD_GRID_COLUMNS, getDefaultDashboardGridWidthById(id))
const previousLayout = dashboardGridLayout.value[id]
const nextItemLayout: DashboardGridLayoutItem = {
x: clampGridNumber(widget.x, 0, DASHBOARD_GRID_COLUMNS - width, 0),
y: clampGridNumber(widget.y, 0, 999, 0),
w: width,
}
if (manualHeightId === id || previousLayout?.h !== undefined) {
nextItemLayout.h = clampGridNumber(widget.h, 1, 96, getDefaultDashboardGridRows())
}
nextLayout[id] = nextItemLayout
})
dashboardGridLayout.value = nextLayout
saveDashboardGridLayout(nextLayout)
nextTick(resizeAutoDashboardItemsToContent)
}
// 根据组件 ID 查找默认宽度,保存布局时用于兜底。
function getDefaultDashboardGridWidthById(id: string) {
const item = dashboardConfigs.value.find(config => buildPluginDashboardId(config.id, config.key) === id)
return item ? getDefaultDashboardGridWidth(item) : DASHBOARD_GRID_COLUMNS
}
// 压实 GridStack 布局并保存本地占位信息。
function compactAndPersistDashboardGrid(manualHeightId: string | false = false) {
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
isDashboardGridLayoutResetPending.value = false
dashboardGrid.value.compact('compact')
nextTick(() => persistDashboardGridLayout(manualHeightId))
}
watch(isLayoutEditing, value => {
updateDashboardGridEditableState(value)
})
watch(
dashboardGridItems,
() => {
syncDashboardLoadedItemIds()
syncDashboardGrid()
scheduleDashboardReveal()
},
{ deep: true },
)
onBeforeMount(async () => {
await loadDashboardConfig()
initializeColors()
await getPluginDashboardMeta()
isDashboardConfigLoaded.value = true
scheduleDashboardReveal()
})
onMounted(() => {
initializeDashboardGrid()
})
onActivated(() => {
isRequest.value = true
refreshEnabledPluginDashboards()
nextTick(syncDashboardGrid)
})
onDeactivated(() => {
isRequest.value = false
Object.keys(refreshTimers.value).forEach(clearPluginDashboardTimer)
})
onBeforeUnmount(() => {
Object.keys(refreshTimers.value).forEach(clearPluginDashboardTimer)
dashboardGridContentObserver?.disconnect()
dashboardGridContentObserver = null
if (dashboardGridContentResizeFrame !== null) {
cancelAnimationFrame(dashboardGridContentResizeFrame)
dashboardGridContentResizeFrame = null
}
if (dashboardGridResizeRefreshFrame !== null) {
cancelAnimationFrame(dashboardGridResizeRefreshFrame)
dashboardGridResizeRefreshFrame = null
}
if (dashboardRevealFrame !== null) {
cancelAnimationFrame(dashboardRevealFrame)
dashboardRevealFrame = null
}
dashboardGridPendingContentResize.clear()
dashboardGridObservedContentHeights.clear()
dashboardGridResizeStartHeights.clear()
dashboardGrid.value?.destroy(false)
dashboardGrid.value = null
})
</script>
<template>
<!-- 仪表板 -->
<div ref="dashboardGridRef" class="grid-stack dashboard-grid" :class="{ 'is-editing': isLayoutEditing }">
<div
v-for="gridItem in dashboardGridItems"
:key="gridItem.id"
class="grid-stack-item dashboard-grid-item"
:class="{ 'is-manual-height': hasManualDashboardGridHeight(gridItem.id) }"
:gs-id="gridItem.id"
:gs-x="gridItem.widget.x"
:gs-y="gridItem.widget.y"
:gs-w="gridItem.widget.w"
:gs-h="gridItem.widget.h"
:gs-auto-position="gridItem.widget.autoPosition ? 'true' : undefined"
:gs-min-w="gridItem.widget.minW"
:gs-min-h="gridItem.widget.minH"
>
<div class="grid-stack-item-content dashboard-grid-item-content">
<div class="dashboard-grid-auto-size">
<div class="dashboard-grid-content-measure">
<DashboardElement
: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')">
<VIcon icon="mdi-drag" size="small" />
</span>
</div>
</div>
</div>
</div>
<Teleport to="body" v-if="!appMode && route.path === '/dashboard'">
<div v-if="canAdmin" class="compact-fab-stack">
<VFab
icon="mdi-tune"
color="info"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="openDashboardSettings"
/>
<VFab
v-if="isLayoutEditing"
icon="mdi-restore"
color="warning"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="resetDashboardGridLayout"
/>
<VFab
:icon="isLayoutEditing ? 'mdi-check' : 'mdi-view-dashboard-edit'"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="toggleDashboardLayoutEditing"
/>
</div>
</Teleport>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.dashboard-grid {
pointer-events: auto;
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-item.is-manual-height :deep(.v-card) {
block-size: 100%;
}
.dashboard-grid-item-content {
position: relative;
}
.dashboard-grid > .dashboard-grid-item > .dashboard-grid-item-content {
overflow: visible !important;
}
.dashboard-grid-auto-size {
position: relative;
inline-size: 100%;
}
.dashboard-grid-item.is-manual-height .dashboard-grid-auto-size,
.dashboard-grid-item.is-manual-height .dashboard-grid-content-measure,
.dashboard-grid.is-editing .dashboard-grid-auto-size,
.dashboard-grid.is-editing .dashboard-grid-content-measure {
block-size: 100%;
}
.dashboard-grid.is-editing :deep(.v-card) {
block-size: 100%;
}
.dashboard-grid :deep(.dashboard-chart-card),
.dashboard-grid :deep(.dashboard-summary-card),
.dashboard-grid :deep(.dashboard-work-card),
.dashboard-grid :deep(.dashboard-media-card) {
display: flex;
flex-direction: column;
min-block-size: 0;
}
.dashboard-grid :deep(.dashboard-chart-card .v-card-text),
.dashboard-grid :deep(.dashboard-work-card .v-card-text),
.dashboard-grid :deep(.dashboard-card-grid-wrap) {
flex: 1 1 auto;
min-block-size: 0;
}
.dashboard-grid:not(.is-editing) .dashboard-grid-item:not(.is-manual-height) :deep(.dashboard-summary-card) {
min-block-size: 160px;
}
.dashboard-grid:not(.is-editing) .dashboard-grid-item:not(.is-manual-height) :deep(.dashboard-chart-card) {
min-block-size: 256px;
}
.dashboard-grid:not(.is-editing) .dashboard-grid-item:not(.is-manual-height) :deep(.dashboard-work-card) {
min-block-size: 352px;
}
.dashboard-grid.is-editing :deep(.v-card-text),
.dashboard-grid-item.is-manual-height :deep(.v-card-text) {
overflow: auto;
}
.dashboard-grid-drag-handle {
position: absolute;
z-index: 10;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 4px;
block-size: 28px;
color: rgba(var(--v-theme-on-surface), 0.72);
cursor: move;
inline-size: 28px;
inset-block-start: 8px;
inset-inline-end: 8px;
}
.dashboard-grid-drag-handle:hover {
background: rgba(var(--v-theme-on-surface), 0.12);
}
.dashboard-grid :deep(.ui-resizable-handle) {
z-index: 11;
pointer-events: auto;
}
.dashboard-grid.is-editing :deep(.ui-resizable-s) {
block-size: 18px;
inset-block-end: -4px;
}
.dashboard-grid.is-editing :deep(.ui-resizable-se) {
block-size: 24px;
inline-size: 24px;
inset-block-end: -4px;
inset-inline-end: -4px;
}
@media (prefers-reduced-motion: reduce) {
.dashboard-grid {
transform: none;
transition: none;
}
}
</style>