mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
在多个组件中添加渲染模式支持,优化插件配置和数据加载逻辑,增强用户体验和代码可读性
This commit is contained in:
@@ -7,6 +7,16 @@ const api = axios.create({
|
|||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 声明全局变量类型
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
MoviePilotAPI: typeof api
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 API 实例暴露到全局,供插件使用
|
||||||
|
window.MoviePilotAPI = api
|
||||||
|
|
||||||
// 添加请求拦截器
|
// 添加请求拦截器
|
||||||
api.interceptors.request.use(config => {
|
api.interceptors.request.use(config => {
|
||||||
// 认证 Store
|
// 认证 Store
|
||||||
|
|||||||
@@ -631,6 +631,10 @@ export interface DashboardItem {
|
|||||||
cols: { [key: string]: number }
|
cols: { [key: string]: number }
|
||||||
// 页面元素
|
// 页面元素
|
||||||
elements: RenderProps[]
|
elements: RenderProps[]
|
||||||
|
// 渲染方式
|
||||||
|
render_mode: string
|
||||||
|
// 组件地址
|
||||||
|
component_url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 种子信息
|
// 种子信息
|
||||||
|
|||||||
@@ -42,29 +42,81 @@ const $toast = useToast()
|
|||||||
// 是否刷新
|
// 是否刷新
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
// 调用API读取表单页面
|
// 渲染模式: 'vuetify' 或 'vue'
|
||||||
async function loadPluginForm() {
|
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 {
|
try {
|
||||||
|
// 获取UI定义
|
||||||
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
|
const result: { [key: string]: any } = await api.get(`plugin/form/${props.plugin?.id}`)
|
||||||
if (result) {
|
if (!result) {
|
||||||
pluginFormItems = result.conf
|
console.error(`插件 ${props.plugin?.plugin_name} UI数据加载失败:无效的响应`)
|
||||||
if (result.model) pluginConfigForm.value = result.model
|
return
|
||||||
}
|
}
|
||||||
} catch (error) {
|
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)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
isRefreshed.value = true
|
||||||
}
|
}
|
||||||
isRefreshed.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用API读取配置数据
|
// 处理 Vue 组件触发的保存事件
|
||||||
async function loadPluginConf() {
|
function handleVueComponentSave(newConfig: Record<string, any>) {
|
||||||
try {
|
pluginConfigForm.value = newConfig
|
||||||
const result: { [key: string]: any } = await api.get(`plugin/${props.plugin?.id}`)
|
savePluginConf()
|
||||||
if (!isNullOrEmptyObject(result)) pluginConfigForm.value = result
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
isRefreshed.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用API保存配置数据
|
// 调用API保存配置数据
|
||||||
@@ -75,22 +127,20 @@ async function savePluginConf() {
|
|||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
|
const result: { [key: string]: any } = await api.put(`plugin/${props.plugin?.id}`, pluginConfigForm.value)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
progressDialog.value = false
|
|
||||||
$toast.success(t('dialog.pluginConfig.saveSuccess', { name: props.plugin?.plugin_name }))
|
$toast.success(t('dialog.pluginConfig.saveSuccess', { name: props.plugin?.plugin_name }))
|
||||||
// 通知父组件刷新
|
// 通知父组件刷新
|
||||||
emit('save')
|
emit('save')
|
||||||
} else {
|
} else {
|
||||||
progressDialog.value = false
|
|
||||||
$toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
$toast.error(t('dialog.pluginConfig.saveFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
|
progressDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
await loadPluginForm()
|
await loadPluginUIData()
|
||||||
await loadPluginConf()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
@@ -99,15 +149,32 @@ onBeforeMount(async () => {
|
|||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardText v-if="isRefreshed">
|
<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>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="pt-3">
|
||||||
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info">
|
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" variant="outlined" color="info">
|
||||||
{{ t('dialog.pluginConfig.viewData') }}
|
{{ t('dialog.pluginConfig.viewData') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn @click="savePluginConf" variant="elevated" prepend-icon="mdi-content-save" class="px-5">
|
<!-- 只有Vuetify模式显示默认保存按钮,Vue模式由组件内部控制 -->
|
||||||
{{ t('dialog.pluginConfig.save') }}
|
<VBtn
|
||||||
|
v-if="renderMode === 'vuetify'"
|
||||||
|
@click="savePluginConf"
|
||||||
|
variant="elevated"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
class="px-5"
|
||||||
|
>
|
||||||
|
保存
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useDisplay } from 'vuetify'
|
|||||||
import type { Plugin } from '@/api/types'
|
import type { Plugin } from '@/api/types'
|
||||||
import PageRender from '@/components/render/PageRender.vue'
|
import PageRender from '@/components/render/PageRender.vue'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import { useToast } from 'vue-toast-notification'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -19,25 +20,80 @@ const display = useDisplay()
|
|||||||
// APP
|
// APP
|
||||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||||
|
|
||||||
|
// 提示框
|
||||||
|
const $toast = useToast()
|
||||||
|
|
||||||
// 是否刷新
|
// 是否刷新
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
// 渲染模式: 'vuetify' 或 'vue'
|
||||||
|
const renderMode = ref('vuetify')
|
||||||
|
|
||||||
|
// Vue 模式:组件 URL
|
||||||
|
const vueComponentUrl = ref<string | null>(null)
|
||||||
|
|
||||||
// 插件数据页面配置项
|
// 插件数据页面配置项
|
||||||
let pluginPageItems = ref([])
|
let pluginPageItems = ref([])
|
||||||
|
|
||||||
// 调用API读取数据页面
|
// Vue 模式:动态加载的组件
|
||||||
async function loadPluginPage() {
|
const dynamicComponent = computed(() => {
|
||||||
try {
|
if (renderMode.value === 'vue' && vueComponentUrl.value) {
|
||||||
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
|
const url = vueComponentUrl.value
|
||||||
if (result) pluginPageItems.value = result
|
return defineAsyncComponent(() =>
|
||||||
} catch (error) {
|
import(/* @vite-ignore */ url)
|
||||||
console.error(error)
|
.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(() => {
|
onMounted(() => {
|
||||||
loadPluginPage()
|
loadPluginUIData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
@@ -46,7 +102,15 @@ onMounted(() => {
|
|||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||||
<VCardText v-else class="min-h-40">
|
<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>
|
</VCardText>
|
||||||
<VFab
|
<VFab
|
||||||
icon="mdi-cog"
|
icon="mdi-cog"
|
||||||
|
|||||||
@@ -28,6 +28,33 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:refreshStatus'])
|
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(() => {
|
onUnmounted(() => {
|
||||||
// 组件卸载时禁用刷新状态
|
// 组件卸载时禁用刷新状态
|
||||||
emit('update:refreshStatus', false)
|
emit('update:refreshStatus', false)
|
||||||
@@ -46,34 +73,49 @@ onUnmounted(() => {
|
|||||||
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
|
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
|
||||||
<MediaServerLatest v-else-if="config?.id === 'latest'" />
|
<MediaServerLatest v-else-if="config?.id === 'latest'" />
|
||||||
<!-- 插件仪表板 -->
|
<!-- 插件仪表板 -->
|
||||||
<VHover v-else-if="!isNullOrEmptyObject(props.config)">
|
<template v-else-if="!isNullOrEmptyObject(props.config)">
|
||||||
<template #default="hover">
|
<!-- Vue 渲染模式 -->
|
||||||
<!-- 无边框 -->
|
<div v-if="pluginRenderMode === 'vue' && dynamicPluginComponent">
|
||||||
<div v-if="props.config?.attrs.border === false">
|
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" />
|
||||||
<VCard v-bind="hover.props">
|
<!-- Vue 模式下也可以显示拖拽句柄 -->
|
||||||
<VCardText class="p-0">
|
<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" />
|
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<div v-if="hover.isHovering" class="absolute right-5 top-5">
|
|
||||||
<VIcon class="cursor-move">mdi-drag</VIcon>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</template>
|
||||||
<!-- 有边框 -->
|
</VHover>
|
||||||
<VCard v-else v-bind="hover.props">
|
<!-- 未知模式或错误 -->
|
||||||
<VCardItem v-if="props.config?.attrs.border !== false">
|
<VCard v-else>
|
||||||
<template #append>
|
<VCardText>无法渲染插件仪表盘部件: 未知渲染模式或配置错误</VCardText>
|
||||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
</VCard>
|
||||||
</template>
|
</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>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user