mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-21 07:33:49 +08:00
1280 lines
40 KiB
Vue
1280 lines
40 KiB
Vue
<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>
|