mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
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:
@@ -49,6 +49,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-http-proxy": "^2.1.1",
|
"express-http-proxy": "^2.1.1",
|
||||||
|
"gridstack": "^12.6.0",
|
||||||
"http-proxy-middleware": "^3.0.0",
|
"http-proxy-middleware": "^3.0.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
|||||||
@@ -702,6 +702,8 @@ export interface DashboardItem {
|
|||||||
attrs: { [key: string]: any }
|
attrs: { [key: string]: any }
|
||||||
// col列数
|
// col列数
|
||||||
cols: { [key: string]: number }
|
cols: { [key: string]: number }
|
||||||
|
// Grid行数
|
||||||
|
rows?: number
|
||||||
// 页面元素
|
// 页面元素
|
||||||
elements: RenderProps[]
|
elements: RenderProps[]
|
||||||
// 渲染方式
|
// 渲染方式
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const props = withDefaults(
|
|||||||
items: UnknownRecord[]
|
items: UnknownRecord[]
|
||||||
labelGetter?: (item: UnknownRecord) => string
|
labelGetter?: (item: UnknownRecord) => string
|
||||||
modelValue?: boolean
|
modelValue?: boolean
|
||||||
|
resetIcon?: string
|
||||||
|
resetText?: string
|
||||||
selectAllText?: string
|
selectAllText?: string
|
||||||
selectNoneText?: string
|
selectNoneText?: string
|
||||||
showBulkActions?: boolean
|
showBulkActions?: boolean
|
||||||
@@ -28,6 +30,8 @@ const props = withDefaults(
|
|||||||
elevated: false,
|
elevated: false,
|
||||||
labelGetter: undefined,
|
labelGetter: undefined,
|
||||||
modelValue: true,
|
modelValue: true,
|
||||||
|
resetIcon: 'mdi-restore',
|
||||||
|
resetText: '',
|
||||||
selectAllText: '',
|
selectAllText: '',
|
||||||
selectNoneText: '',
|
selectNoneText: '',
|
||||||
showBulkActions: false,
|
showBulkActions: false,
|
||||||
@@ -38,6 +42,7 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'close'): void
|
(event: 'close'): void
|
||||||
|
(event: 'reset'): void
|
||||||
(event: 'save', payload: { elevated: boolean; enabled: Record<string, boolean> }): void
|
(event: 'save', payload: { elevated: boolean; enabled: Record<string, boolean> }): void
|
||||||
(event: 'update:elevated', value: boolean): void
|
(event: 'update:elevated', value: boolean): void
|
||||||
(event: 'update:modelValue', value: boolean): void
|
(event: 'update:modelValue', value: boolean): void
|
||||||
@@ -99,6 +104,11 @@ function setAllItems(value: boolean) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 触发调用方提供的重置动作。
|
||||||
|
function triggerResetAction() {
|
||||||
|
emit('reset')
|
||||||
|
}
|
||||||
|
|
||||||
// 提交通用内容开关设置。
|
// 提交通用内容开关设置。
|
||||||
function submitSettings() {
|
function submitSettings() {
|
||||||
emit('save', {
|
emit('save', {
|
||||||
@@ -147,6 +157,12 @@ function submitSettings() {
|
|||||||
</p>
|
</p>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<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)">
|
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
|
||||||
{{ props.selectAllText }}
|
{{ props.selectAllText }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
|
|||||||
@@ -148,17 +148,11 @@ onUnmounted(() => {
|
|||||||
<VCardText class="p-0">
|
<VCardText class="p-0">
|
||||||
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
|
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<div v-if="hover.isHovering" class="absolute right-5 top-5">
|
|
||||||
<VIcon class="cursor-move">mdi-drag</VIcon>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
<!-- 有边框 -->
|
<!-- 有边框 -->
|
||||||
<VCard v-else v-bind="hover.props">
|
<VCard v-else v-bind="hover.props">
|
||||||
<VCardItem v-if="props.config?.attrs.border !== false">
|
<VCardItem v-if="props.config?.attrs.border !== false">
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>
|
<VCardTitle>
|
||||||
{{ props.config?.attrs?.title ?? props.config?.name }}
|
{{ props.config?.attrs?.title ?? props.config?.name }}
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
|
|||||||
@@ -898,7 +898,10 @@ export default {
|
|||||||
latest: 'Recently Added',
|
latest: 'Recently Added',
|
||||||
settings: 'Dashboard Settings',
|
settings: 'Dashboard Settings',
|
||||||
chooseContent: 'Choose content to display',
|
chooseContent: 'Choose content to display',
|
||||||
adaptiveHeight: 'Adaptive Component Height',
|
editLayout: 'Edit Layout',
|
||||||
|
exitEditMode: 'Done Editing',
|
||||||
|
resetLayout: 'Reset Layout',
|
||||||
|
dragHandle: 'Drag Component',
|
||||||
current: 'Current',
|
current: 'Current',
|
||||||
episodes: 'Episodes',
|
episodes: 'Episodes',
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
|
|||||||
@@ -894,7 +894,10 @@ export default {
|
|||||||
latest: '最近添加',
|
latest: '最近添加',
|
||||||
settings: '设置仪表板',
|
settings: '设置仪表板',
|
||||||
chooseContent: '选择您想在页面显示的内容',
|
chooseContent: '选择您想在页面显示的内容',
|
||||||
adaptiveHeight: '自适应组件高度',
|
editLayout: '编辑布局',
|
||||||
|
exitEditMode: '完成编辑',
|
||||||
|
resetLayout: '恢复默认布局',
|
||||||
|
dragHandle: '拖动组件',
|
||||||
current: '当前',
|
current: '当前',
|
||||||
episodes: '剧集',
|
episodes: '剧集',
|
||||||
users: '用户',
|
users: '用户',
|
||||||
|
|||||||
@@ -894,7 +894,10 @@ export default {
|
|||||||
latest: '最近添加',
|
latest: '最近添加',
|
||||||
settings: '設置儀表板',
|
settings: '設置儀表板',
|
||||||
chooseContent: '選擇您想在頁面顯示的內容',
|
chooseContent: '選擇您想在頁面顯示的內容',
|
||||||
adaptiveHeight: '自適應組件高度',
|
editLayout: '編輯佈局',
|
||||||
|
exitEditMode: '完成編輯',
|
||||||
|
resetLayout: '恢復默認佈局',
|
||||||
|
dragHandle: '拖動組件',
|
||||||
current: '當前',
|
current: '當前',
|
||||||
episodes: '劇集',
|
episodes: '劇集',
|
||||||
users: '用戶',
|
users: '用戶',
|
||||||
|
|||||||
+574
-54
@@ -1,17 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<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 api from '@/api'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { DashboardItem } from '@/api/types'
|
import type { DashboardItem } from '@/api/types'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import DashboardElement from '@/components/misc/DashboardElement.vue'
|
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 { useI18n } from 'vue-i18n'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
|
||||||
const ContentToggleSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/ContentToggleSettingsDialog.vue'))
|
const ContentToggleSettingsDialog = defineAsyncComponent(
|
||||||
|
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
||||||
|
)
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -25,19 +29,51 @@ const route = useRoute()
|
|||||||
// 从用户 Store 中获取superuser信息
|
// 从用户 Store 中获取superuser信息
|
||||||
const superUser = useUserStore().superUser
|
const superUser = useUserStore().superUser
|
||||||
|
|
||||||
// 是否拉升高度
|
const DASHBOARD_GRID_COLUMNS = 12
|
||||||
const isElevated = ref(true)
|
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 isRequest = ref(true)
|
||||||
|
|
||||||
// 计算属性,控制是否拉升高度
|
// GridStack 容器引用
|
||||||
const elevatedConf = controlledComputed(
|
const dashboardGridRef = ref<HTMLElement | null>(null)
|
||||||
() => isElevated.value,
|
|
||||||
() => ({
|
// GridStack 实例
|
||||||
class: { 'match-height': isElevated.value },
|
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 }>({})
|
const refreshTimers = ref<{ [key: string]: NodeJS.Timeout }>({})
|
||||||
@@ -68,6 +104,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 4 },
|
cols: { cols: 12, md: 4 },
|
||||||
|
rows: 5,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -76,6 +113,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 8 },
|
cols: { cols: 12, md: 8 },
|
||||||
|
rows: 5,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -84,6 +122,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 4 },
|
cols: { cols: 12, md: 4 },
|
||||||
|
rows: 11,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -92,6 +131,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 4 },
|
cols: { cols: 12, md: 4 },
|
||||||
|
rows: 11,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -100,6 +140,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 4 },
|
cols: { cols: 12, md: 4 },
|
||||||
|
rows: 11,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -108,6 +149,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 6 },
|
cols: { cols: 12, md: 6 },
|
||||||
|
rows: 8,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -116,6 +158,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 6 },
|
cols: { cols: 12, md: 6 },
|
||||||
|
rows: 8,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -124,6 +167,7 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
|||||||
key: '',
|
key: '',
|
||||||
attrs: {},
|
attrs: {},
|
||||||
cols: { cols: 12, md: 6 },
|
cols: { cols: 12, md: 6 },
|
||||||
|
rows: 8,
|
||||||
elements: [],
|
elements: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -161,7 +205,102 @@ const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
|
|||||||
// 为每个项目生成随机颜色
|
// 为每个项目生成随机颜色
|
||||||
const itemColors = ref<{ [key: string]: string }>({})
|
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() {
|
function initializeColors() {
|
||||||
initializeItemColors(dashboardConfigs.value, item => buildPluginDashboardId(item.id, item.key))
|
initializeItemColors(dashboardConfigs.value, item => buildPluginDashboardId(item.id, item.key))
|
||||||
dashboardConfigs.value.forEach(item => {
|
dashboardConfigs.value.forEach(item => {
|
||||||
@@ -180,12 +319,11 @@ function openDashboardSettings() {
|
|||||||
ContentToggleSettingsDialog,
|
ContentToggleSettingsDialog,
|
||||||
{
|
{
|
||||||
colors: itemColors.value,
|
colors: itemColors.value,
|
||||||
elevated: isElevated.value,
|
|
||||||
enabled: enableConfig.value,
|
enabled: enableConfig.value,
|
||||||
hint: t('dashboard.chooseContent'),
|
hint: t('dashboard.chooseContent'),
|
||||||
items: dashboardConfigs.value,
|
items: dashboardConfigs.value,
|
||||||
labelGetter: (item: DashboardItem) => item.attrs?.title ?? item.name,
|
labelGetter: (item: DashboardItem) => item.attrs?.title ?? item.name,
|
||||||
switchLabel: t('dashboard.adaptiveHeight'),
|
resetText: t('dashboard.resetLayout'),
|
||||||
title: t('dashboard.settings'),
|
title: t('dashboard.settings'),
|
||||||
valueGetter: (item: DashboardItem) => buildPluginDashboardId(item.id, item.key),
|
valueGetter: (item: DashboardItem) => buildPluginDashboardId(item.id, item.key),
|
||||||
},
|
},
|
||||||
@@ -193,10 +331,8 @@ function openDashboardSettings() {
|
|||||||
close: () => {
|
close: () => {
|
||||||
settingsDialogController = null
|
settingsDialogController = null
|
||||||
},
|
},
|
||||||
|
reset: resetDashboardGridLayout,
|
||||||
save: saveDashboardConfig,
|
save: saveDashboardConfig,
|
||||||
'update:elevated': (value: boolean) => {
|
|
||||||
isElevated.value = value
|
|
||||||
},
|
|
||||||
'update:modelValue': (value: boolean) => {
|
'update:modelValue': (value: boolean) => {
|
||||||
if (!value) settingsDialogController = null
|
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({
|
useDynamicButton({
|
||||||
icon: 'mdi-view-dashboard-edit',
|
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() {
|
async function loadDashboardConfig() {
|
||||||
// 显示配置
|
// 显示配置
|
||||||
@@ -234,9 +411,8 @@ async function loadDashboardConfig() {
|
|||||||
localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify(orderConfig.value))
|
localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify(orderConfig.value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 是否拉升高度
|
// 本地 Grid 布局覆盖
|
||||||
const local_elevated = localStorage.getItem('MP_DASHBOARD_ELEVATED')
|
dashboardGridLayout.value = readDashboardGridLayout()
|
||||||
if (local_elevated) isElevated.value = local_elevated === 'true'
|
|
||||||
// 排序
|
// 排序
|
||||||
if (orderConfig.value) {
|
if (orderConfig.value) {
|
||||||
sortDashboardConfigs()
|
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) {
|
if (payload?.enabled) {
|
||||||
enableConfig.value = payload.enabled
|
enableConfig.value = payload.enabled
|
||||||
}
|
}
|
||||||
if (payload?.elevated !== undefined) {
|
|
||||||
isElevated.value = payload.elevated
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启用配置
|
// 启用配置
|
||||||
const enableString = JSON.stringify(enableConfig.value)
|
const enableString = JSON.stringify(enableConfig.value)
|
||||||
@@ -274,9 +447,6 @@ async function saveDashboardConfig(payload?: { elevated?: boolean; enabled?: Rec
|
|||||||
const orderString = JSON.stringify(orderObj)
|
const orderString = JSON.stringify(orderObj)
|
||||||
localStorage.setItem('MP_DASHBOARD_ORDER', orderString)
|
localStorage.setItem('MP_DASHBOARD_ORDER', orderString)
|
||||||
|
|
||||||
// 是否拉升高度
|
|
||||||
localStorage.setItem('MP_DASHBOARD_ELEVATED', isElevated.value.toString())
|
|
||||||
|
|
||||||
// 保存到服务端
|
// 保存到服务端
|
||||||
try {
|
try {
|
||||||
await api.post('/user/config/Dashboard', enableConfig.value)
|
await api.post('/user/config/Dashboard', enableConfig.value)
|
||||||
@@ -387,61 +557,411 @@ async function getPluginDashboard(id: string, key: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖动排序结束
|
// 初始化 GridStack 仪表板实例。
|
||||||
function dragOrderEnd() {
|
function initializeDashboardGrid() {
|
||||||
// 保存数据
|
if (!dashboardGridRef.value || dashboardGrid.value) return
|
||||||
saveDashboardConfig()
|
|
||||||
|
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 () => {
|
onBeforeMount(async () => {
|
||||||
await loadDashboardConfig()
|
await loadDashboardConfig()
|
||||||
initializeColors()
|
initializeColors()
|
||||||
getPluginDashboardMeta()
|
getPluginDashboardMeta()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initializeDashboardGrid()
|
||||||
|
})
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
isRequest.value = true
|
isRequest.value = true
|
||||||
refreshEnabledPluginDashboards()
|
refreshEnabledPluginDashboards()
|
||||||
|
nextTick(syncDashboardGrid)
|
||||||
})
|
})
|
||||||
|
|
||||||
onDeactivated(() => {
|
onDeactivated(() => {
|
||||||
isRequest.value = false
|
isRequest.value = false
|
||||||
Object.keys(refreshTimers.value).forEach(clearPluginDashboardTimer)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 仪表板 -->
|
<!-- 仪表板 -->
|
||||||
<draggable
|
<div ref="dashboardGridRef" class="grid-stack dashboard-grid" :class="{ 'is-editing': isLayoutEditing }">
|
||||||
v-model="dashboardConfigs"
|
<div
|
||||||
@end="dragOrderEnd"
|
v-for="gridItem in dashboardGridItems"
|
||||||
handle=".cursor-move"
|
:key="gridItem.id"
|
||||||
item-key="id"
|
class="grid-stack-item dashboard-grid-item"
|
||||||
tag="VRow"
|
:class="{ 'is-manual-height': hasManualDashboardGridHeight(gridItem.id) }"
|
||||||
:component-data="elevatedConf"
|
: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"
|
||||||
>
|
>
|
||||||
<template #item="{ element }">
|
<div class="grid-stack-item-content dashboard-grid-item-content">
|
||||||
<VCol v-if="enableConfig[buildPluginDashboardId(element.id, element.key)] && element.cols" v-bind:="element.cols">
|
<div class="dashboard-grid-auto-size">
|
||||||
|
<div class="dashboard-grid-content-measure">
|
||||||
<DashboardElement
|
<DashboardElement
|
||||||
:config="element"
|
:config="gridItem.config"
|
||||||
:allow-refresh="isRequest"
|
:allow-refresh="isRequest"
|
||||||
v-model:refreshStatus="pluginDashboardRefreshStatus[buildPluginDashboardId(element.id, element.key)]"
|
v-model:refreshStatus="pluginDashboardRefreshStatus[gridItem.id]"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</div>
|
||||||
</template>
|
<span v-if="isLayoutEditing" class="dashboard-grid-drag-handle" :aria-label="t('dashboard.dragHandle')">
|
||||||
</draggable>
|
<VIcon icon="mdi-drag" size="small" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 底部操作按钮(只在非移动设备上显示) -->
|
<Teleport to="body" v-if="!appMode && route.path === '/dashboard'">
|
||||||
<Teleport to="body" v-if="route.path === '/dashboard'">
|
<div class="compact-fab-stack">
|
||||||
<div v-if="!appMode" class="compact-fab-stack">
|
|
||||||
<VFab
|
<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"
|
color="primary"
|
||||||
appear
|
appear
|
||||||
class="compact-fab compact-fab--primary"
|
class="compact-fab compact-fab--primary"
|
||||||
@click="openDashboardSettings"
|
@click="toggleDashboardLayoutEditing"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -135,11 +135,8 @@ useKeepAliveRefresh(refresh)
|
|||||||
<template>
|
<template>
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props" class="dashboard-chart-card">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>CPU</VCardTitle>
|
<VCardTitle>CPU</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
@@ -150,3 +147,9 @@ useKeepAliveRefresh(refresh)
|
|||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-chart-card {
|
||||||
|
min-block-size: 256px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -56,11 +56,8 @@ onActivated(() => {
|
|||||||
<template>
|
<template>
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props" class="dashboard-summary-card">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ t('dashboard.mediaStatistic') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.mediaStatistic') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
@@ -88,3 +85,9 @@ onActivated(() => {
|
|||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-summary-card {
|
||||||
|
min-block-size: 160px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -140,11 +140,8 @@ useKeepAliveRefresh(refresh)
|
|||||||
<template>
|
<template>
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props" class="dashboard-chart-card">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ t('dashboard.memory') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.memory') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
@@ -155,3 +152,9 @@ useKeepAliveRefresh(refresh)
|
|||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-chart-card {
|
||||||
|
min-block-size: 256px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -173,11 +173,8 @@ useKeepAliveRefresh(refresh)
|
|||||||
<template>
|
<template>
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props" class="dashboard-chart-card">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ t('dashboard.network') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.network') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
@@ -197,3 +194,9 @@ useKeepAliveRefresh(refresh)
|
|||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-chart-card {
|
||||||
|
min-block-size: 256px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -43,9 +43,6 @@ useDataRefresh(
|
|||||||
<template>
|
<template>
|
||||||
<VCard>
|
<VCard>
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ t('dashboard.processes.title') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.processes.title') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VTable item-key="fullName" class="table-rounded" hide-default-footer disable-sort>
|
<VTable item-key="fullName" class="table-rounded" hide-default-footer disable-sort>
|
||||||
|
|||||||
@@ -46,16 +46,13 @@ useDataRefresh(
|
|||||||
<template>
|
<template>
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props" class="dashboard-work-card">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ t('dashboard.scheduler') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.scheduler') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<VList class="card-list" height="250">
|
<VList class="card-list" height="260">
|
||||||
<VListItem v-for="item in schedulerList" :key="item.id">
|
<VListItem v-for="item in schedulerList" :key="item.id">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VAvatar size="40" variant="tonal" color="" class="me-3">
|
<VAvatar size="40" variant="tonal" color="" class="me-3">
|
||||||
@@ -94,6 +91,10 @@ useDataRefresh(
|
|||||||
--v-card-list-gap: 1.5rem;
|
--v-card-list-gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-work-card {
|
||||||
|
min-block-size: 352px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-list::-webkit-scrollbar {
|
.card-list::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,11 +89,8 @@ const { loading } = useDataRefresh(
|
|||||||
<template>
|
<template>
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props" class="dashboard-work-card">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ t('dashboard.realTimeSpeed') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.realTimeSpeed') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
@@ -131,4 +128,8 @@ const { loading } = useDataRefresh(
|
|||||||
.card-list {
|
.card-list {
|
||||||
--v-card-list-gap: 1rem;
|
--v-card-list-gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-work-card {
|
||||||
|
min-block-size: 352px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -49,13 +49,10 @@ onActivated(() => {
|
|||||||
<template>
|
<template>
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props" class="dashboard-summary-card">
|
||||||
<!-- Triangle Background -->
|
<!-- Triangle Background -->
|
||||||
<VImg :src="triangleBg" class="triangle-bg flip-in-rtl" />
|
<VImg :src="triangleBg" class="triangle-bg flip-in-rtl" />
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ t('dashboard.storage') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.storage') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
@@ -90,4 +87,8 @@ onActivated(() => {
|
|||||||
inset-block-end: 2rem;
|
inset-block-end: 2rem;
|
||||||
inset-inline-end: 2rem;
|
inset-inline-end: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-summary-card {
|
||||||
|
min-block-size: 160px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -133,11 +133,8 @@ onActivated(() => {
|
|||||||
<template>
|
<template>
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props" class="dashboard-work-card">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ t('dashboard.weeklyOverview') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.weeklyOverview') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
@@ -156,3 +153,9 @@ onActivated(() => {
|
|||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-work-card {
|
||||||
|
min-block-size: 352px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -60,11 +60,8 @@ onActivated(() => {
|
|||||||
<div>
|
<div>
|
||||||
<VHover v-for="(data, name) in latestList" :key="name">
|
<VHover v-for="(data, name) in latestList" :key="name">
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props" class="dashboard-work-card">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
@@ -90,6 +87,11 @@ onActivated(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard-card-grid-wrap {
|
.dashboard-card-grid-wrap {
|
||||||
/* 用内边距提供卡片留白,避免 100% 宽度网格叠加横向外边距后在 iOS 小屏溢出。 */
|
/* 用内边距提供卡片留白,避免 100% 宽度网格叠加横向外边距后在 iOS 小屏溢出。 */
|
||||||
|
min-block-size: 260px;
|
||||||
padding: 0 0.75rem 0.75rem;
|
padding: 0 0.75rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-work-card {
|
||||||
|
min-block-size: 352px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -65,9 +65,6 @@ onActivated(() => {
|
|||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<div class="dashboard-card-grid-wrap">
|
<div class="dashboard-card-grid-wrap">
|
||||||
|
|||||||
@@ -65,9 +65,6 @@ onActivated(() => {
|
|||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<template #append>
|
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
|
||||||
</template>
|
|
||||||
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
|
|||||||
@@ -4721,6 +4721,11 @@ graphemer@^1.4.0:
|
|||||||
resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz"
|
||||||
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
|
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:
|
has-bigints@^1.0.2:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz"
|
resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user