mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-20 12:01:48 +08:00
1587 lines
52 KiB
Vue
1587 lines
52 KiB
Vue
<script setup lang="ts">
|
||
import { GridStack } from 'gridstack'
|
||
import type { ColumnOptions, 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 { openSharedDialog } from '@/composables/useSharedDialog'
|
||
import { useUserStore } from '@/stores'
|
||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||
import { useDisplay } from 'vuetify'
|
||
|
||
const ContentToggleSettingsDialog = defineAsyncComponent(
|
||
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
||
)
|
||
|
||
// 国际化
|
||
const { t } = useI18n()
|
||
|
||
// PWA模式检测
|
||
const { appMode } = usePWA()
|
||
const display = useDisplay()
|
||
const userStore = useUserStore()
|
||
const canAdmin = computed(() =>
|
||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||
)
|
||
|
||
// 路由
|
||
const route = useRoute()
|
||
|
||
const DASHBOARD_GRID_COLUMNS = 12
|
||
const DASHBOARD_GRID_DESKTOP_BREAKPOINT = 1280
|
||
const DASHBOARD_GRID_TABLET_BREAKPOINT = 960
|
||
const DASHBOARD_GRID_MOBILE_BREAKPOINT = 640
|
||
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_PREFIX = 'MP_DASHBOARD_GRID_LAYOUT'
|
||
const DASHBOARD_ENABLE_CONFIG_KEY = 'Dashboard'
|
||
const DASHBOARD_ORDER_CONFIG_KEY = 'DashboardOrder'
|
||
const DASHBOARD_GRID_LAYOUT_CONFIG_KEY = 'DashboardGridLayout'
|
||
const DASHBOARD_GRID_LAYOUT_CONFIG_KEY_PREFIX = '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
|
||
type DashboardLayoutProfile = 'desktop' | 'tablet' | 'mobile'
|
||
|
||
// CPU 与内存组件共用默认行高,确保两张资源趋势卡片始终等高。
|
||
const DASHBOARD_RESOURCE_CHART_ROWS = 11
|
||
|
||
interface DashboardGridLayoutItem {
|
||
x?: number
|
||
y?: number
|
||
w?: number
|
||
h?: number
|
||
}
|
||
|
||
// 参考桌面端设计稿定义默认排布;用户保存过的布局仍优先于这里的初始值。
|
||
const DASHBOARD_DESKTOP_DEFAULT_LAYOUT: DashboardGridLayoutConfig = {
|
||
storage: { x: 0, y: 0, w: 4, h: 7 },
|
||
mediaStatistic: { x: 4, y: 0, w: 8, h: 7 },
|
||
speed: { x: 0, y: 7, w: 4, h: 12 },
|
||
recentImports: { x: 4, y: 7, w: 4, h: 15 },
|
||
scheduler: { x: 8, y: 7, w: 4, h: 15 },
|
||
memory: { x: 0, y: 22, w: 4, h: DASHBOARD_RESOURCE_CHART_ROWS },
|
||
cpu: { x: 4, y: 22, w: 4, h: DASHBOARD_RESOURCE_CHART_ROWS },
|
||
quickActions: { x: 8, y: 22, w: 4, h: 5 },
|
||
systemInfo: { x: 8, y: 27, w: 4, h: 6 },
|
||
}
|
||
|
||
// 单个设备档位的仪表盘配置,将布局与显示项绑定到同一份持久化数据。
|
||
interface DashboardProfileConfig {
|
||
enabled?: DashboardEnableConfig
|
||
items: DashboardGridLayoutConfig
|
||
}
|
||
|
||
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)
|
||
|
||
// 是否正在把 GridStack 当前布局写回 Vue 状态,避免同源变更再次反向同步到 GridStack。
|
||
const isPersistingDashboardGridLayoutFromGrid = ref(false)
|
||
|
||
// 仪表板本地布局覆盖配置
|
||
const dashboardGridLayout = ref<DashboardGridLayoutConfig>({})
|
||
|
||
// 当前仪表板布局档位,按 GridStack 响应式列数拆分跨端配置。
|
||
const dashboardLayoutProfile = ref<DashboardLayoutProfile>('desktop')
|
||
|
||
// 是否刚恢复过默认布局,用于避免退出编辑时立即把默认布局写回本地覆盖。
|
||
const isDashboardGridLayoutResetPending = ref(false)
|
||
|
||
// 旧版跨设备显示项配置,仅用于首次迁移到按设备拆分的仪表盘配置。
|
||
let legacyDashboardEnableConfig: DashboardEnableConfig | undefined
|
||
let isLegacyDashboardEnableConfigLoaded = 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
|
||
// 标记最近一次响应式档位切换,避免快速缩放时较早的异步配置覆盖最新档位。
|
||
let dashboardLayoutProfileSwitchId = 0
|
||
|
||
// 是否正在手动缩放组件,避免自动测高抢回用户拖动中的高度。
|
||
const isDashboardGridResizing = ref(false)
|
||
|
||
// 所有组件刷新定时器的句柄
|
||
const refreshTimers = ref<{ [key: string]: NodeJS.Timeout }>({})
|
||
|
||
// 仪表板启用配置
|
||
const enableConfig = ref<DashboardEnableConfig>(getDefaultDashboardEnableConfig())
|
||
|
||
// 仪表板顺序配置
|
||
const orderConfig = ref<DashboardOrderConfig>([])
|
||
|
||
// 仪表板配置
|
||
const dashboardConfigs = ref<DashboardItem[]>([
|
||
{
|
||
id: 'storage',
|
||
name: t('dashboard.storage'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 4 },
|
||
rows: 7,
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'mediaStatistic',
|
||
name: t('dashboard.mediaStatistic'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 8 },
|
||
rows: 7,
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'weeklyOverview',
|
||
name: t('dashboard.weeklyOverview'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 4 },
|
||
rows: 23,
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'speed',
|
||
name: t('dashboard.realTimeSpeed'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 4 },
|
||
rows: 12,
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'scheduler',
|
||
name: t('dashboard.scheduler'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 4 },
|
||
rows: 15,
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'cpu',
|
||
name: t('dashboard.cpu'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, sm: 3, md: 4 },
|
||
rows: DASHBOARD_RESOURCE_CHART_ROWS,
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'memory',
|
||
name: t('dashboard.memory'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, sm: 3, md: 4 },
|
||
rows: DASHBOARD_RESOURCE_CHART_ROWS,
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'network',
|
||
name: t('dashboard.network'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, sm: 3, md: 4 },
|
||
rows: DASHBOARD_RESOURCE_CHART_ROWS,
|
||
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: [],
|
||
},
|
||
{
|
||
id: 'recentImports',
|
||
name: t('dashboard.recentImports'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, sm: 2, md: 4 },
|
||
rows: 15,
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'quickActions',
|
||
name: t('dashboard.quickActions.title'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, sm: 3, md: 4 },
|
||
rows: 5,
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'systemInfo',
|
||
name: t('dashboard.systemInfo.title'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, sm: 3, md: 4 },
|
||
rows: 6,
|
||
elements: [],
|
||
},
|
||
])
|
||
|
||
// 插件的仪表板元信息
|
||
const pluginDashboardMeta = ref<any[]>([])
|
||
|
||
// 插件仪表板的刷新状态
|
||
const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
|
||
|
||
// 当前启用且可渲染的仪表板 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
|
||
}
|
||
|
||
syncDashboardFillContentState()
|
||
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()
|
||
void nextTick(syncDashboardFillContentState)
|
||
}
|
||
|
||
// 将未知数值限制到 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 getDefaultDashboardEnableConfig(): DashboardEnableConfig {
|
||
return {
|
||
mediaStatistic: true,
|
||
scheduler: true,
|
||
speed: true,
|
||
storage: true,
|
||
weeklyOverview: false,
|
||
cpu: true,
|
||
memory: true,
|
||
network: false,
|
||
library: false,
|
||
playing: false,
|
||
latest: false,
|
||
recentImports: true,
|
||
quickActions: true,
|
||
systemInfo: true,
|
||
}
|
||
}
|
||
|
||
// 用默认开关补齐旧配置中新出现的组件,同时保留用户已有选择。
|
||
function mergeDashboardEnableConfig(config?: DashboardEnableConfig): DashboardEnableConfig {
|
||
return {
|
||
...getDefaultDashboardEnableConfig(),
|
||
...config,
|
||
}
|
||
}
|
||
|
||
// 校验并归一化仪表板显示配置,避免异常用户配置影响页面渲染。
|
||
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 布局覆盖配置,兼容旧版裸布局和新版 profile 包装结构。
|
||
function normalizeDashboardGridLayout(value: unknown): DashboardGridLayoutConfig | undefined {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||
|
||
const configValue = value as { items?: unknown }
|
||
const hasWrappedLayout = Object.prototype.hasOwnProperty.call(configValue, 'items')
|
||
const layoutValue = hasWrappedLayout ? configValue.items : value
|
||
if (!layoutValue || typeof layoutValue !== 'object' || Array.isArray(layoutValue)) {
|
||
return hasWrappedLayout ? {} : undefined
|
||
}
|
||
|
||
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 normalizeDashboardProfileConfig(value: unknown): DashboardProfileConfig | undefined {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||
|
||
const configValue = value as { enabled?: unknown; items?: unknown }
|
||
const hasProfileField =
|
||
Object.prototype.hasOwnProperty.call(configValue, 'items') ||
|
||
Object.prototype.hasOwnProperty.call(configValue, 'enabled')
|
||
const items = normalizeDashboardGridLayout(hasProfileField ? { items: configValue.items ?? {} } : value)
|
||
if (items === undefined) return undefined
|
||
|
||
const enabled = normalizeDashboardEnableConfig(configValue.enabled)
|
||
const profileConfig: DashboardProfileConfig = { items }
|
||
|
||
if (enabled !== undefined) {
|
||
profileConfig.enabled = enabled
|
||
}
|
||
|
||
return profileConfig
|
||
}
|
||
|
||
// 构造设备档位仪表盘配置,让显示项和 Grid 布局始终作为一个整体持久化。
|
||
function buildDashboardProfileConfig(
|
||
layout: DashboardGridLayoutConfig = dashboardGridLayout.value,
|
||
enabled: DashboardEnableConfig = enableConfig.value,
|
||
): DashboardProfileConfig {
|
||
return {
|
||
enabled,
|
||
items: layout,
|
||
}
|
||
}
|
||
|
||
// 构造服务端设备档位配置,避免空布局被后端按空值删除后又被其他浏览器旧缓存回填。
|
||
function buildRemoteDashboardProfileConfig(config: DashboardProfileConfig) {
|
||
const remoteConfig: DashboardProfileConfig = { items: config.items }
|
||
|
||
if (config.enabled !== undefined) {
|
||
remoteConfig.enabled = config.enabled
|
||
}
|
||
|
||
return remoteConfig
|
||
}
|
||
|
||
// 根据当前视口判断仪表板布局档位,避免手机和桌面共用 Grid 坐标。
|
||
function resolveDashboardLayoutProfile(): DashboardLayoutProfile {
|
||
const width =
|
||
display.width.value || (typeof window === 'undefined' ? DASHBOARD_GRID_DESKTOP_BREAKPOINT : window.innerWidth)
|
||
|
||
if (width <= DASHBOARD_GRID_MOBILE_BREAKPOINT) return 'mobile'
|
||
if (width <= DASHBOARD_GRID_TABLET_BREAKPOINT) return 'tablet'
|
||
|
||
return 'desktop'
|
||
}
|
||
|
||
// 获取当前布局档位对应的 GridStack 列数。
|
||
function getDashboardGridColumnsForProfile(profile: DashboardLayoutProfile) {
|
||
if (profile === 'mobile') return 1
|
||
if (profile === 'tablet') return 6
|
||
|
||
return DASHBOARD_GRID_COLUMNS
|
||
}
|
||
|
||
// 获取当前 Grid 实际列数,用于按布局档位保存当前坐标。
|
||
function getCurrentDashboardGridColumns() {
|
||
return dashboardGrid.value?.getColumn() ?? getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
|
||
}
|
||
|
||
// 获取当前布局档位的 GridStack 列变化策略。
|
||
function getDashboardGridColumnLayout(profile: DashboardLayoutProfile): ColumnOptions {
|
||
return profile === 'mobile' ? 'list' : 'moveScale'
|
||
}
|
||
|
||
// 获取布局档位对应的本地存储键,桌面沿用旧键以兼容已有配置。
|
||
function getDashboardGridLayoutStorageKey(profile: DashboardLayoutProfile) {
|
||
if (profile === 'desktop') return DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX
|
||
|
||
return `${DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX}_${profile.toUpperCase()}`
|
||
}
|
||
|
||
// 获取布局档位对应的用户配置键,桌面沿用旧键以兼容已同步配置。
|
||
function getDashboardGridLayoutConfigKey(profile: DashboardLayoutProfile) {
|
||
if (profile === 'desktop') return DASHBOARD_GRID_LAYOUT_CONFIG_KEY
|
||
|
||
return `${DASHBOARD_GRID_LAYOUT_CONFIG_KEY_PREFIX}${profile === 'mobile' ? 'Mobile' : 'Tablet'}`
|
||
}
|
||
|
||
// 加载指定设备档位的仪表盘配置,远端旧布局缺少显示项时保留本地新版显示项。
|
||
async function loadDashboardProfileConfig(profile: DashboardLayoutProfile) {
|
||
const configKey = getDashboardGridLayoutConfigKey(profile)
|
||
const storageKey = getDashboardGridLayoutStorageKey(profile)
|
||
const localConfig = readLocalDashboardConfig(storageKey, normalizeDashboardProfileConfig)
|
||
|
||
try {
|
||
const response = await api.get(`/user/config/${configKey}`)
|
||
const remoteConfig = normalizeDashboardProfileConfig(response?.data?.value)
|
||
|
||
if (remoteConfig !== undefined) {
|
||
const profileConfig: DashboardProfileConfig = { items: remoteConfig.items }
|
||
const enabled = remoteConfig.enabled ?? localConfig?.enabled
|
||
|
||
if (enabled !== undefined) {
|
||
profileConfig.enabled = enabled
|
||
}
|
||
|
||
saveLocalDashboardConfig(storageKey, profileConfig)
|
||
|
||
if (remoteConfig.enabled === undefined && localConfig?.enabled !== undefined) {
|
||
await saveUserDashboardConfig(configKey, buildRemoteDashboardProfileConfig(profileConfig))
|
||
}
|
||
|
||
return profileConfig
|
||
}
|
||
|
||
if (localConfig !== undefined) {
|
||
await saveUserDashboardConfig(configKey, buildRemoteDashboardProfileConfig(localConfig))
|
||
}
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
|
||
return localConfig
|
||
}
|
||
|
||
// 从本地存储读取并归一化指定的仪表板配置。
|
||
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)
|
||
}
|
||
|
||
// 读取旧版全局显示项配置,用于设备档位配置还没有 enabled 字段时迁移。
|
||
async function loadLegacyDashboardEnableConfig() {
|
||
if (isLegacyDashboardEnableConfigLoaded) return legacyDashboardEnableConfig
|
||
|
||
const localConfig = readLocalDashboardConfig(DASHBOARD_ENABLE_STORAGE_KEY, normalizeDashboardEnableConfig)
|
||
|
||
try {
|
||
const response = await api.get(`/user/config/${DASHBOARD_ENABLE_CONFIG_KEY}`)
|
||
legacyDashboardEnableConfig = normalizeDashboardEnableConfig(response?.data?.value) ?? localConfig
|
||
} catch (error) {
|
||
console.error(error)
|
||
legacyDashboardEnableConfig = localConfig
|
||
}
|
||
|
||
isLegacyDashboardEnableConfigLoaded = true
|
||
|
||
return legacyDashboardEnableConfig
|
||
}
|
||
|
||
// 优先加载用户配置;服务端缺失时使用本地历史配置并回填到用户配置。
|
||
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 saveDashboardProfileConfig(layout = dashboardGridLayout.value, enabled = enableConfig.value) {
|
||
const profile = dashboardLayoutProfile.value
|
||
const profileConfig = buildDashboardProfileConfig(layout, enabled)
|
||
|
||
saveLocalDashboardConfig(getDashboardGridLayoutStorageKey(profile), profileConfig)
|
||
void saveUserDashboardConfig(
|
||
getDashboardGridLayoutConfigKey(profile),
|
||
buildRemoteDashboardProfileConfig(profileConfig),
|
||
).catch(error => console.error(error))
|
||
}
|
||
|
||
// 将当前仪表板布局覆盖配置保存到本地和用户配置。
|
||
function saveDashboardGridLayout(layout: DashboardGridLayoutConfig) {
|
||
saveDashboardProfileConfig(layout)
|
||
}
|
||
|
||
// 获取仪表板组件的默认宽度,优先兼容插件旧版 cols.md / cols.cols 配置。
|
||
function getDefaultDashboardGridWidth(item: DashboardItem) {
|
||
const profile = dashboardLayoutProfile.value
|
||
if (profile === 'mobile') return 1
|
||
|
||
const columns = getDashboardGridColumnsForProfile(profile)
|
||
const requestedWidth = profile === 'tablet' ? item.cols?.sm ?? item.cols?.md : item.cols?.md ?? item.cols?.cols
|
||
|
||
return clampGridNumber(requestedWidth, 1, columns, 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 defaultLayout = dashboardLayoutProfile.value === 'desktop' ? DASHBOARD_DESKTOP_DEFAULT_LAYOUT[id] : undefined
|
||
const gridColumns = getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
|
||
const width = savedLayout?.w ?? defaultLayout?.w ?? getDefaultDashboardGridWidth(item)
|
||
const height = savedLayout?.h ?? defaultLayout?.h ?? getDefaultDashboardGridRows(item)
|
||
const normalizedWidth = clampGridNumber(width, 1, gridColumns, gridColumns)
|
||
const widget: GridStackWidget = {
|
||
id,
|
||
w: normalizedWidth,
|
||
h: clampGridNumber(height, 1, 96, getDefaultDashboardGridRows(item)),
|
||
minW: 1,
|
||
minH: 1,
|
||
}
|
||
|
||
const x = savedLayout?.x ?? defaultLayout?.x
|
||
const y = savedLayout?.y ?? defaultLayout?.y
|
||
if (x !== undefined && y !== undefined) {
|
||
widget.x = clampGridNumber(x, 0, gridColumns - normalizedWidth, 0)
|
||
widget.y = clampGridNumber(y, 0, 999, 0)
|
||
} else {
|
||
widget.autoPosition = true
|
||
}
|
||
|
||
return widget
|
||
}
|
||
|
||
// 使用动态按钮钩子
|
||
let settingsDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||
|
||
// 打开仪表板共享设置弹窗。
|
||
function openDashboardSettings() {
|
||
settingsDialogController?.close()
|
||
settingsDialogController = openSharedDialog(
|
||
ContentToggleSettingsDialog,
|
||
{
|
||
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 updateDashboardSettingsDialog() {
|
||
settingsDialogController?.updateProps({
|
||
enabled: enableConfig.value,
|
||
items: dashboardConfigs.value,
|
||
})
|
||
}
|
||
|
||
// 退出仪表板布局编辑模式;如果刚恢复默认布局,则跳过本次本地持久化。
|
||
async function exitDashboardLayoutEditing() {
|
||
if (isDashboardGridLayoutResetPending.value) {
|
||
isDashboardGridLayoutResetPending.value = false
|
||
} else {
|
||
await persistCurrentDashboardGridLayout()
|
||
}
|
||
|
||
isLayoutEditing.value = false
|
||
await nextTick()
|
||
syncDashboardFillContentState()
|
||
resizeAutoDashboardItemsToContent()
|
||
notifyDashboardContentResize()
|
||
}
|
||
|
||
// 清除用户本地布局覆盖,并恢复内置组件和插件声明的默认占位,然后退出编辑模式。
|
||
async function resetDashboardGridLayout() {
|
||
dashboardGridLayout.value = {}
|
||
saveDashboardGridLayout({})
|
||
dashboardGrid.value?.removeAll(false, false)
|
||
isDashboardGridLayoutResetPending.value = true
|
||
await syncDashboardGrid()
|
||
if (isLayoutEditing.value) {
|
||
await exitDashboardLayoutEditing()
|
||
} else {
|
||
await nextTick()
|
||
syncDashboardFillContentState()
|
||
resizeAutoDashboardItemsToContent()
|
||
notifyDashboardContentResize()
|
||
}
|
||
}
|
||
|
||
// 生成 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) {
|
||
void exitDashboardLayoutEditing()
|
||
return
|
||
}
|
||
|
||
isDashboardGridLayoutResetPending.value = false
|
||
isLayoutEditing.value = true
|
||
nextTick(syncDashboardGrid)
|
||
}
|
||
|
||
// 加载用户监控面板配置,优先使用服务端用户配置以支持跨浏览器同步。
|
||
async function loadDashboardConfig() {
|
||
dashboardLayoutProfile.value = resolveDashboardLayoutProfile()
|
||
// 顺序配置
|
||
const order = await loadSharedDashboardConfig(
|
||
DASHBOARD_ORDER_CONFIG_KEY,
|
||
DASHBOARD_ORDER_STORAGE_KEY,
|
||
normalizeDashboardOrderConfig,
|
||
)
|
||
if (order !== undefined) {
|
||
orderConfig.value = order
|
||
}
|
||
// 设备档位配置同时承载 Grid 布局和显示项,显示项缺失时从旧版全局配置迁移。
|
||
const profileConfig = await loadDashboardProfileConfig(dashboardLayoutProfile.value)
|
||
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
|
||
dashboardGridLayout.value = profileConfig?.items ?? {}
|
||
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable)
|
||
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
||
saveDashboardProfileConfig()
|
||
}
|
||
// 排序
|
||
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
|
||
}
|
||
|
||
// 顺序配置,从dashboardConfigs中提取
|
||
const orderObj = dashboardConfigs.value.map(item => ({ id: item.id, key: item.key }))
|
||
saveLocalDashboardConfig(DASHBOARD_ORDER_STORAGE_KEY, orderObj)
|
||
saveDashboardProfileConfig()
|
||
|
||
// 保存到服务端
|
||
try {
|
||
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)
|
||
// 排序
|
||
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: DASHBOARD_GRID_MOBILE_BREAKPOINT, c: 1, layout: 'list' },
|
||
{ w: DASHBOARD_GRID_TABLET_BREAKPOINT, c: 6, layout: 'moveScale' },
|
||
{ w: DASHBOARD_GRID_DESKTOP_BREAKPOINT, 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()
|
||
syncDashboardFillContentState()
|
||
|
||
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)
|
||
syncDashboardFillContentState()
|
||
observeDashboardGridContent()
|
||
nextTick(() => {
|
||
syncDashboardFillContentState()
|
||
resizeAutoDashboardItemsToContent()
|
||
scheduleDashboardReveal()
|
||
})
|
||
} finally {
|
||
isSyncingDashboardGrid.value = false
|
||
}
|
||
}
|
||
|
||
// 判断仪表板组件高度是否已被用户手动固定。
|
||
function hasManualDashboardGridHeight(id: string) {
|
||
return dashboardGridLayout.value[id]?.h !== undefined
|
||
}
|
||
|
||
// 根据子组件声明的填充标记,同步 GridStack 外层测高节点的填充状态。
|
||
function syncDashboardFillContentState(element?: GridItemHTMLElement) {
|
||
const gridElement = dashboardGridRef.value
|
||
const itemElements = element
|
||
? [element]
|
||
: Array.from(gridElement?.querySelectorAll<GridItemHTMLElement>('.dashboard-grid-item') ?? [])
|
||
|
||
itemElements.forEach(itemElement => {
|
||
itemElement.classList.toggle('has-fill-content', Boolean(itemElement.querySelector('.dashboard-grid-fill')))
|
||
})
|
||
}
|
||
|
||
// 监听仪表板组件内容尺寸变化,让未手动调高的组件按内容高度自适应。
|
||
function observeDashboardGridContent() {
|
||
const gridElement = dashboardGridRef.value
|
||
if (!gridElement || typeof ResizeObserver === 'undefined') return
|
||
|
||
syncDashboardFillContentState()
|
||
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
|
||
|
||
syncDashboardFillContentState(element)
|
||
const shouldMeasureFillContent = element.classList.contains('has-fill-content')
|
||
if (shouldMeasureFillContent) {
|
||
element.classList.add('is-measuring-content')
|
||
}
|
||
|
||
try {
|
||
grid.resizeToContent(element)
|
||
} finally {
|
||
if (shouldMeasureFillContent) {
|
||
element.classList.remove('is-measuring-content')
|
||
}
|
||
}
|
||
}
|
||
|
||
// 将所有未手动固定高度的组件高度调整到内容实际高度。
|
||
function resizeAutoDashboardItemsToContent() {
|
||
const gridElement = dashboardGridRef.value
|
||
if (!gridElement) return
|
||
|
||
syncDashboardFillContentState()
|
||
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() {
|
||
void persistCurrentDashboardGridLayout(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()
|
||
void persistCurrentDashboardGridLayout(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 gridColumns = getCurrentDashboardGridColumns()
|
||
const savedWidgets = dashboardGrid.value.save(false, false, undefined, gridColumns)
|
||
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, gridColumns, getDefaultDashboardGridWidthById(id, gridColumns))
|
||
const previousLayout = dashboardGridLayout.value[id]
|
||
const nextItemLayout: DashboardGridLayoutItem = {
|
||
x: clampGridNumber(widget.x, 0, gridColumns - 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
|
||
})
|
||
|
||
isPersistingDashboardGridLayoutFromGrid.value = true
|
||
dashboardGridLayout.value = nextLayout
|
||
saveDashboardGridLayout(nextLayout)
|
||
nextTick(() => {
|
||
isPersistingDashboardGridLayoutFromGrid.value = false
|
||
resizeAutoDashboardItemsToContent()
|
||
})
|
||
}
|
||
|
||
// 根据组件 ID 查找默认宽度,保存布局时用于兜底。
|
||
function getDefaultDashboardGridWidthById(id: string, maxColumns = DASHBOARD_GRID_COLUMNS) {
|
||
const item = dashboardConfigs.value.find(config => buildPluginDashboardId(config.id, config.key) === id)
|
||
|
||
return item ? Math.min(getDefaultDashboardGridWidth(item), maxColumns) : maxColumns
|
||
}
|
||
|
||
// 等待 GridStack 当前拖拽/缩放事件收尾后,保存用户当前看到的布局。
|
||
async function persistCurrentDashboardGridLayout(manualHeightId: string | false = false) {
|
||
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
|
||
|
||
isDashboardGridLayoutResetPending.value = false
|
||
await nextTick()
|
||
persistDashboardGridLayout(manualHeightId)
|
||
}
|
||
|
||
watch(isLayoutEditing, value => {
|
||
updateDashboardGridEditableState(value)
|
||
})
|
||
|
||
watch(
|
||
dashboardGridItems,
|
||
() => {
|
||
syncDashboardLoadedItemIds()
|
||
if (!isPersistingDashboardGridLayoutFromGrid.value) {
|
||
syncDashboardGrid()
|
||
}
|
||
scheduleDashboardReveal()
|
||
},
|
||
{ deep: true },
|
||
)
|
||
|
||
watch(
|
||
() => display.width.value,
|
||
async () => {
|
||
const nextProfile = resolveDashboardLayoutProfile()
|
||
if (nextProfile === dashboardLayoutProfile.value) return
|
||
|
||
// GridStack 可能已先完成列数压缩;档位切换只读取目标配置,不能保存当前自动重排结果。
|
||
const profileSwitchId = ++dashboardLayoutProfileSwitchId
|
||
dashboardLayoutProfile.value = nextProfile
|
||
const profileConfig = await loadDashboardProfileConfig(nextProfile)
|
||
if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return
|
||
|
||
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
|
||
if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return
|
||
|
||
dashboardGridLayout.value = profileConfig?.items ?? {}
|
||
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable)
|
||
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
||
saveDashboardProfileConfig()
|
||
}
|
||
updateDashboardSettingsDialog()
|
||
dashboardGrid.value?.column(
|
||
getDashboardGridColumnsForProfile(nextProfile),
|
||
getDashboardGridColumnLayout(nextProfile),
|
||
)
|
||
dashboardGrid.value?.removeAll(false, false)
|
||
await syncDashboardGrid()
|
||
notifyDashboardContentResize()
|
||
},
|
||
)
|
||
|
||
onBeforeMount(async () => {
|
||
await loadDashboardConfig()
|
||
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 :deep(.v-card) {
|
||
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.72));
|
||
border-radius: 12px;
|
||
box-shadow: 0 3px 14px rgba(15, 23, 42, 0.035);
|
||
}
|
||
|
||
.dashboard-grid :deep(.v-card-title) {
|
||
font-size: 0.92rem;
|
||
font-weight: 650;
|
||
}
|
||
|
||
/* 媒体卡片上浮 4px 时保留安全区,避免被标题栏或滚动容器顶边裁切。 */
|
||
.dashboard-grid :deep(.dashboard-media-content) {
|
||
padding-block-start: 0.5rem;
|
||
}
|
||
|
||
.dashboard-grid-item.is-manual-height :deep(.v-card) {
|
||
block-size: 100%;
|
||
}
|
||
|
||
.dashboard-grid-item.is-manual-height :deep(.dashboard-work-card),
|
||
.dashboard-grid.is-editing :deep(.dashboard-work-card) {
|
||
max-block-size: none;
|
||
}
|
||
|
||
.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-item.has-fill-content .dashboard-grid-auto-size,
|
||
.dashboard-grid-item.has-fill-content .dashboard-grid-content-measure {
|
||
block-size: 100%;
|
||
min-block-size: 100%;
|
||
}
|
||
|
||
.dashboard-grid-item.has-fill-content.is-measuring-content .dashboard-grid-auto-size,
|
||
.dashboard-grid-item.has-fill-content.is-measuring-content .dashboard-grid-content-measure {
|
||
block-size: auto;
|
||
}
|
||
|
||
.dashboard-grid.is-editing :deep(.v-card) {
|
||
block-size: 100%;
|
||
}
|
||
|
||
.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>
|