feat(dashboard): integrate GridStack for enhanced layout management

- Added GridStack for dynamic dashboard layout with drag-and-drop functionality.
- Introduced new properties for DashboardItem to support row configuration.
- Enhanced ContentToggleSettingsDialog with reset functionality.
- Updated localization files for new dashboard features.
- Refactored dashboard components to utilize GridStack for layout rendering.
- Improved responsiveness and styling for dashboard elements.
- Removed deprecated draggable component in favor of GridStack.
This commit is contained in:
jxxghp
2026-06-06 08:45:45 +08:00
parent 08a2741c06
commit 9dc63e2c21
21 changed files with 671 additions and 113 deletions

View File

@@ -49,6 +49,7 @@
"dayjs": "^1.11.13",
"express": "^4.21.2",
"express-http-proxy": "^2.1.1",
"gridstack": "^12.6.0",
"http-proxy-middleware": "^3.0.0",
"js-cookie": "^3.0.5",
"lodash-es": "^4.17.21",

View File

@@ -702,6 +702,8 @@ export interface DashboardItem {
attrs: { [key: string]: any }
// col列数
cols: { [key: string]: number }
// Grid行数
rows?: number
// 页面元素
elements: RenderProps[]
// 渲染方式

View File

@@ -16,6 +16,8 @@ const props = withDefaults(
items: UnknownRecord[]
labelGetter?: (item: UnknownRecord) => string
modelValue?: boolean
resetIcon?: string
resetText?: string
selectAllText?: string
selectNoneText?: string
showBulkActions?: boolean
@@ -28,6 +30,8 @@ const props = withDefaults(
elevated: false,
labelGetter: undefined,
modelValue: true,
resetIcon: 'mdi-restore',
resetText: '',
selectAllText: '',
selectNoneText: '',
showBulkActions: false,
@@ -38,6 +42,7 @@ const props = withDefaults(
const emit = defineEmits<{
(event: 'close'): void
(event: 'reset'): void
(event: 'save', payload: { elevated: boolean; enabled: Record<string, boolean> }): void
(event: 'update:elevated', value: boolean): void
(event: 'update:modelValue', value: boolean): void
@@ -99,6 +104,11 @@ function setAllItems(value: boolean) {
})
}
// 触发调用方提供的重置动作。
function triggerResetAction() {
emit('reset')
}
// 提交通用内容开关设置。
function submitSettings() {
emit('save', {
@@ -147,6 +157,12 @@ function submitSettings() {
</p>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.resetText" variant="text" color="secondary" @click="triggerResetAction">
<template #prepend>
<VIcon :icon="props.resetIcon" />
</template>
{{ props.resetText }}
</VBtn>
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
{{ props.selectAllText }}
</VBtn>

View File

@@ -148,17 +148,11 @@ onUnmounted(() => {
<VCardText class="p-0">
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
<div v-if="hover.isHovering" class="absolute right-5 top-5">
<VIcon class="cursor-move">mdi-drag</VIcon>
</div>
</VCard>
</div>
<!-- 有边框 -->
<VCard v-else v-bind="hover.props">
<VCardItem v-if="props.config?.attrs.border !== false">
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>
{{ props.config?.attrs?.title ?? props.config?.name }}
</VCardTitle>

View File

@@ -898,7 +898,10 @@ export default {
latest: 'Recently Added',
settings: 'Dashboard Settings',
chooseContent: 'Choose content to display',
adaptiveHeight: 'Adaptive Component Height',
editLayout: 'Edit Layout',
exitEditMode: 'Done Editing',
resetLayout: 'Reset Layout',
dragHandle: 'Drag Component',
current: 'Current',
episodes: 'Episodes',
users: 'Users',

View File

@@ -894,7 +894,10 @@ export default {
latest: '最近添加',
settings: '设置仪表板',
chooseContent: '选择您想在页面显示的内容',
adaptiveHeight: '自适应组件高度',
editLayout: '编辑布局',
exitEditMode: '完成编辑',
resetLayout: '恢复默认布局',
dragHandle: '拖动组件',
current: '当前',
episodes: '剧集',
users: '用户',

View File

@@ -894,7 +894,10 @@ export default {
latest: '最近添加',
settings: '設置儀表板',
chooseContent: '選擇您想在頁面顯示的內容',
adaptiveHeight: '自適應組件高度',
editLayout: '編輯佈局',
exitEditMode: '完成編輯',
resetLayout: '恢復默認佈局',
dragHandle: '拖動組件',
current: '當前',
episodes: '劇集',
users: '用戶',

View File

@@ -1,17 +1,21 @@
<script setup lang="ts">
import draggable from 'vuedraggable'
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 { DashboardItem } from '@/api/types'
import type { DashboardItem } from '@/api/types'
import { useUserStore } from '@/stores'
import DashboardElement from '@/components/misc/DashboardElement.vue'
import { useDynamicButton } from '@/composables/useDynamicButton'
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'
const ContentToggleSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/ContentToggleSettingsDialog.vue'))
const ContentToggleSettingsDialog = defineAsyncComponent(
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
)
// 国际化
const { t } = useI18n()
@@ -25,19 +29,51 @@ const route = useRoute()
// 从用户 Store 中获取superuser信息
const superUser = useUserStore().superUser
// 是否拉升高度
const isElevated = ref(true)
const DASHBOARD_GRID_COLUMNS = 12
const DASHBOARD_GRID_CELL_HEIGHT = 32
const DASHBOARD_GRID_FALLBACK_ROWS = 4
const DASHBOARD_GRID_MARGIN = 8
const DASHBOARD_GRID_LAYOUT_STORAGE_KEY = 'MP_DASHBOARD_GRID_LAYOUT'
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)
// 计算属性,控制是否拉升高度
const elevatedConf = controlledComputed(
() => isElevated.value,
() => ({
class: { 'match-height': isElevated.value },
}),
)
// GridStack 容器引用
const dashboardGridRef = ref<HTMLElement | null>(null)
// GridStack 实例
const dashboardGrid = shallowRef<GridStack | null>(null)
// 是否正在由 Vue 同步 GridStack避免初始化写入覆盖用户布局
const isSyncingDashboardGrid = ref(false)
// 仪表板本地布局覆盖配置
const dashboardGridLayout = ref<Record<string, DashboardGridLayoutItem>>({})
const dashboardGridResizeStartHeights = new Map<string, number | undefined>()
const dashboardGridPendingContentResize = new Set<GridItemHTMLElement>()
let dashboardGridContentObserver: ResizeObserver | null = null
let dashboardGridContentResizeFrame: number | null = null
// 是否正在手动缩放组件,避免自动测高抢回用户拖动中的高度。
const isDashboardGridResizing = ref(false)
// 所有组件刷新定时器的句柄
const refreshTimers = ref<{ [key: string]: NodeJS.Timeout }>({})
@@ -68,6 +104,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 5,
elements: [],
},
{
@@ -76,6 +113,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 8 },
rows: 5,
elements: [],
},
{
@@ -84,6 +122,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 11,
elements: [],
},
{
@@ -92,6 +131,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 11,
elements: [],
},
{
@@ -100,6 +140,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 4 },
rows: 11,
elements: [],
},
{
@@ -108,6 +149,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
rows: 8,
elements: [],
},
{
@@ -116,6 +158,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
rows: 8,
elements: [],
},
{
@@ -124,6 +167,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
key: '',
attrs: {},
cols: { cols: 12, md: 6 },
rows: 8,
elements: [],
},
{
@@ -161,7 +205,102 @@ 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),
}
}),
)
// 将未知数值限制到 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 readDashboardGridLayout() {
const rawLayout = localStorage.getItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY)
if (!rawLayout) return {}
try {
const parsedLayout = JSON.parse(rawLayout) as Record<string, DashboardGridLayoutItem>
const normalizedLayout: Record<string, DashboardGridLayoutItem> = {}
Object.entries(parsedLayout).forEach(([id, layout]) => {
if (!layout || typeof layout !== 'object') return
const width = clampGridNumber(layout.w, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
const normalizedItemLayout: DashboardGridLayoutItem = {
x: clampGridNumber(layout.x, 0, DASHBOARD_GRID_COLUMNS - width, 0),
y: clampGridNumber(layout.y, 0, 999, 0),
w: width,
}
if (layout.h !== undefined) {
normalizedItemLayout.h = clampGridNumber(layout.h, 1, 96, getDefaultDashboardGridRows())
}
normalizedLayout[id] = normalizedItemLayout
})
return normalizedLayout
} catch (error) {
console.error(error)
return {}
}
}
// 将当前仪表板布局覆盖配置保存到本地。
function saveDashboardGridLayout(layout: Record<string, DashboardGridLayoutItem>) {
localStorage.setItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY, JSON.stringify(layout))
}
// 获取仪表板组件的默认宽度,优先兼容插件旧版 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 => {
@@ -180,12 +319,11 @@ function openDashboardSettings() {
ContentToggleSettingsDialog,
{
colors: itemColors.value,
elevated: isElevated.value,
enabled: enableConfig.value,
hint: t('dashboard.chooseContent'),
items: dashboardConfigs.value,
labelGetter: (item: DashboardItem) => item.attrs?.title ?? item.name,
switchLabel: t('dashboard.adaptiveHeight'),
resetText: t('dashboard.resetLayout'),
title: t('dashboard.settings'),
valueGetter: (item: DashboardItem) => buildPluginDashboardId(item.id, item.key),
},
@@ -193,10 +331,8 @@ function openDashboardSettings() {
close: () => {
settingsDialogController = null
},
reset: resetDashboardGridLayout,
save: saveDashboardConfig,
'update:elevated': (value: boolean) => {
isElevated.value = value
},
'update:modelValue': (value: boolean) => {
if (!value) settingsDialogController = null
},
@@ -205,11 +341,52 @@ function openDashboardSettings() {
)
}
// 清除用户本地布局覆盖,并恢复内置组件和插件声明的默认占位。
function resetDashboardGridLayout() {
dashboardGridLayout.value = {}
localStorage.removeItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY)
dashboardGrid.value?.removeAll(false, false)
nextTick(syncDashboardGrid)
}
// 生成 appMode 底部动态按钮菜单,普通 Web 模式由页面内 FAB 承接。
const dashboardDynamicButtonMenuItems = computed<DynamicButtonMenuItem[] | undefined>(() => {
if (!appMode.value) return undefined
return [
{
title: isLayoutEditing.value ? t('dashboard.exitEditMode') : t('dashboard.editLayout'),
icon: isLayoutEditing.value ? 'mdi-check' : 'mdi-view-dashboard-edit',
color: 'primary',
action: toggleDashboardLayoutEditing,
},
{
title: t('dashboard.settings'),
icon: 'mdi-tune',
color: 'info',
action: openDashboardSettings,
},
]
})
useDynamicButton({
icon: 'mdi-view-dashboard-edit',
onClick: openDashboardSettings,
menuItems: dashboardDynamicButtonMenuItems,
show: computed(() => appMode.value && route.path === '/dashboard'),
})
// 切换仪表板布局编辑模式,退出编辑时压实并保存当前布局。
function toggleDashboardLayoutEditing() {
if (isLayoutEditing.value) {
compactAndPersistDashboardGrid()
isLayoutEditing.value = false
return
}
isLayoutEditing.value = true
nextTick(syncDashboardGrid)
}
// 加载用户监控面板配置(本地无配置时才加载)
async function loadDashboardConfig() {
// 显示配置
@@ -234,9 +411,8 @@ async function loadDashboardConfig() {
localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify(orderConfig.value))
}
}
// 是否拉升高度
const local_elevated = localStorage.getItem('MP_DASHBOARD_ELEVATED')
if (local_elevated) isElevated.value = local_elevated === 'true'
// 本地 Grid 布局覆盖
dashboardGridLayout.value = readDashboardGridLayout()
// 排序
if (orderConfig.value) {
sortDashboardConfigs()
@@ -257,13 +433,10 @@ function sortDashboardConfigs() {
}
// 设置项目
async function saveDashboardConfig(payload?: { elevated?: boolean; enabled?: Record<string, boolean> }) {
async function saveDashboardConfig(payload?: { enabled?: Record<string, boolean> }) {
if (payload?.enabled) {
enableConfig.value = payload.enabled
}
if (payload?.elevated !== undefined) {
isElevated.value = payload.elevated
}
// 启用配置
const enableString = JSON.stringify(enableConfig.value)
@@ -274,9 +447,6 @@ async function saveDashboardConfig(payload?: { elevated?: boolean; enabled?: Rec
const orderString = JSON.stringify(orderObj)
localStorage.setItem('MP_DASHBOARD_ORDER', orderString)
// 是否拉升高度
localStorage.setItem('MP_DASHBOARD_ELEVATED', isElevated.value.toString())
// 保存到服务端
try {
await api.post('/user/config/Dashboard', enableConfig.value)
@@ -387,61 +557,411 @@ async function getPluginDashboard(id: string, key: string) {
}
}
// 拖动排序结束
function dragOrderEnd() {
// 保存数据
saveDashboardConfig()
// 初始化 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('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)
} 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()
dashboardGridContentObserver = new ResizeObserver(entries => {
entries.forEach(entry => {
const itemElement = entry.target.closest('.dashboard-grid-item') as GridItemHTMLElement | null
if (itemElement) scheduleDashboardItemContentResize(itemElement)
})
})
gridElement.querySelectorAll<HTMLElement>('.dashboard-grid-auto-size').forEach(element => {
dashboardGridContentObserver?.observe(element)
})
}
// 延迟执行单个组件内容测高,合并连续 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)
}
// 保存用户拖动后的位置,并保持未手动调高组件继续按内容自适应。
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
compactAndPersistDashboardGrid(heightChanged ? id : false)
}
// 将 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
dashboardGrid.value.compact('compact')
nextTick(() => persistDashboardGridLayout(manualHeightId))
}
watch(isLayoutEditing, value => {
updateDashboardGridEditableState(value)
})
watch(
dashboardGridItems,
() => {
syncDashboardGrid()
},
{ deep: true },
)
onBeforeMount(async () => {
await loadDashboardConfig()
initializeColors()
getPluginDashboardMeta()
})
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
}
dashboardGridPendingContentResize.clear()
dashboardGridResizeStartHeights.clear()
dashboardGrid.value?.destroy(false)
dashboardGrid.value = null
})
</script>
<template>
<!-- 仪表板 -->
<draggable
v-model="dashboardConfigs"
@end="dragOrderEnd"
handle=".cursor-move"
item-key="id"
tag="VRow"
:component-data="elevatedConf"
>
<template #item="{ element }">
<VCol v-if="enableConfig[buildPluginDashboardId(element.id, element.key)] && element.cols" v-bind:="element.cols">
<DashboardElement
:config="element"
:allow-refresh="isRequest"
v-model:refreshStatus="pluginDashboardRefreshStatus[buildPluginDashboardId(element.id, element.key)]"
/>
</VCol>
</template>
</draggable>
<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]"
/>
</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="route.path === '/dashboard'">
<div v-if="!appMode" class="compact-fab-stack">
<Teleport to="body" v-if="!appMode && route.path === '/dashboard'">
<div class="compact-fab-stack">
<VFab
icon="mdi-view-dashboard-edit"
icon="mdi-tune"
color="info"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="openDashboardSettings"
/>
<VFab
:icon="isLayoutEditing ? 'mdi-check' : 'mdi-view-dashboard-edit'"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openDashboardSettings"
@click="toggleDashboardLayoutEditing"
/>
</div>
</Teleport>
</template>
<style scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
.dashboard-grid {
margin-block: -6px 0;
}
.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-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;
}
</style>

