mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-10 17:42:50 +08:00
在多个组件中添加渲染模式支持,优化插件配置和数据加载逻辑,增强用户体验和代码可读性
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -631,6 +631,10 @@ export interface DashboardItem {
|
||||
cols: { [key: string]: number }
|
||||
// 页面元素
|
||||
elements: RenderProps[]
|
||||
// 渲染方式
|
||||
render_mode: string
|
||||
// 组件地址
|
||||
component_url: string
|
||||
}
|
||||
|
||||
// 种子信息
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user