mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-16 21:20:45 +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",
|
||||
"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",
|
||||
|
||||
@@ -702,6 +702,8 @@ export interface DashboardItem {
|
||||
attrs: { [key: string]: any }
|
||||
// col列数
|
||||
cols: { [key: string]: number }
|
||||
// Grid行数
|
||||
rows?: number
|
||||
// 页面元素
|
||||
elements: RenderProps[]
|
||||
// 渲染方式
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -894,7 +894,10 @@ export default {
|
||||
latest: '最近添加',
|
||||
settings: '设置仪表板',
|
||||
chooseContent: '选择您想在页面显示的内容',
|
||||
adaptiveHeight: '自适应组件高度',
|
||||
editLayout: '编辑布局',
|
||||
exitEditMode: '完成编辑',
|
||||
resetLayout: '恢复默认布局',
|
||||
dragHandle: '拖动组件',
|
||||
current: '当前',
|
||||
episodes: '剧集',
|
||||
users: '用户',
|
||||
|
||||
@@ -894,7 +894,10 @@ export default {
|
||||
latest: '最近添加',
|
||||
settings: '設置儀表板',
|
||||
chooseContent: '選擇您想在頁面顯示的內容',
|
||||
adaptiveHeight: '自適應組件高度',
|
||||
editLayout: '編輯佈局',
|
||||
exitEditMode: '完成編輯',
|
||||
resetLayout: '恢復默認佈局',
|
||||
dragHandle: '拖動組件',
|
||||
current: '當前',
|
||||
episodes: '劇集',
|
||||
users: '用戶',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user