mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-12 02:21:06 +08:00
527 lines
14 KiB
Vue
527 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import draggable from 'vuedraggable'
|
||
import api from '@/api'
|
||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||
import { DashboardItem } from '@/api/types'
|
||
import { useUserStore } from '@/stores'
|
||
import DashboardElement from '@/components/misc/DashboardElement.vue'
|
||
import { useDisplay } from 'vuetify'
|
||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { VCardActions } from 'vuetify/components'
|
||
import { usePWA } from '@/composables/usePWA'
|
||
import { getItemColor, initializeItemColors } from '@/utils/colorUtils'
|
||
|
||
// 国际化
|
||
const { t } = useI18n()
|
||
|
||
// APP
|
||
const display = useDisplay()
|
||
// PWA模式检测
|
||
const { appMode } = usePWA()
|
||
|
||
// 路由
|
||
const route = useRoute()
|
||
|
||
// 从用户 Store 中获取superuser信息
|
||
const superUser = useUserStore().superUser
|
||
|
||
// 是否拉升高度
|
||
const isElevated = ref(true)
|
||
|
||
// 是否发送请求的总开关
|
||
const isRequest = ref(true)
|
||
|
||
// 计算属性,控制是否拉升高度
|
||
const elevatedConf = controlledComputed(
|
||
() => isElevated.value,
|
||
() => ({
|
||
class: { 'match-height': isElevated.value },
|
||
}),
|
||
)
|
||
|
||
// 所有组件刷新定时器的句柄
|
||
const refreshTimers = ref<{ [key: string]: NodeJS.Timeout }>({})
|
||
|
||
// 仪表板启用配置
|
||
const enableConfig = ref<{ [key: string]: boolean }>({
|
||
mediaStatistic: true,
|
||
scheduler: false,
|
||
speed: false,
|
||
storage: true,
|
||
weeklyOverview: false,
|
||
cpu: false,
|
||
memory: false,
|
||
network: false,
|
||
library: true,
|
||
playing: true,
|
||
latest: true,
|
||
})
|
||
|
||
// 仪表板顺序配置
|
||
const orderConfig = ref<{ id: string; key: string }[]>([])
|
||
|
||
// 仪表板配置
|
||
const dashboardConfigs = ref<DashboardItem[]>([
|
||
{
|
||
id: 'storage',
|
||
name: t('dashboard.storage'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 4 },
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'mediaStatistic',
|
||
name: t('dashboard.mediaStatistic'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 8 },
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'weeklyOverview',
|
||
name: t('dashboard.weeklyOverview'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 4 },
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'speed',
|
||
name: t('dashboard.realTimeSpeed'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 4 },
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'scheduler',
|
||
name: t('dashboard.scheduler'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 4 },
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'cpu',
|
||
name: t('dashboard.cpu'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 6 },
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'memory',
|
||
name: t('dashboard.memory'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 6 },
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'network',
|
||
name: t('dashboard.network'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12, md: 6 },
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'library',
|
||
name: t('dashboard.library'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12 },
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'playing',
|
||
name: t('dashboard.playing'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12 },
|
||
elements: [],
|
||
},
|
||
{
|
||
id: 'latest',
|
||
name: t('dashboard.latest'),
|
||
key: '',
|
||
attrs: {},
|
||
cols: { cols: 12 },
|
||
elements: [],
|
||
},
|
||
])
|
||
|
||
// 插件的仪表板元信息
|
||
const pluginDashboardMeta = ref<any[]>([])
|
||
|
||
// 插件仪表板的刷新状态
|
||
const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
|
||
|
||
// 弹窗
|
||
const dialog = ref(false)
|
||
|
||
// 为每个项目生成随机颜色
|
||
const itemColors = ref<{ [key: string]: string }>({})
|
||
|
||
// 初始化颜色
|
||
function initializeColors() {
|
||
initializeItemColors(dashboardConfigs.value, item => buildPluginDashboardId(item.id, item.key))
|
||
dashboardConfigs.value.forEach(item => {
|
||
const itemId = buildPluginDashboardId(item.id, item.key)
|
||
itemColors.value[itemId] = getItemColor(itemId)
|
||
})
|
||
}
|
||
|
||
// 使用动态按钮钩子
|
||
useDynamicButton({
|
||
icon: 'mdi-view-dashboard-edit',
|
||
onClick: () => {
|
||
dialog.value = true
|
||
},
|
||
})
|
||
|
||
// 加载用户监控面板配置(本地无配置时才加载)
|
||
async function loadDashboardConfig() {
|
||
// 显示配置
|
||
const local_enable = localStorage.getItem('MP_DASHBOARD')
|
||
if (local_enable) {
|
||
enableConfig.value = JSON.parse(local_enable)
|
||
} else {
|
||
const response = await api.get('/user/config/Dashboard')
|
||
if (response && response.data && response.data.value) {
|
||
enableConfig.value = response.data.value
|
||
localStorage.setItem('MP_DASHBOARD', JSON.stringify(response.data.value))
|
||
}
|
||
}
|
||
// 顺序配置
|
||
const local_order = localStorage.getItem('MP_DASHBOARD_ORDER')
|
||
if (local_order) {
|
||
orderConfig.value = JSON.parse(local_order)
|
||
} else {
|
||
const response2 = await api.get('/user/config/DashboardOrder')
|
||
if (response2 && response2.data && response2.data.value) {
|
||
orderConfig.value = response2.data.value
|
||
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'
|
||
// 排序
|
||
if (orderConfig.value) {
|
||
sortDashboardConfigs()
|
||
}
|
||
}
|
||
|
||
// 按order的顺序对dashboardConfigs进行排序
|
||
function sortDashboardConfigs() {
|
||
dashboardConfigs.value.sort((a, b) => {
|
||
const aIndex = orderConfig.value.findIndex(
|
||
(item: { id: string; key: string }) => item.id === a.id && item.key === a.key,
|
||
)
|
||
const bIndex = orderConfig.value.findIndex(
|
||
(item: { id: string; key: string }) => item.id === b.id && item.key === b.key,
|
||
)
|
||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
||
})
|
||
}
|
||
|
||
// 设置项目
|
||
async function saveDashboardConfig() {
|
||
// 启用配置
|
||
const enableString = JSON.stringify(enableConfig.value)
|
||
localStorage.setItem('MP_DASHBOARD', enableString)
|
||
|
||
// 顺序配置,从dashboardConfigs中提取
|
||
const orderObj = dashboardConfigs.value.map(item => ({ id: item.id, key: item.key }))
|
||
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)
|
||
await api.post('/user/config/DashboardOrder', orderObj)
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
// 保存后重新获取插件仪表板
|
||
getPluginDashboardMeta()
|
||
dialog.value = false
|
||
}
|
||
|
||
// 构造插件仪表板主ID
|
||
function buildPluginDashboardId(plugin_id: string, key: string) {
|
||
if (!key) return plugin_id
|
||
return plugin_id + ':' + key
|
||
}
|
||
|
||
// 调用API获取所有插件的仪表板元信息
|
||
async function getPluginDashboardMeta() {
|
||
// 只有超级用户才能获取
|
||
if (!superUser) return
|
||
pluginDashboardMeta.value = await api.get('/plugin/dashboard/meta')
|
||
try {
|
||
if (!isNullOrEmptyObject(pluginDashboardMeta.value)) {
|
||
// 下载插件仪表板配置
|
||
pluginDashboardMeta.value.forEach(async (pluginDashboard: { id: string; key: string }) => {
|
||
const pluginDashboardId = buildPluginDashboardId(pluginDashboard.id, pluginDashboard.key)
|
||
// 初始化插件仪表板的刷新状态
|
||
pluginDashboardRefreshStatus.value[pluginDashboardId] = true
|
||
await getPluginDashboard(pluginDashboard.id, pluginDashboard.key)
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 获取一个插件的仪表板配置项
|
||
async function getPluginDashboard(id: string, key: string) {
|
||
try {
|
||
const url = key ? `/plugin/dashboard/${id}/${key}` : `/plugin/dashboard/${id}`
|
||
api.get(url).then((res: any) => {
|
||
if (res) {
|
||
// 名称替换为元信息的名称
|
||
const meta = pluginDashboardMeta.value.find(
|
||
(item: { id: string; key: string }) => item.id === id && item.key === key,
|
||
)
|
||
if (meta) res.name = meta.name
|
||
// 保存到仪表板配置中,如果已经存在则替换
|
||
const index = dashboardConfigs.value.findIndex(
|
||
(item: { id: string; key: string }) => item.id === id && item.key === key,
|
||
)
|
||
if (index !== -1) {
|
||
dashboardConfigs.value[index] = res
|
||
} else {
|
||
dashboardConfigs.value.push(res)
|
||
// 为新增的插件仪表板生成颜色
|
||
const pluginDashboardId = buildPluginDashboardId(id, key)
|
||
if (!itemColors.value[pluginDashboardId]) {
|
||
itemColors.value[pluginDashboardId] = getItemColor(pluginDashboardId)
|
||
}
|
||
// 排序
|
||
sortDashboardConfigs()
|
||
}
|
||
const pluginDashboardId = buildPluginDashboardId(id, key)
|
||
// 定时刷新
|
||
if (
|
||
res.attrs?.refresh &&
|
||
pluginDashboardRefreshStatus.value[pluginDashboardId] &&
|
||
enableConfig.value[pluginDashboardId] &&
|
||
isRequest.value
|
||
) {
|
||
// 清除之前的定时器
|
||
if (refreshTimers.value[pluginDashboardId]) {
|
||
clearTimeout(refreshTimers.value[pluginDashboardId])
|
||
}
|
||
// 设置新的定时器
|
||
let timer = setTimeout(() => {
|
||
getPluginDashboard(id, key)
|
||
}, res.attrs.refresh * 1000)
|
||
refreshTimers.value[pluginDashboardId] = timer
|
||
}
|
||
}
|
||
})
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
// 拖动排序结束
|
||
function dragOrderEnd() {
|
||
// 保存数据
|
||
saveDashboardConfig()
|
||
}
|
||
|
||
onBeforeMount(async () => {
|
||
await loadDashboardConfig()
|
||
initializeColors()
|
||
getPluginDashboardMeta()
|
||
})
|
||
|
||
onActivated(() => {
|
||
isRequest.value = true
|
||
})
|
||
|
||
onDeactivated(() => {
|
||
isRequest.value = false
|
||
})
|
||
</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>
|
||
|
||
<!-- 底部操作按钮(只在非移动设备上显示) -->
|
||
<Teleport to="body" v-if="route.path === '/dashboard'">
|
||
<div v-if="!appMode" class="compact-fab-stack">
|
||
<VFab
|
||
icon="mdi-view-dashboard-edit"
|
||
color="primary"
|
||
appear
|
||
class="compact-fab compact-fab--primary"
|
||
@click="dialog = true"
|
||
/>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<!-- 弹窗,根据配置生成选项 -->
|
||
<VDialog v-if="dialog" v-model="dialog" max-width="35rem" :fullscreen="!display.mdAndUp.value" scrollable>
|
||
<VCard>
|
||
<VCardItem>
|
||
<VCardTitle>
|
||
<VIcon icon="mdi-tune" size="small" class="me-2" />
|
||
{{ t('dashboard.settings') }}
|
||
</VCardTitle>
|
||
<VDialogCloseBtn @click="dialog = false" />
|
||
</VCardItem>
|
||
<VDivider />
|
||
<VCardText>
|
||
<p class="settings-hint">{{ t('dashboard.chooseContent') }}</p>
|
||
<div class="settings-grid">
|
||
<div
|
||
v-for="item in dashboardConfigs"
|
||
:key="buildPluginDashboardId(item.id, item.key)"
|
||
class="setting-item"
|
||
:class="{
|
||
'enabled': enableConfig[buildPluginDashboardId(item.id, item.key)],
|
||
}"
|
||
:style="{ '--item-color': itemColors[buildPluginDashboardId(item.id, item.key)] }"
|
||
@click="
|
||
enableConfig[buildPluginDashboardId(item.id, item.key)] =
|
||
!enableConfig[buildPluginDashboardId(item.id, item.key)]
|
||
"
|
||
>
|
||
<div class="setting-item-inner">
|
||
<div class="setting-check">
|
||
<VIcon
|
||
:icon="
|
||
enableConfig[buildPluginDashboardId(item.id, item.key)] ? 'mdi-check-circle' : 'mdi-circle-outline'
|
||
"
|
||
:color="enableConfig[buildPluginDashboardId(item.id, item.key)] ? 'primary' : undefined"
|
||
size="small"
|
||
/>
|
||
</div>
|
||
<span class="setting-label">{{ item.attrs?.title ?? item.name }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p class="mt-3">
|
||
<VSwitch v-model="isElevated" :label="t('dashboard.adaptiveHeight')" />
|
||
</p>
|
||
</VCardText>
|
||
<VCardActions class="pt-3">
|
||
<VSpacer />
|
||
<VBtn @click="saveDashboardConfig">
|
||
<template #prepend>
|
||
<VIcon icon="mdi-content-save" />
|
||
</template>
|
||
{{ t('common.save') }}
|
||
</VBtn>
|
||
</VCardActions>
|
||
</VCard>
|
||
</VDialog>
|
||
</template>
|
||
<style lang="scss" scoped>
|
||
.settings-card-header {
|
||
padding-block: 16px;
|
||
padding-inline: 20px;
|
||
}
|
||
|
||
.settings-hint {
|
||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||
font-size: 0.9rem;
|
||
margin-block-end: 16px;
|
||
}
|
||
|
||
.settings-grid {
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
}
|
||
|
||
.setting-label {
|
||
flex: 1;
|
||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
line-height: 1.2;
|
||
transition: color 0.2s ease;
|
||
}
|
||
|
||
.setting-item {
|
||
position: relative;
|
||
overflow: hidden;
|
||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||
border-radius: 8px;
|
||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||
cursor: pointer;
|
||
padding-block: 10px;
|
||
padding-inline: 12px;
|
||
transition: all 0.2s ease;
|
||
|
||
&::before {
|
||
position: absolute;
|
||
background-color: var(--item-color, #4caf50);
|
||
block-size: 100%;
|
||
content: '';
|
||
inline-size: 4px;
|
||
inset-block-start: 0;
|
||
inset-inline-start: 0;
|
||
transition: background-color 0.3s ease;
|
||
}
|
||
|
||
&:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
&.enabled {
|
||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||
|
||
.setting-label {
|
||
color: rgba(var(--v-theme-primary), 0.9);
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
}
|
||
|
||
.setting-item-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.setting-check {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
@media (width <= 600px) {
|
||
.settings-grid {
|
||
grid-template-columns: repeat(2, 1fr);
|
||
}
|
||
}
|
||
</style>
|