在多个组件中添加渲染模式支持,优化插件配置和数据加载逻辑,增强用户体验和代码可读性

This commit is contained in:
jxxghp
2025-05-03 10:04:50 +08:00
parent 04e9b68e4a
commit 4a8cf16012
5 changed files with 251 additions and 64 deletions

View File

@@ -7,6 +7,16 @@ const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
})
// 声明全局变量类型
declare global {
interface Window {
MoviePilotAPI: typeof api
}
}
// 将 API 实例暴露到全局,供插件使用
window.MoviePilotAPI = api
// 添加请求拦截器
api.interceptors.request.use(config => {
// 认证 Store

View File

@@ -631,6 +631,10 @@ export interface DashboardItem {
cols: { [key: string]: number }
// 页面元素
elements: RenderProps[]
// 渲染方式
render_mode: string
// 组件地址
component_url: string
}
// 种子信息

View File

@@ -42,29 +42,81 @@ const $toast = useToast()
// 是否刷新
const isRefreshed = ref(false)
// 调用API读取表单页面
async function loadPluginForm() {
try {
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (result) {
pluginFormItems = result.conf
if (result.model) pluginConfigForm.value = result.model
}
} catch (error) {
console.error(error)
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
//Vue 模式:组件 URL
const vueComponentUrl = ref<string | null>(null)
//Vue 模式:动态加载的组件
const dynamicComponent = computed(() => {
if (renderMode.value === 'vue' && vueComponentUrl.value) {
const url = vueComponentUrl.value
return defineAsyncComponent(() =>
import(/* @vite-ignore */ url)
.then(module => {
// 假设 JS 文件默认导出组件
if (module.default) {
return module.default
} else {
$toast.error(`无法从 ${url} 加载默认导出的 Vue 组件`)
return { render: () => h('div', '组件加载失败: 无默认导出') }
}
})
.catch(err => {
$toast.error(`无法加载插件组件: ${url}`, err)
return { render: () => h('div', '组件加载失败') }
}),
)
}
return null
})
//调用API读取UI和配置数据
async function loadPluginUIData() {
// 重置
isRefreshed.value = false
pluginFormItems = []
pluginConfigForm.value = {}
renderMode.value = 'vuetify'
vueComponentUrl.value = null
try {
// 获取UI定义
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
if (!result) {
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败无效的响应`)
return
}
renderMode.value = result.render_mode
if (renderMode.value === 'vue') {
// 使用 component_url
vueComponentUrl.value = result.component_url
// Vue模式下初始配置在同一个API返回
if (!isNullOrEmptyObject(result.model)) {
pluginConfigForm.value = result.model
}
if (!vueComponentUrl.value) {
console.error(`插件 ${props.plugin?.plugin_name} 配置错误未提供Vue组件URL`)
}
} else {
// Vuetify模式
pluginFormItems = result.conf || []
if (result.model) {
pluginConfigForm.value = result.model
}
}
} catch (error: any) {
console.error(error)
} finally {
isRefreshed.value = true
}
isRefreshed.value = true
}
// 调用API读取配置数据
async function loadPluginConf() {
try {
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
} catch (error) {
console.error(error)
}
isRefreshed.value = true
// 处理 Vue 组件触发的保存事件
function handleVueComponentSave(newConfig: Record<string, any>) {
pluginConfigForm.value = newConfig
savePluginConf()
}
// 调用API保存配置数据
@@ -75,22 +127,20 @@ async function savePluginConf() {
try {
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
if (result.success) {
progressDialog.value = false
$toast.success(t('dialog.pluginConfig.saveSuccess', { name: props.plugin?.plugin_name }))
// 通知父组件刷新
emit('save')
} else {
progressDialog.value = false
$toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message: result.message }))
}
} catch (error) {
console.error(error)
}
progressDialog.value = false
}
onBeforeMount(async () => {
await loadPluginForm()
await loadPluginConf()
await loadPluginUIData()
})
</script>
<template>
@@ -99,15 +149,32 @@ onBeforeMount(async () => {
<VDialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText v-if="isRefreshed">
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
<!-- Vuetify 渲染模式 -->
<div v-if="renderMode === 'vuetify'">
<FormRender v-for="(item, index) in pluginFormItems" :key="index" :config="item" :model="pluginConfigForm" />
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
</div>
<!-- Vue 渲染模式 -->
<div v-else-if="renderMode === 'vue' && dynamicComponent">
<component :is="dynamicComponent" :initial-config="pluginConfigForm" @save="handleVueComponentSave" />
</div>
<!-- 加载中或错误 -->
<div v-else><VProgressCircular indeterminate /> 加载中...</div>
</VCardText>
<VCardActions class="pt-3">
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info">
{{ t('dialog.pluginConfig.viewData') }}
</VBtn>
<VSpacer />
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
{{ t('dialog.pluginConfig.save') }}
<!-- 只有Vuetify模式显示默认保存按钮Vue模式由组件内部控制 -->
<VBtn
v-if="renderMode === 'vuetify'"
@click="savePluginConf"
variant="elevated"
prepend-icon="mdi-content-save"
class="px-5"
>
保存
</VBtn>
</VCardActions>
</VCard>

View File

@@ -3,6 +3,7 @@ import { useDisplay } from 'vuetify'
import type { Plugin } from '@/api/types'
import PageRender from '@/components/render/PageRender.vue'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
// 输入参数
const props = defineProps({
@@ -19,25 +20,80 @@ const display = useDisplay()
// APP
const appMode = inject('pwaMode') && display.mdAndDown.value
// 提示框
const $toast = useToast()
// 是否刷新
const isRefreshed = ref(false)
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
// Vue 模式:组件 URL
const vueComponentUrl = ref<string | null>(null)
// 插件数据页面配置项
let pluginPageItems = ref([])
// 调用API读取数据页面
async function loadPluginPage() {
try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
if (result) pluginPageItems.value = result
} catch (error) {
console.error(error)
// Vue 模式:动态加载的组件
const dynamicComponent = computed(() => {
if (renderMode.value === 'vue' && vueComponentUrl.value) {
const url = vueComponentUrl.value
return defineAsyncComponent(() =>
import(/* @vite-ignore */ url)
.then(module => {
if (module.default) {
return module.default
} else {
$toast.error(`无法从 ${url} 加载默认导出的 Vue 组件`)
return { render: () => h('div', '组件加载失败: 无默认导出') }
}
})
.catch(err => {
$toast.error(`无法加载插件组件: ${url}`, err)
return { render: () => h('div', '组件加载失败') }
}),
)
}
isRefreshed.value = true
return null
})
// 调用API读取数据页面UI
async function loadPluginUIData() {
isRefreshed.value = false
pluginPageItems.value = []
renderMode.value = 'vuetify'
vueComponentUrl.value = null
try {
const result: { [key: string]: any } = await api.get(`plugin/page/${props.plugin?.id}`)
if (!result || !result.render_mode) {
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败无效的响应`)
return
}
renderMode.value = result.render_mode
if (renderMode.value === 'vue') {
vueComponentUrl.value = result.component_url
if (!vueComponentUrl.value) {
console.error(`插件 ${props.plugin?.plugin_name} 配置错误未提供Vue组件URL`)
renderMode.value = 'vuetify'
}
} else {
pluginPageItems.value = result.page || []
}
} catch (error: any) {
console.error(error)
} finally {
isRefreshed.value = true
}
}
// 重新加载数据(可由 PageRender 或 Vue component 触发)
function handleAction() {
loadPluginUIData()
}
onMounted(() => {
loadPluginPage()
loadPluginUIData()
})
</script>
<template>
@@ -46,7 +102,15 @@ onMounted(() => {
<VDialogCloseBtn @click="emit('close')" />
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
<VCardText v-else class="min-h-40">
<PageRender @action="loadPluginPage" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
<!-- Vuetify 渲染模式 -->
<div v-if="renderMode === 'vuetify'">
<PageRender @action="handleAction" v-for="(item, index) in pluginPageItems" :key="index" :config="item" />
<div v-if="!pluginPageItems || pluginPageItems.length === 0">此插件没有详情页面</div>
</div>
<!-- Vue 渲染模式 -->
<div v-else-if="renderMode === 'vue' && dynamicComponent">
<component :is="dynamicComponent" @action="handleAction" />
</div>
</VCardText>
<VFab
icon="mdi-cog"

View File

@@ -28,6 +28,33 @@ const props = defineProps({
const emit = defineEmits(['update:refreshStatus'])
// 插件UI渲染模式 ('vuetify' 或 'vue')
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
// Vue 模式:动态加载的组件
const dynamicPluginComponent = computed(() => {
// 确保 config 存在并且 component_url 也存在
if (pluginRenderMode.value === 'vue' && props.config?.component_url) {
const url = props.config.component_url
return defineAsyncComponent(() =>
import(/* @vite-ignore */ url)
.then(module => {
if (module.default) {
return module.default
} else {
console.error(`无法从 ${url} 加载默认导出的 Vue 组件`)
return { render: () => h('div', '组件加载失败: 无默认导出') }
}
})
.catch(err => {
console.error(`无法加载插件仪表盘组件: ${url}`, err)
return { render: () => h('div', '组件加载失败') }
}),
)
}
return null
})
onUnmounted(() => {
// 组件卸载时禁用刷新状态
emit('update:refreshStatus', false)
@@ -46,34 +73,49 @@ onUnmounted(() => {
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
<MediaServerLatest v-else-if="config?.id === 'latest'" />
<!-- 插件仪表板 -->
<VHover v-else-if="!isNullOrEmptyObject(props.config)">
<template #default="hover">
<!-- 无边框 -->
<div v-if="props.config?.attrs.border === false">
<VCard v-bind="hover.props">
<VCardText class="p-0">
<template v-else-if="!isNullOrEmptyObject(props.config)">
<!-- Vue 渲染模式 -->
<div v-if="pluginRenderMode === 'vue' && dynamicPluginComponent">
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" />
<!-- Vue 模式下也可以显示拖拽句柄 -->
<div class="absolute right-5 top-5">
<VIcon class="cursor-move">mdi-drag</VIcon>
</div>
</div>
<!-- Vuetify 渲染模式 -->
<VHover v-else-if="pluginRenderMode === 'vuetify'">
<template #default="hover">
<!-- 无边框 -->
<div v-if="props.config?.attrs.border === false">
<VCard v-bind="hover.props">
<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>
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
</VCardItem>
<VCardText>
<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>
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
</VCardItem>
<VCardText>
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
</VCardText>
</VCard>
</template>
</VHover>
</template>
</VHover>
<!-- 未知模式或错误 -->
<VCard v-else>
<VCardText>无法渲染插件仪表盘部件: 未知渲染模式或配置错误</VCardText>
</VCard>
</template>
</template>