mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-17 05:30:59 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
785cbcf81d | ||
|
|
364b660390 | ||
|
|
599ca912f4 | ||
|
|
2f66f0f1fc | ||
|
|
cd2f561194 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moviepilot",
|
"name": "moviepilot",
|
||||||
"version": "2.13.7",
|
"version": "2.13.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ function openLoggerWindow() {
|
|||||||
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 下载当前插件日志压缩包。 */
|
||||||
|
function downloadLogger() {
|
||||||
|
const url = `${import.meta.env.VITE_API_BASE_URL}system/logging/download/${props.plugin?.id?.toLowerCase()}`
|
||||||
|
window.open(url, '_blank')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -52,12 +58,20 @@ function openLoggerWindow() {
|
|||||||
<VCardTitle class="d-inline-flex">
|
<VCardTitle class="d-inline-flex">
|
||||||
<VIcon icon="mdi-file-document" class="me-2" />
|
<VIcon icon="mdi-file-document" class="me-2" />
|
||||||
{{ t('plugin.logTitle') }}
|
{{ t('plugin.logTitle') }}
|
||||||
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
<span class="ms-4 d-inline-flex align-center ga-1">
|
||||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
<a class="d-inline-flex align-center cursor-pointer" @click="downloadLogger">
|
||||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
<VChip color="grey-darken-1" size="small">
|
||||||
{{ t('common.openInNewWindow') }}
|
<VIcon icon="mdi-download" size="small" start />
|
||||||
</VChip>
|
{{ t('common.download') }}
|
||||||
</a>
|
</VChip>
|
||||||
|
</a>
|
||||||
|
<a class="d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
||||||
|
<VChip color="grey-darken-1" size="small">
|
||||||
|
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||||
|
{{ t('common.openInNewWindow') }}
|
||||||
|
</VChip>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
ManualTransferPayload,
|
ManualTransferPayload,
|
||||||
ManualTransferPreviewData,
|
ManualTransferPreviewData,
|
||||||
ManualTransferPreviewItem,
|
ManualTransferPreviewItem,
|
||||||
ManualTransferTargetPathData,
|
|
||||||
StorageConf,
|
StorageConf,
|
||||||
TransferDirectoryConf,
|
TransferDirectoryConf,
|
||||||
TransferForm,
|
TransferForm,
|
||||||
@@ -118,14 +117,6 @@ const episodeFormatRecommendState = reactive<{
|
|||||||
|
|
||||||
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
|
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
|
||||||
|
|
||||||
interface ManualTransferTargetPathRequest {
|
|
||||||
fileitem?: FileItem
|
|
||||||
fileitems?: FileItem[]
|
|
||||||
logid?: number
|
|
||||||
logids?: number[]
|
|
||||||
target_storage?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TargetDirectoryOption {
|
interface TargetDirectoryOption {
|
||||||
title: string
|
title: string
|
||||||
value: string
|
value: string
|
||||||
@@ -297,16 +288,20 @@ const disableEpisodeDetail = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const initialTargetPath = normalizeTargetPath(props.target_path)
|
||||||
|
|
||||||
// 表单
|
// 表单
|
||||||
const transferForm = reactive<TransferForm>({
|
const transferForm = reactive<TransferForm>({
|
||||||
fileitem: {} as FileItem,
|
fileitem: {} as FileItem,
|
||||||
logid: 0,
|
logid: 0,
|
||||||
target_storage: props.target_storage ?? 'local',
|
target_storage: initialTargetPath ? props.target_storage ?? 'local' : null,
|
||||||
target_path: normalizeTargetPath(props.target_path),
|
target_path: initialTargetPath,
|
||||||
transfer_type: null,
|
transfer_type: null,
|
||||||
min_filesize: 0,
|
min_filesize: 0,
|
||||||
scrape: false,
|
scrape: initialTargetPath ? false : null,
|
||||||
from_history: false,
|
from_history: false,
|
||||||
|
library_type_folder: null,
|
||||||
|
library_category_folder: null,
|
||||||
episode_group: null,
|
episode_group: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -354,51 +349,6 @@ const targetPathSelection = computed({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 构造目的路径自动匹配请求,只传用户真实上下文,避免用默认存储误导后端匹配。
|
|
||||||
function createTargetPathMatchRequest(): ManualTransferTargetPathRequest | undefined {
|
|
||||||
const payload: ManualTransferTargetPathRequest = {}
|
|
||||||
|
|
||||||
if (props.target_storage) {
|
|
||||||
payload.target_storage = props.target_storage
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedItems.value.length === 1) {
|
|
||||||
payload.fileitem = normalizedItems.value[0]
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedItems.value.length > 1) {
|
|
||||||
payload.fileitems = normalizedItems.value
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.logids?.length) {
|
|
||||||
if (props.logids.length > 1) {
|
|
||||||
payload.logids = props.logids
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.logid = props.logids[0]
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用后端匹配到的目的路径配置,未匹配时保持 null 等待用户手工选择。
|
|
||||||
function applyMatchedTargetPath(data?: ManualTransferTargetPathData) {
|
|
||||||
const matchedTargetPath = normalizeTargetPath(data?.target_path)
|
|
||||||
if (!matchedTargetPath) {
|
|
||||||
resetAutomaticTargetConfig()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transferForm.target_storage = data?.target_storage || transferForm.target_storage || 'local'
|
|
||||||
transferForm.transfer_type = data?.transfer_type || transferForm.transfer_type
|
|
||||||
transferForm.scrape = data?.scrape ?? false
|
|
||||||
transferForm.library_type_folder = data?.library_type_folder ?? false
|
|
||||||
transferForm.library_category_folder = data?.library_category_folder ?? false
|
|
||||||
transferForm.target_path = matchedTargetPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置为完全自动匹配状态,提交时不携带目标路径及其派生配置。
|
// 重置为完全自动匹配状态,提交时不携带目标路径及其派生配置。
|
||||||
function resetAutomaticTargetConfig() {
|
function resetAutomaticTargetConfig() {
|
||||||
transferForm.target_storage = null
|
transferForm.target_storage = null
|
||||||
@@ -409,34 +359,6 @@ function resetAutomaticTargetConfig() {
|
|||||||
transferForm.library_category_folder = null
|
transferForm.library_category_folder = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求后端按源目录匹配最合适的手动整理目的路径。
|
|
||||||
async function autoSelectTargetPath() {
|
|
||||||
if (normalizeTargetPath(props.target_path) || transferForm.target_path) return
|
|
||||||
|
|
||||||
const payload = createTargetPathMatchRequest()
|
|
||||||
if (!payload) {
|
|
||||||
resetAutomaticTargetConfig()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.post<ApiResponse<ManualTransferTargetPathData>, ApiResponse<ManualTransferTargetPathData>>(
|
|
||||||
'transfer/manual/target-path',
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
resetAutomaticTargetConfig()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
applyMatchedTargetPath(result.data)
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
resetAutomaticTargetConfig()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听目的路径变化,配置默认值
|
// 监听目的路径变化,配置默认值
|
||||||
watch(
|
watch(
|
||||||
() => transferForm.target_path,
|
() => transferForm.target_path,
|
||||||
@@ -1374,7 +1296,6 @@ async function transfer(background: boolean = false) {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadDirectories()
|
await loadDirectories()
|
||||||
await autoSelectTargetPath()
|
|
||||||
loadStorages()
|
loadStorages()
|
||||||
loadEpisodeFormatRuleConfiguration()
|
loadEpisodeFormatRuleConfiguration()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ const visible = computed({
|
|||||||
function allLoggingUrl() {
|
function allLoggingUrl() {
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
|
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 拼接主程序日志下载 URL。 */
|
||||||
|
function allLoggingDownloadUrl() {
|
||||||
|
return `${import.meta.env.VITE_API_BASE_URL}system/logging/download/moviepilot`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,12 +49,20 @@ function allLoggingUrl() {
|
|||||||
<VCardTitle class="d-inline-flex">
|
<VCardTitle class="d-inline-flex">
|
||||||
<VIcon icon="mdi-file-document" class="me-2" />
|
<VIcon icon="mdi-file-document" class="me-2" />
|
||||||
{{ t('shortcut.log.subtitle') }}
|
{{ t('shortcut.log.subtitle') }}
|
||||||
<a class="mx-2 d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
|
<span class="ms-4 d-inline-flex align-center ga-1">
|
||||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
<a class="d-inline-flex align-center" :href="allLoggingDownloadUrl()" target="_blank">
|
||||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
<VChip color="grey-darken-1" size="small">
|
||||||
{{ t('common.openInNewWindow') }}
|
<VIcon icon="mdi-download" size="small" start />
|
||||||
</VChip>
|
{{ t('common.download') }}
|
||||||
</a>
|
</VChip>
|
||||||
|
</a>
|
||||||
|
<a class="d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
|
||||||
|
<VChip color="grey-darken-1" size="small">
|
||||||
|
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||||
|
{{ t('common.openInNewWindow') }}
|
||||||
|
</VChip>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default {
|
|||||||
success: 'Success',
|
success: 'Success',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
openInNewWindow: 'Open in new window',
|
openInNewWindow: 'Open in new window',
|
||||||
|
download: 'Download',
|
||||||
inputMessage: 'Enter message or command',
|
inputMessage: 'Enter message or command',
|
||||||
send: 'Send',
|
send: 'Send',
|
||||||
noData: 'No data',
|
noData: 'No data',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default {
|
|||||||
success: '成功',
|
success: '成功',
|
||||||
error: '错误',
|
error: '错误',
|
||||||
openInNewWindow: '在新窗口中打开',
|
openInNewWindow: '在新窗口中打开',
|
||||||
|
download: '下载',
|
||||||
inputMessage: '输入消息或命令',
|
inputMessage: '输入消息或命令',
|
||||||
send: '发送',
|
send: '发送',
|
||||||
noData: '暂无数据',
|
noData: '暂无数据',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default {
|
|||||||
success: '成功',
|
success: '成功',
|
||||||
error: '錯誤',
|
error: '錯誤',
|
||||||
openInNewWindow: '在新窗口中打開',
|
openInNewWindow: '在新窗口中打開',
|
||||||
|
download: '下載',
|
||||||
inputMessage: '輸入消息或命令',
|
inputMessage: '輸入消息或命令',
|
||||||
send: '發送',
|
send: '發送',
|
||||||
noData: '暫無數據',
|
noData: '暫無數據',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { GridStack } from 'gridstack'
|
import { GridStack } from 'gridstack'
|
||||||
import type { GridItemHTMLElement, GridStackWidget } from 'gridstack'
|
import type { ColumnOptions, GridItemHTMLElement, GridStackWidget } from 'gridstack'
|
||||||
import 'gridstack/dist/gridstack.min.css'
|
import 'gridstack/dist/gridstack.min.css'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
@@ -13,6 +13,7 @@ import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
|||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
const ContentToggleSettingsDialog = defineAsyncComponent(
|
const ContentToggleSettingsDialog = defineAsyncComponent(
|
||||||
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
||||||
@@ -23,6 +24,7 @@ const { t } = useI18n()
|
|||||||
|
|
||||||
// PWA模式检测
|
// PWA模式检测
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
|
const display = useDisplay()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const canAdmin = computed(() =>
|
const canAdmin = computed(() =>
|
||||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||||
@@ -32,11 +34,27 @@ const canAdmin = computed(() =>
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const DASHBOARD_GRID_COLUMNS = 12
|
const DASHBOARD_GRID_COLUMNS = 12
|
||||||
|
const DASHBOARD_GRID_DESKTOP_BREAKPOINT = 1280
|
||||||
|
const DASHBOARD_GRID_TABLET_BREAKPOINT = 960
|
||||||
|
const DASHBOARD_GRID_MOBILE_BREAKPOINT = 640
|
||||||
const DASHBOARD_GRID_CELL_HEIGHT = 16
|
const DASHBOARD_GRID_CELL_HEIGHT = 16
|
||||||
const DASHBOARD_GRID_FALLBACK_ROWS = 4
|
const DASHBOARD_GRID_FALLBACK_ROWS = 4
|
||||||
const DASHBOARD_GRID_MARGIN = 8
|
const DASHBOARD_GRID_MARGIN = 8
|
||||||
const DASHBOARD_GRID_CONTENT_RESIZE_THRESHOLD = 4
|
const DASHBOARD_GRID_CONTENT_RESIZE_THRESHOLD = 4
|
||||||
const DASHBOARD_GRID_LAYOUT_STORAGE_KEY = 'MP_DASHBOARD_GRID_LAYOUT'
|
const DASHBOARD_ENABLE_STORAGE_KEY = 'MP_DASHBOARD'
|
||||||
|
const DASHBOARD_ORDER_STORAGE_KEY = 'MP_DASHBOARD_ORDER'
|
||||||
|
const DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX = 'MP_DASHBOARD_GRID_LAYOUT'
|
||||||
|
const DASHBOARD_ENABLE_CONFIG_KEY = 'Dashboard'
|
||||||
|
const DASHBOARD_ORDER_CONFIG_KEY = 'DashboardOrder'
|
||||||
|
const DASHBOARD_GRID_LAYOUT_CONFIG_KEY = 'DashboardGridLayout'
|
||||||
|
const DASHBOARD_GRID_LAYOUT_CONFIG_KEY_PREFIX = 'DashboardGridLayout'
|
||||||
|
|
||||||
|
type DashboardEnableConfig = Record<string, boolean>
|
||||||
|
type DashboardOrderConfig = { id: string; key: string }[]
|
||||||
|
type DashboardGridLayoutConfig = Record<string, DashboardGridLayoutItem>
|
||||||
|
type DashboardConfigNormalizer<T> = (value: unknown) => T | undefined
|
||||||
|
type DashboardConfigRemoteValueBuilder<T> = (value: T) => unknown
|
||||||
|
type DashboardLayoutProfile = 'desktop' | 'tablet' | 'mobile'
|
||||||
|
|
||||||
interface DashboardGridLayoutItem {
|
interface DashboardGridLayoutItem {
|
||||||
x?: number
|
x?: number
|
||||||
@@ -75,6 +93,9 @@ const isSyncingDashboardGrid = ref(false)
|
|||||||
// 仪表板本地布局覆盖配置
|
// 仪表板本地布局覆盖配置
|
||||||
const dashboardGridLayout = ref<Record<string, DashboardGridLayoutItem>>({})
|
const dashboardGridLayout = ref<Record<string, DashboardGridLayoutItem>>({})
|
||||||
|
|
||||||
|
// 当前仪表板布局档位,按 GridStack 响应式列数拆分跨端配置。
|
||||||
|
const dashboardLayoutProfile = ref<DashboardLayoutProfile>('desktop')
|
||||||
|
|
||||||
// 是否刚恢复过默认布局,用于避免退出编辑时立即把默认布局写回本地覆盖。
|
// 是否刚恢复过默认布局,用于避免退出编辑时立即把默认布局写回本地覆盖。
|
||||||
const isDashboardGridLayoutResetPending = ref(false)
|
const isDashboardGridLayoutResetPending = ref(false)
|
||||||
|
|
||||||
@@ -309,42 +330,182 @@ function clampGridNumber(value: unknown, min: number, max: number, fallback: num
|
|||||||
return Math.min(max, Math.max(min, Math.round(numericValue)))
|
return Math.min(max, Math.max(min, Math.round(numericValue)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取并校验本地仪表板布局覆盖配置。
|
// 校验并归一化仪表板显示配置,避免异常用户配置影响页面渲染。
|
||||||
function readDashboardGridLayout() {
|
function normalizeDashboardEnableConfig(value: unknown): DashboardEnableConfig | undefined {
|
||||||
const rawLayout = localStorage.getItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY)
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||||
if (!rawLayout) return {}
|
|
||||||
|
|
||||||
try {
|
return Object.entries(value).reduce<DashboardEnableConfig>((config, [key, enabled]) => {
|
||||||
const parsedLayout = JSON.parse(rawLayout) as Record<string, DashboardGridLayoutItem>
|
config[key] = Boolean(enabled)
|
||||||
const normalizedLayout: Record<string, DashboardGridLayoutItem> = {}
|
|
||||||
|
|
||||||
Object.entries(parsedLayout).forEach(([id, layout]) => {
|
return config
|
||||||
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) {
|
// 校验并归一化仪表板顺序配置,只保留具备组件 ID 的项目。
|
||||||
normalizedItemLayout.h = clampGridNumber(layout.h, 1, 96, getDefaultDashboardGridRows())
|
function normalizeDashboardOrderConfig(value: unknown): DashboardOrderConfig | undefined {
|
||||||
}
|
if (!Array.isArray(value)) return undefined
|
||||||
|
|
||||||
normalizedLayout[id] = normalizedItemLayout
|
return value.reduce<DashboardOrderConfig>((config, item) => {
|
||||||
|
if (!item || typeof item !== 'object') return config
|
||||||
|
|
||||||
|
const rawItem = item as { id?: unknown; key?: unknown }
|
||||||
|
if (typeof rawItem.id !== 'string' || !rawItem.id) return config
|
||||||
|
|
||||||
|
config.push({
|
||||||
|
id: rawItem.id,
|
||||||
|
key: typeof rawItem.key === 'string' ? rawItem.key : '',
|
||||||
})
|
})
|
||||||
|
|
||||||
return normalizedLayout
|
return config
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验并归一化仪表板 Grid 布局覆盖配置,兼容旧版裸布局和新版服务端包装结构。
|
||||||
|
function normalizeDashboardGridLayout(value: unknown): DashboardGridLayoutConfig | undefined {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||||
|
|
||||||
|
const configValue = value as { items?: unknown }
|
||||||
|
const layoutValue = configValue.items && typeof configValue.items === 'object' ? configValue.items : value
|
||||||
|
const normalizedLayout: DashboardGridLayoutConfig = {}
|
||||||
|
|
||||||
|
Object.entries(layoutValue).forEach(([id, layout]) => {
|
||||||
|
if (!layout || typeof layout !== 'object') return
|
||||||
|
|
||||||
|
const rawLayout = layout as DashboardGridLayoutItem
|
||||||
|
const width = clampGridNumber(rawLayout.w, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
|
||||||
|
const normalizedItemLayout: DashboardGridLayoutItem = {
|
||||||
|
x: clampGridNumber(rawLayout.x, 0, DASHBOARD_GRID_COLUMNS - width, 0),
|
||||||
|
y: clampGridNumber(rawLayout.y, 0, 999, 0),
|
||||||
|
w: width,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawLayout.h !== undefined) {
|
||||||
|
normalizedItemLayout.h = clampGridNumber(rawLayout.h, 1, 96, getDefaultDashboardGridRows())
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedLayout[id] = normalizedItemLayout
|
||||||
|
})
|
||||||
|
|
||||||
|
return normalizedLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造服务端 Grid 布局配置,避免空布局被后端按空值删除后又被其他浏览器旧缓存回填。
|
||||||
|
function buildRemoteDashboardGridLayout(layout: DashboardGridLayoutConfig) {
|
||||||
|
return { items: layout }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据当前视口判断仪表板布局档位,避免手机和桌面共用 Grid 坐标。
|
||||||
|
function resolveDashboardLayoutProfile(): DashboardLayoutProfile {
|
||||||
|
const width = display.width.value || (typeof window === 'undefined' ? DASHBOARD_GRID_DESKTOP_BREAKPOINT : window.innerWidth)
|
||||||
|
|
||||||
|
if (width <= DASHBOARD_GRID_MOBILE_BREAKPOINT) return 'mobile'
|
||||||
|
if (width <= DASHBOARD_GRID_TABLET_BREAKPOINT) return 'tablet'
|
||||||
|
|
||||||
|
return 'desktop'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前布局档位对应的 GridStack 列数。
|
||||||
|
function getDashboardGridColumnsForProfile(profile: DashboardLayoutProfile) {
|
||||||
|
if (profile === 'mobile') return 1
|
||||||
|
if (profile === 'tablet') return 6
|
||||||
|
|
||||||
|
return DASHBOARD_GRID_COLUMNS
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前 Grid 实际列数,用于按布局档位保存当前坐标。
|
||||||
|
function getCurrentDashboardGridColumns() {
|
||||||
|
return dashboardGrid.value?.getColumn() ?? getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前布局档位的 GridStack 列变化策略。
|
||||||
|
function getDashboardGridColumnLayout(profile: DashboardLayoutProfile): ColumnOptions {
|
||||||
|
return profile === 'mobile' ? 'list' : 'moveScale'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取布局档位对应的本地存储键,桌面沿用旧键以兼容已有配置。
|
||||||
|
function getDashboardGridLayoutStorageKey(profile: DashboardLayoutProfile) {
|
||||||
|
if (profile === 'desktop') return DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX
|
||||||
|
|
||||||
|
return `${DASHBOARD_GRID_LAYOUT_STORAGE_KEY_PREFIX}_${profile.toUpperCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取布局档位对应的用户配置键,桌面沿用旧键以兼容已同步配置。
|
||||||
|
function getDashboardGridLayoutConfigKey(profile: DashboardLayoutProfile) {
|
||||||
|
if (profile === 'desktop') return DASHBOARD_GRID_LAYOUT_CONFIG_KEY
|
||||||
|
|
||||||
|
return `${DASHBOARD_GRID_LAYOUT_CONFIG_KEY_PREFIX}${profile === 'mobile' ? 'Mobile' : 'Tablet'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载指定布局档位的 Grid 布局配置。
|
||||||
|
async function loadDashboardGridLayoutConfig(profile: DashboardLayoutProfile) {
|
||||||
|
return await loadSharedDashboardConfig(
|
||||||
|
getDashboardGridLayoutConfigKey(profile),
|
||||||
|
getDashboardGridLayoutStorageKey(profile),
|
||||||
|
normalizeDashboardGridLayout,
|
||||||
|
buildRemoteDashboardGridLayout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从本地存储读取并归一化指定的仪表板配置。
|
||||||
|
function readLocalDashboardConfig<T>(storageKey: string, normalize: DashboardConfigNormalizer<T>) {
|
||||||
|
const rawConfig = localStorage.getItem(storageKey)
|
||||||
|
if (!rawConfig) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
return normalize(JSON.parse(rawConfig))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|
||||||
return {}
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将当前仪表板布局覆盖配置保存到本地。
|
// 将仪表板配置写入本地存储,保留离线和接口失败时的兜底能力。
|
||||||
|
function saveLocalDashboardConfig(storageKey: string, value: unknown) {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将仪表板配置写入用户配置,用于跨浏览器共享。
|
||||||
|
async function saveUserDashboardConfig(configKey: string, value: unknown) {
|
||||||
|
await api.post(`/user/config/${configKey}`, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先加载用户配置;服务端缺失时使用本地历史配置并回填到用户配置。
|
||||||
|
async function loadSharedDashboardConfig<T>(
|
||||||
|
configKey: string,
|
||||||
|
storageKey: string,
|
||||||
|
normalize: DashboardConfigNormalizer<T>,
|
||||||
|
buildRemoteValue: DashboardConfigRemoteValueBuilder<T> = value => value,
|
||||||
|
) {
|
||||||
|
const localConfig = readLocalDashboardConfig(storageKey, normalize)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/user/config/${configKey}`)
|
||||||
|
const remoteConfig = normalize(response?.data?.value)
|
||||||
|
|
||||||
|
if (remoteConfig !== undefined) {
|
||||||
|
saveLocalDashboardConfig(storageKey, remoteConfig)
|
||||||
|
|
||||||
|
return remoteConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localConfig !== undefined) {
|
||||||
|
await saveUserDashboardConfig(configKey, buildRemoteValue(localConfig))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return localConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将当前仪表板布局覆盖配置保存到本地和用户配置。
|
||||||
function saveDashboardGridLayout(layout: Record<string, DashboardGridLayoutItem>) {
|
function saveDashboardGridLayout(layout: Record<string, DashboardGridLayoutItem>) {
|
||||||
localStorage.setItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY, JSON.stringify(layout))
|
const profile = dashboardLayoutProfile.value
|
||||||
|
saveLocalDashboardConfig(getDashboardGridLayoutStorageKey(profile), layout)
|
||||||
|
void saveUserDashboardConfig(getDashboardGridLayoutConfigKey(profile), buildRemoteDashboardGridLayout(layout)).catch(
|
||||||
|
error => console.error(error),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取仪表板组件的默认宽度,优先兼容插件旧版 cols.md / cols.cols 配置。
|
// 获取仪表板组件的默认宽度,优先兼容插件旧版 cols.md / cols.cols 配置。
|
||||||
@@ -360,9 +521,10 @@ function getDefaultDashboardGridRows(item?: DashboardItem) {
|
|||||||
// 合并插件/内置组件默认尺寸与用户本地布局覆盖。
|
// 合并插件/内置组件默认尺寸与用户本地布局覆盖。
|
||||||
function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWidget {
|
function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWidget {
|
||||||
const savedLayout = dashboardGridLayout.value[id]
|
const savedLayout = dashboardGridLayout.value[id]
|
||||||
|
const gridColumns = getDashboardGridColumnsForProfile(dashboardLayoutProfile.value)
|
||||||
const width = savedLayout?.w ?? getDefaultDashboardGridWidth(item)
|
const width = savedLayout?.w ?? getDefaultDashboardGridWidth(item)
|
||||||
const height = savedLayout?.h ?? getDefaultDashboardGridRows(item)
|
const height = savedLayout?.h ?? getDefaultDashboardGridRows(item)
|
||||||
const normalizedWidth = clampGridNumber(width, 1, DASHBOARD_GRID_COLUMNS, DASHBOARD_GRID_COLUMNS)
|
const normalizedWidth = clampGridNumber(width, 1, gridColumns, gridColumns)
|
||||||
const widget: GridStackWidget = {
|
const widget: GridStackWidget = {
|
||||||
id,
|
id,
|
||||||
w: normalizedWidth,
|
w: normalizedWidth,
|
||||||
@@ -372,7 +534,7 @@ function buildDashboardGridWidget(item: DashboardItem, id: string): GridStackWid
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (savedLayout?.x !== undefined && savedLayout?.y !== undefined) {
|
if (savedLayout?.x !== undefined && savedLayout?.y !== undefined) {
|
||||||
widget.x = clampGridNumber(savedLayout.x, 0, DASHBOARD_GRID_COLUMNS - normalizedWidth, 0)
|
widget.x = clampGridNumber(savedLayout.x, 0, gridColumns - normalizedWidth, 0)
|
||||||
widget.y = clampGridNumber(savedLayout.y, 0, 999, 0)
|
widget.y = clampGridNumber(savedLayout.y, 0, 999, 0)
|
||||||
} else {
|
} else {
|
||||||
widget.autoPosition = true
|
widget.autoPosition = true
|
||||||
@@ -434,7 +596,7 @@ function exitDashboardLayoutEditing() {
|
|||||||
// 清除用户本地布局覆盖,并恢复内置组件和插件声明的默认占位,然后退出编辑模式。
|
// 清除用户本地布局覆盖,并恢复内置组件和插件声明的默认占位,然后退出编辑模式。
|
||||||
async function resetDashboardGridLayout() {
|
async function resetDashboardGridLayout() {
|
||||||
dashboardGridLayout.value = {}
|
dashboardGridLayout.value = {}
|
||||||
localStorage.removeItem(DASHBOARD_GRID_LAYOUT_STORAGE_KEY)
|
saveDashboardGridLayout({})
|
||||||
dashboardGrid.value?.removeAll(false, false)
|
dashboardGrid.value?.removeAll(false, false)
|
||||||
isDashboardGridLayoutResetPending.value = true
|
isDashboardGridLayoutResetPending.value = true
|
||||||
await syncDashboardGrid()
|
await syncDashboardGrid()
|
||||||
@@ -497,32 +659,31 @@ function toggleDashboardLayoutEditing() {
|
|||||||
nextTick(syncDashboardGrid)
|
nextTick(syncDashboardGrid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载用户监控面板配置(本地无配置时才加载)
|
// 加载用户监控面板配置,优先使用服务端用户配置以支持跨浏览器同步。
|
||||||
async function loadDashboardConfig() {
|
async function loadDashboardConfig() {
|
||||||
|
dashboardLayoutProfile.value = resolveDashboardLayoutProfile()
|
||||||
// 显示配置
|
// 显示配置
|
||||||
const local_enable = localStorage.getItem('MP_DASHBOARD')
|
const enable = await loadSharedDashboardConfig(
|
||||||
if (local_enable) {
|
DASHBOARD_ENABLE_CONFIG_KEY,
|
||||||
enableConfig.value = JSON.parse(local_enable)
|
DASHBOARD_ENABLE_STORAGE_KEY,
|
||||||
} else {
|
normalizeDashboardEnableConfig,
|
||||||
const response = await api.get('/user/config/Dashboard')
|
)
|
||||||
if (response && response.data && response.data.value) {
|
if (enable !== undefined) {
|
||||||
enableConfig.value = response.data.value
|
enableConfig.value = enable
|
||||||
localStorage.setItem('MP_DASHBOARD', JSON.stringify(response.data.value))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 顺序配置
|
// 顺序配置
|
||||||
const local_order = localStorage.getItem('MP_DASHBOARD_ORDER')
|
const order = await loadSharedDashboardConfig(
|
||||||
if (local_order) {
|
DASHBOARD_ORDER_CONFIG_KEY,
|
||||||
orderConfig.value = JSON.parse(local_order)
|
DASHBOARD_ORDER_STORAGE_KEY,
|
||||||
} else {
|
normalizeDashboardOrderConfig,
|
||||||
const response2 = await api.get('/user/config/DashboardOrder')
|
)
|
||||||
if (response2 && response2.data && response2.data.value) {
|
if (order !== undefined) {
|
||||||
orderConfig.value = response2.data.value
|
orderConfig.value = order
|
||||||
localStorage.setItem('MP_DASHBOARD_ORDER', JSON.stringify(orderConfig.value))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 本地 Grid 布局覆盖
|
// Grid 布局覆盖
|
||||||
dashboardGridLayout.value = readDashboardGridLayout()
|
const gridLayoutProfile = dashboardLayoutProfile.value
|
||||||
|
const gridLayout = await loadDashboardGridLayoutConfig(gridLayoutProfile)
|
||||||
|
dashboardGridLayout.value = gridLayout ?? {}
|
||||||
// 排序
|
// 排序
|
||||||
if (orderConfig.value) {
|
if (orderConfig.value) {
|
||||||
sortDashboardConfigs()
|
sortDashboardConfigs()
|
||||||
@@ -549,18 +710,16 @@ async function saveDashboardConfig(payload?: { enabled?: Record<string, boolean>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 启用配置
|
// 启用配置
|
||||||
const enableString = JSON.stringify(enableConfig.value)
|
saveLocalDashboardConfig(DASHBOARD_ENABLE_STORAGE_KEY, enableConfig.value)
|
||||||
localStorage.setItem('MP_DASHBOARD', enableString)
|
|
||||||
|
|
||||||
// 顺序配置,从dashboardConfigs中提取
|
// 顺序配置,从dashboardConfigs中提取
|
||||||
const orderObj = dashboardConfigs.value.map(item => ({ id: item.id, key: item.key }))
|
const orderObj = dashboardConfigs.value.map(item => ({ id: item.id, key: item.key }))
|
||||||
const orderString = JSON.stringify(orderObj)
|
saveLocalDashboardConfig(DASHBOARD_ORDER_STORAGE_KEY, orderObj)
|
||||||
localStorage.setItem('MP_DASHBOARD_ORDER', orderString)
|
|
||||||
|
|
||||||
// 保存到服务端
|
// 保存到服务端
|
||||||
try {
|
try {
|
||||||
await api.post('/user/config/Dashboard', enableConfig.value)
|
await saveUserDashboardConfig(DASHBOARD_ENABLE_CONFIG_KEY, enableConfig.value)
|
||||||
await api.post('/user/config/DashboardOrder', orderObj)
|
await saveUserDashboardConfig(DASHBOARD_ORDER_CONFIG_KEY, orderObj)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
@@ -676,9 +835,9 @@ function initializeDashboardGrid() {
|
|||||||
column: DASHBOARD_GRID_COLUMNS,
|
column: DASHBOARD_GRID_COLUMNS,
|
||||||
columnOpts: {
|
columnOpts: {
|
||||||
breakpoints: [
|
breakpoints: [
|
||||||
{ w: 640, c: 1, layout: 'list' },
|
{ w: DASHBOARD_GRID_MOBILE_BREAKPOINT, c: 1, layout: 'list' },
|
||||||
{ w: 960, c: 6, layout: 'moveScale' },
|
{ w: DASHBOARD_GRID_TABLET_BREAKPOINT, c: 6, layout: 'moveScale' },
|
||||||
{ w: 1280, c: DASHBOARD_GRID_COLUMNS, layout: 'moveScale' },
|
{ w: DASHBOARD_GRID_DESKTOP_BREAKPOINT, c: DASHBOARD_GRID_COLUMNS, layout: 'moveScale' },
|
||||||
],
|
],
|
||||||
layout: 'moveScale',
|
layout: 'moveScale',
|
||||||
},
|
},
|
||||||
@@ -893,7 +1052,8 @@ function notifyDashboardContentResize() {
|
|||||||
function persistDashboardGridLayout(manualHeightId: string | false = false) {
|
function persistDashboardGridLayout(manualHeightId: string | false = false) {
|
||||||
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
|
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
|
||||||
|
|
||||||
const savedWidgets = dashboardGrid.value.save(false, false, undefined, DASHBOARD_GRID_COLUMNS)
|
const gridColumns = getCurrentDashboardGridColumns()
|
||||||
|
const savedWidgets = dashboardGrid.value.save(false, false, undefined, gridColumns)
|
||||||
const widgets = Array.isArray(savedWidgets) ? savedWidgets : (savedWidgets.children ?? [])
|
const widgets = Array.isArray(savedWidgets) ? savedWidgets : (savedWidgets.children ?? [])
|
||||||
const nextLayout = { ...dashboardGridLayout.value }
|
const nextLayout = { ...dashboardGridLayout.value }
|
||||||
|
|
||||||
@@ -901,10 +1061,10 @@ function persistDashboardGridLayout(manualHeightId: string | false = false) {
|
|||||||
if (!widget.id) return
|
if (!widget.id) return
|
||||||
|
|
||||||
const id = String(widget.id)
|
const id = String(widget.id)
|
||||||
const width = clampGridNumber(widget.w, 1, DASHBOARD_GRID_COLUMNS, getDefaultDashboardGridWidthById(id))
|
const width = clampGridNumber(widget.w, 1, gridColumns, getDefaultDashboardGridWidthById(id, gridColumns))
|
||||||
const previousLayout = dashboardGridLayout.value[id]
|
const previousLayout = dashboardGridLayout.value[id]
|
||||||
const nextItemLayout: DashboardGridLayoutItem = {
|
const nextItemLayout: DashboardGridLayoutItem = {
|
||||||
x: clampGridNumber(widget.x, 0, DASHBOARD_GRID_COLUMNS - width, 0),
|
x: clampGridNumber(widget.x, 0, gridColumns - width, 0),
|
||||||
y: clampGridNumber(widget.y, 0, 999, 0),
|
y: clampGridNumber(widget.y, 0, 999, 0),
|
||||||
w: width,
|
w: width,
|
||||||
}
|
}
|
||||||
@@ -922,10 +1082,10 @@ function persistDashboardGridLayout(manualHeightId: string | false = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 根据组件 ID 查找默认宽度,保存布局时用于兜底。
|
// 根据组件 ID 查找默认宽度,保存布局时用于兜底。
|
||||||
function getDefaultDashboardGridWidthById(id: string) {
|
function getDefaultDashboardGridWidthById(id: string, maxColumns = DASHBOARD_GRID_COLUMNS) {
|
||||||
const item = dashboardConfigs.value.find(config => buildPluginDashboardId(config.id, config.key) === id)
|
const item = dashboardConfigs.value.find(config => buildPluginDashboardId(config.id, config.key) === id)
|
||||||
|
|
||||||
return item ? getDefaultDashboardGridWidth(item) : DASHBOARD_GRID_COLUMNS
|
return item ? Math.min(getDefaultDashboardGridWidth(item), maxColumns) : maxColumns
|
||||||
}
|
}
|
||||||
|
|
||||||
// 压实 GridStack 布局并保存本地占位信息。
|
// 压实 GridStack 布局并保存本地占位信息。
|
||||||
@@ -951,6 +1111,25 @@ watch(
|
|||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => display.width.value,
|
||||||
|
async () => {
|
||||||
|
const nextProfile = resolveDashboardLayoutProfile()
|
||||||
|
if (nextProfile === dashboardLayoutProfile.value) return
|
||||||
|
|
||||||
|
if (dashboardGrid.value && !isSyncingDashboardGrid.value && !isDashboardGridLayoutResetPending.value) {
|
||||||
|
persistDashboardGridLayout(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboardLayoutProfile.value = nextProfile
|
||||||
|
dashboardGridLayout.value = (await loadDashboardGridLayoutConfig(nextProfile)) ?? {}
|
||||||
|
dashboardGrid.value?.column(getDashboardGridColumnsForProfile(nextProfile), getDashboardGridColumnLayout(nextProfile))
|
||||||
|
dashboardGrid.value?.removeAll(false, false)
|
||||||
|
await syncDashboardGrid()
|
||||||
|
notifyDashboardContentResize()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
await loadDashboardConfig()
|
await loadDashboardConfig()
|
||||||
initializeColors()
|
initializeColors()
|
||||||
|
|||||||
Reference in New Issue
Block a user