View File

@@ -135,11 +135,8 @@ useKeepAliveRefresh(refresh)
<template>
<VHover>
<template #default="hover">
<VCard v-bind="hover.props">
<VCard v-bind="hover.props" class="dashboard-chart-card">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>CPU</VCardTitle>
</VCardItem>
<VCardText>
@@ -150,3 +147,9 @@ useKeepAliveRefresh(refresh)
</template>
</VHover>
</template>
<style scoped>
.dashboard-chart-card {
min-block-size: 256px;
}
</style>

View File

@@ -56,11 +56,8 @@ onActivated(() => {
<template>
<VHover>
<template #default="hover">
<VCard v-bind="hover.props">
<VCard v-bind="hover.props" class="dashboard-summary-card">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.mediaStatistic') }}</VCardTitle>
</VCardItem>
@@ -88,3 +85,9 @@ onActivated(() => {
</template>
</VHover>
</template>
<style scoped>
.dashboard-summary-card {
min-block-size: 160px;
}
</style>

View File

@@ -140,11 +140,8 @@ useKeepAliveRefresh(refresh)
<template>
<VHover>
<template #default="hover">
<VCard v-bind="hover.props">
<VCard v-bind="hover.props" class="dashboard-chart-card">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.memory') }}</VCardTitle>
</VCardItem>
<VCardText>
@@ -155,3 +152,9 @@ useKeepAliveRefresh(refresh)
</template>
</VHover>
</template>
<style scoped>
.dashboard-chart-card {
min-block-size: 256px;
}
</style>

View File

@@ -173,11 +173,8 @@ useKeepAliveRefresh(refresh)
<template>
<VHover>
<template #default="hover">
<VCard v-bind="hover.props">
<VCard v-bind="hover.props" class="dashboard-chart-card">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.network') }}</VCardTitle>
</VCardItem>
<VCardText>
@@ -197,3 +194,9 @@ useKeepAliveRefresh(refresh)
</template>
</VHover>
</template>
<style scoped>
.dashboard-chart-card {
min-block-size: 256px;
}
</style>

View File

@@ -43,9 +43,6 @@ useDataRefresh(
<template>
<VCard>
<VCardItem>
<template #append>
<VIcon class="cursor-move">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.processes.title') }}</VCardTitle>
</VCardItem>
<VTable item-key="fullName" class="table-rounded" hide-default-footer disable-sort>

View File

@@ -46,16 +46,13 @@ useDataRefresh(
<template>
<VHover>
<template #default="hover">
<VCard v-bind="hover.props">
<VCard v-bind="hover.props" class="dashboard-work-card">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.scheduler') }}</VCardTitle>
</VCardItem>
<VCardText>
<VList class="card-list" height="250">
<VList class="card-list" height="260">
<VListItem v-for="item in schedulerList" :key="item.id">
<template #prepend>
<VAvatar size="40" variant="tonal" color="" class="me-3">
@@ -94,6 +91,10 @@ useDataRefresh(
--v-card-list-gap: 1.5rem;
}
.dashboard-work-card {
min-block-size: 352px;
}
.card-list::-webkit-scrollbar {
display: none;
}

View File

@@ -89,11 +89,8 @@ const { loading } = useDataRefresh(
<template>
<VHover>
<template #default="hover">
<VCard v-bind="hover.props">
<VCard v-bind="hover.props" class="dashboard-work-card">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.realTimeSpeed') }}</VCardTitle>
</VCardItem>
@@ -131,4 +128,8 @@ const { loading } = useDataRefresh(
.card-list {
--v-card-list-gap: 1rem;
}
.dashboard-work-card {
min-block-size: 352px;
}
</style>

View File

@@ -49,13 +49,10 @@ onActivated(() => {
<template>
<VHover>
<template #default="hover">
<VCard v-bind="hover.props">
<VCard v-bind="hover.props" class="dashboard-summary-card">
<!-- Triangle Background -->
<VImg :src="triangleBg" class="triangle-bg flip-in-rtl" />
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.storage') }}</VCardTitle>
</VCardItem>
<VCardText>
@@ -90,4 +87,8 @@ onActivated(() => {
inset-block-end: 2rem;
inset-inline-end: 2rem;
}
.dashboard-summary-card {
min-block-size: 160px;
}
</style>

View File

@@ -133,11 +133,8 @@ onActivated(() => {
<template>
<VHover>
<template #default="hover">
<VCard v-bind="hover.props">
<VCard v-bind="hover.props" class="dashboard-work-card">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.weeklyOverview') }}</VCardTitle>
</VCardItem>
@@ -156,3 +153,9 @@ onActivated(() => {
</template>
</VHover>
</template>
<style scoped>
.dashboard-work-card {
min-block-size: 352px;
}
</style>

View File

@@ -60,11 +60,8 @@ onActivated(() => {
<div>
<VHover v-for="(data, name) in latestList" :key="name">
<template #default="hover">
<VCard v-bind="hover.props">
<VCard v-bind="hover.props" class="dashboard-work-card">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
</VCardItem>
@@ -90,6 +87,11 @@ onActivated(() => {
<style scoped>
.dashboard-card-grid-wrap {
/* 用内边距提供卡片留白,避免 100% 宽度网格叠加横向外边距后在 iOS 小屏溢出。 */
min-block-size: 260px;
padding: 0 0.75rem 0.75rem;
}
.dashboard-work-card {
min-block-size: 352px;
}
</style>

View File

@@ -65,9 +65,6 @@ onActivated(() => {
<template #default="hover">
<VCard v-bind="hover.props">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
</VCardItem>
<div class="dashboard-card-grid-wrap">

View File

@@ -65,9 +65,6 @@ onActivated(() => {
<template #default="hover">
<VCard v-bind="hover.props">
<VCardItem>
<template #append>
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
</template>
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
</VCardItem>

View File

@@ -4721,6 +4721,11 @@ graphemer@^1.4.0:
resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz"
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
gridstack@^12.6.0:
version "12.6.0"
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.6.0.tgz#acfd8c036b202304712c0562078c86ed2ab6e83f"
integrity sha512-dUrqsormSybFn/2P4Dz8AgprftKD5e/IiV7UmC0XLQU+G+/WtkAeFiCSNLoAGhPDXoJ/O61Xtj3gljY/Ds83yQ==
has-bigints@^1.0.2:
version "1.1.0"
resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz"