mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 08:46:40 +08:00
优化 Vite 配置,移除不再使用的代理规则,更新多个组件以增强远程组件加载逻辑,添加错误处理和加载状态显示,提升用户体验。
This commit is contained in:
@@ -181,24 +181,6 @@ const props = defineProps({
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 输出文件
|
|
||||||
|
|
||||||
构建后会在`dist`目录生成以下核心文件:
|
|
||||||
|
|
||||||
- `remoteEntry.js` - 模块联邦入口文件
|
|
||||||
- `Page.js` - 详情页面组件
|
|
||||||
- `Config.js` - 配置页面组件
|
|
||||||
- `Dashboard.js` - 仪表板组件
|
|
||||||
|
|
||||||
### 部署要求
|
|
||||||
|
|
||||||
这些文件需要部署到后端,并通过以下URL可访问:
|
|
||||||
|
|
||||||
- `/api/v1/plugin/file/{插件ID}/dist/remoteEntry.js`
|
|
||||||
- `/api/v1/plugin/file/{插件ID}/dist/Page.js`
|
|
||||||
- `/api/v1/plugin/file/{插件ID}/dist/Config.js`
|
|
||||||
- `/api/v1/plugin/file/{插件ID}/dist/Dashboard.js`
|
|
||||||
|
|
||||||
## 7. 后端API要求
|
## 7. 后端API要求
|
||||||
|
|
||||||
### 7.1 注册远程组件API
|
### 7.1 注册远程组件API
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
federation({
|
federation({
|
||||||
name: 'my_plugin',
|
name: 'LogsClean',
|
||||||
filename: 'remoteEntry.js',
|
filename: 'remoteEntry.js',
|
||||||
exposes: {
|
exposes: {
|
||||||
'./Page': './src/components/Page.vue',
|
'./Page': './src/components/Page.vue',
|
||||||
'./Config': './src/components/Config.vue',
|
'./Config': './src/components/Config.vue',
|
||||||
'./Dashboard': './src/components/Dashboard.vue',
|
'./Dashboard': './src/components/Dashboard.vue',
|
||||||
},
|
},
|
||||||
shared: ['vue', 'vuetify']
|
shared: ['vue', 'vuetify'],
|
||||||
|
format: 'esm'
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
@@ -25,5 +26,10 @@ export default defineConfig({
|
|||||||
port: 5001, // 使用不同于主应用的端口
|
port: 5001, // 使用不同于主应用的端口
|
||||||
cors: true, // 启用CORS
|
cors: true, // 启用CORS
|
||||||
origin: 'http://localhost:5001'
|
origin: 'http://localhost:5001'
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 5001, // 保持与server相同的端口
|
||||||
|
cors: true, // 启用CORS
|
||||||
|
origin: 'http://localhost:5001'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { useToast } from 'vue-toast-notification'
|
|||||||
import FormRender from '../render/FormRender.vue'
|
import FormRender from '../render/FormRender.vue'
|
||||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { defineAsyncComponent } from 'vue'
|
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||||
import { loadRemoteComponent, clearRemoteComponentCache, ComponentType } from '@/utils/federationLoader'
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -47,46 +46,60 @@ const isRefreshed = ref(false)
|
|||||||
// 渲染模式: 'vuetify' 或 'vue'
|
// 渲染模式: 'vuetify' 或 'vue'
|
||||||
const renderMode = ref('vuetify')
|
const renderMode = ref('vuetify')
|
||||||
|
|
||||||
// 挂载状态
|
// 远程组件加载错误
|
||||||
const componentMounted = ref(false)
|
const remoteComponentError = ref<Error | string | null>(null)
|
||||||
|
|
||||||
// Vue 模式:动态加载的组件
|
// Vue 模式:动态加载的组件
|
||||||
const dynamicComponent = defineAsyncComponent({
|
const dynamicComponent = defineAsyncComponent({
|
||||||
|
// 工厂函数
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
if (renderMode.value !== 'vue' || !props.plugin?.id) {
|
|
||||||
return { render: () => null }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 加载配置组件
|
if (!props.plugin?.id) {
|
||||||
componentMounted.value = false
|
throw new Error('插件ID不存在')
|
||||||
const component = await loadRemoteComponent(props.plugin.id, ComponentType.CONFIG)
|
|
||||||
componentMounted.value = true
|
|
||||||
|
|
||||||
if (!component) {
|
|
||||||
throw new Error('组件加载失败')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return component
|
// 动态加载远程组件
|
||||||
} catch (error: any) {
|
const module = await loadRemoteComponent(props.plugin.id, 'Config')
|
||||||
console.error(`加载插件配置组件失败: ${props.plugin.id}`, error)
|
|
||||||
|
// 返回组件
|
||||||
|
return module.default
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载远程组件失败:', error)
|
||||||
|
remoteComponentError.value = error instanceof Error ? error.message : String(error)
|
||||||
|
// 返回一个简单的错误组件
|
||||||
return {
|
return {
|
||||||
render: () => h('div', { class: 'text-error pa-4' }, `加载失败: ${error.message || '未知错误'}`),
|
template: `
|
||||||
|
<div class="pa-4">
|
||||||
|
<VAlert type="error" title="组件加载失败">
|
||||||
|
无法加载远程组件: {{ error }}
|
||||||
|
</VAlert>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
props: ['error'],
|
||||||
|
setup() {
|
||||||
|
return { error: remoteComponentError.value }
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 加载中显示的组件
|
||||||
loadingComponent: {
|
loadingComponent: {
|
||||||
render: () =>
|
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
|
||||||
h('div', { class: 'text-center pa-4' }, [
|
|
||||||
h('VProgressCircular', { indeterminate: true, class: 'mr-2' }),
|
|
||||||
'加载组件中...',
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
errorComponent: {
|
// 如果加载组件超时
|
||||||
render: () => h('div', { class: 'text-error pa-4 text-center' }, '组件加载失败'),
|
timeout: 10000,
|
||||||
},
|
// 在显示loadingComponent之前的延迟 | 默认值:200(毫秒)
|
||||||
onError: error => {
|
delay: 200,
|
||||||
console.error('加载插件组件出错', error)
|
// 定义组件是否可挂起 | 默认值:true
|
||||||
|
suspensible: false,
|
||||||
|
onError(error, retry, fail, attempts) {
|
||||||
|
if (attempts <= 3) {
|
||||||
|
// 重试3次
|
||||||
|
retry()
|
||||||
|
} else {
|
||||||
|
// 超过重试次数后不再重试
|
||||||
|
fail()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -97,11 +110,7 @@ async function loadPluginUIData() {
|
|||||||
pluginFormItems = []
|
pluginFormItems = []
|
||||||
pluginConfigForm.value = {}
|
pluginConfigForm.value = {}
|
||||||
renderMode.value = 'vuetify'
|
renderMode.value = 'vuetify'
|
||||||
|
remoteComponentError.value = null
|
||||||
// 清除组件缓存
|
|
||||||
if (props.plugin?.id) {
|
|
||||||
clearRemoteComponentCache(props.plugin.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取UI定义
|
// 获取UI定义
|
||||||
@@ -159,13 +168,6 @@ async function savePluginConf() {
|
|||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
await loadPluginUIData()
|
await loadPluginUIData()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载时清理资源
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (props.plugin?.id) {
|
|
||||||
clearRemoteComponentCache(props.plugin.id, ComponentType.CONFIG)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
||||||
@@ -182,6 +184,9 @@ onUnmounted(() => {
|
|||||||
<!-- Vue 渲染模式 -->
|
<!-- Vue 渲染模式 -->
|
||||||
<div v-else-if="renderMode === 'vue'">
|
<div v-else-if="renderMode === 'vue'">
|
||||||
<component :is="dynamicComponent" :initial-config="pluginConfigForm" @save="handleVueComponentSave" />
|
<component :is="dynamicComponent" :initial-config="pluginConfigForm" @save="handleVueComponentSave" />
|
||||||
|
<div v-if="remoteComponentError">
|
||||||
|
<v-alert type="error" title="组件加载失败"> 无法加载远程组件: {{ remoteComponentError }} </v-alert>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="pt-3">
|
||||||
|
|||||||
@@ -3,9 +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'
|
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||||
import { defineAsyncComponent } from 'vue'
|
|
||||||
import { loadRemoteComponent, clearRemoteComponentCache, ComponentType } from '@/utils/federationLoader'
|
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -22,58 +20,69 @@ 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'
|
// 渲染模式: 'vuetify' 或 'vue'
|
||||||
const renderMode = ref('vuetify')
|
const renderMode = ref('vuetify')
|
||||||
|
|
||||||
// 挂载状态
|
|
||||||
const componentMounted = ref(false)
|
|
||||||
|
|
||||||
// 插件数据页面配置项
|
// 插件数据页面配置项
|
||||||
let pluginPageItems = ref([])
|
let pluginPageItems = ref([])
|
||||||
|
|
||||||
|
// 远程组件加载错误
|
||||||
|
const remoteComponentError = ref<Error | string | null>(null)
|
||||||
|
|
||||||
// Vue 模式:动态加载的组件
|
// Vue 模式:动态加载的组件
|
||||||
const dynamicComponent = defineAsyncComponent({
|
const dynamicComponent = defineAsyncComponent({
|
||||||
|
// 工厂函数
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
if (renderMode.value !== 'vue' || !props.plugin?.id) {
|
|
||||||
return { render: () => null }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 加载页面组件
|
if (!props.plugin?.id) {
|
||||||
componentMounted.value = false
|
throw new Error('插件ID不存在')
|
||||||
const component = await loadRemoteComponent(props.plugin.id, ComponentType.PAGE)
|
|
||||||
componentMounted.value = true
|
|
||||||
|
|
||||||
if (!component) {
|
|
||||||
throw new Error('组件加载失败')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return component
|
// 动态加载远程组件
|
||||||
} catch (error: any) {
|
const module = await loadRemoteComponent(props.plugin.id, 'Page')
|
||||||
console.error(`加载插件页面组件失败: ${props.plugin.id}`, error)
|
|
||||||
|
// 返回组件
|
||||||
|
return module.default
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载远程组件失败:', error)
|
||||||
|
remoteComponentError.value = error instanceof Error ? error.message : String(error)
|
||||||
|
// 返回一个简单的错误组件
|
||||||
return {
|
return {
|
||||||
render: () => h('div', { class: 'text-error pa-4' }, `加载失败: ${error.message || '未知错误'}`),
|
template: `
|
||||||
|
<div class="pa-4">
|
||||||
|
<VAlert type="error" title="组件加载失败">
|
||||||
|
无法加载远程组件: {{ error }}
|
||||||
|
</VAlert>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
props: ['error'],
|
||||||
|
setup() {
|
||||||
|
return { error: remoteComponentError.value }
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 加载中显示的组件
|
||||||
loadingComponent: {
|
loadingComponent: {
|
||||||
render: () =>
|
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
|
||||||
h('div', { class: 'text-center pa-4' }, [
|
|
||||||
h('VProgressCircular', { indeterminate: true, class: 'mr-2' }),
|
|
||||||
'加载组件中...',
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
errorComponent: {
|
// 如果加载组件超时
|
||||||
render: () => h('div', { class: 'text-error pa-4 text-center' }, '组件加载失败'),
|
timeout: 10000,
|
||||||
},
|
// 在显示loadingComponent之前的延迟 | 默认值:200(毫秒)
|
||||||
onError: error => {
|
delay: 200,
|
||||||
console.error('加载插件组件出错', error)
|
// 定义组件是否可挂起 | 默认值:true
|
||||||
|
suspensible: false,
|
||||||
|
onError(error, retry, fail, attempts) {
|
||||||
|
if (attempts <= 3) {
|
||||||
|
// 重试3次
|
||||||
|
retry()
|
||||||
|
} else {
|
||||||
|
// 超过重试次数后不再重试
|
||||||
|
fail()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -82,11 +91,7 @@ async function loadPluginUIData() {
|
|||||||
isRefreshed.value = false
|
isRefreshed.value = false
|
||||||
pluginPageItems.value = []
|
pluginPageItems.value = []
|
||||||
renderMode.value = 'vuetify'
|
renderMode.value = 'vuetify'
|
||||||
|
remoteComponentError.value = null
|
||||||
// 清除组件缓存
|
|
||||||
if (props.plugin?.id) {
|
|
||||||
clearRemoteComponentCache(props.plugin.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get(`plugin/page/${props.plugin?.id}`)
|
const result: { [key: string]: any } = await api.get(`plugin/page/${props.plugin?.id}`)
|
||||||
@@ -114,13 +119,6 @@ function handleAction() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPluginUIData()
|
loadPluginUIData()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载时清理资源
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (props.plugin?.id) {
|
|
||||||
clearRemoteComponentCache(props.plugin.id, ComponentType.PAGE)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||||
@@ -136,6 +134,9 @@ onUnmounted(() => {
|
|||||||
<!-- Vue 渲染模式 -->
|
<!-- Vue 渲染模式 -->
|
||||||
<div v-else-if="renderMode === 'vue'">
|
<div v-else-if="renderMode === 'vue'">
|
||||||
<component :is="dynamicComponent" @action="handleAction" />
|
<component :is="dynamicComponent" @action="handleAction" />
|
||||||
|
<div v-if="remoteComponentError">
|
||||||
|
<v-alert type="error" title="组件加载失败"> 无法加载远程组件: {{ remoteComponentError }} </v-alert>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VFab
|
<VFab
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, defineAsyncComponent } from 'vue'
|
||||||
import { DashboardItem } from '@/api/types'
|
import { DashboardItem } from '@/api/types'
|
||||||
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
|
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
|
||||||
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
|
import AnalyticsScheduler from '@/views/dashboard/AnalyticsScheduler.vue'
|
||||||
@@ -12,8 +13,7 @@ import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
|
|||||||
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
|
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
|
||||||
import DashboardRender from '@/components/render/DashboardRender.vue'
|
import DashboardRender from '@/components/render/DashboardRender.vue'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { defineAsyncComponent } from 'vue'
|
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||||
import { loadRemoteComponent, clearRemoteComponentCache, ComponentType } from '@/utils/federationLoader'
|
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -33,57 +33,66 @@ const emit = defineEmits(['update:refreshStatus'])
|
|||||||
// 插件UI渲染模式 ('vuetify' 或 'vue')
|
// 插件UI渲染模式 ('vuetify' 或 'vue')
|
||||||
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
|
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
|
||||||
|
|
||||||
// 挂载状态
|
// 远程组件加载错误
|
||||||
const componentMounted = ref(false)
|
const remoteComponentError = ref<Error | string | null>(null)
|
||||||
|
|
||||||
// Vue 模式:动态加载的组件
|
// Vue 模式:动态加载的组件
|
||||||
const dynamicPluginComponent = defineAsyncComponent({
|
const dynamicPluginComponent = defineAsyncComponent({
|
||||||
|
// 工厂函数
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
if (pluginRenderMode.value !== 'vue' || !props.config?.id) {
|
|
||||||
return { render: () => null }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 加载仪表板组件
|
if (!props.config?.id) {
|
||||||
componentMounted.value = false
|
throw new Error('插件ID不存在')
|
||||||
const component = await loadRemoteComponent(props.config.id, ComponentType.DASHBOARD)
|
|
||||||
componentMounted.value = true
|
|
||||||
|
|
||||||
if (!component) {
|
|
||||||
throw new Error('组件加载失败')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return component
|
// 动态加载远程组件
|
||||||
} catch (error: any) {
|
const module = await loadRemoteComponent(props.config.id, 'Dashboard')
|
||||||
console.error(`加载插件仪表板组件失败: ${props.config.id}`, error)
|
|
||||||
|
// 返回组件
|
||||||
|
return module.default
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载远程组件失败:', error)
|
||||||
|
remoteComponentError.value = error instanceof Error ? error.message : String(error)
|
||||||
|
// 返回一个简单的错误组件
|
||||||
return {
|
return {
|
||||||
render: () => h('div', { class: 'text-error pa-4' }, `加载失败: ${error.message || '未知错误'}`),
|
template: `
|
||||||
|
<div class="pa-4">
|
||||||
|
<VAlert type="error" title="组件加载失败">
|
||||||
|
无法加载远程组件: {{ error }}
|
||||||
|
</VAlert>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
props: ['error'],
|
||||||
|
setup() {
|
||||||
|
return { error: remoteComponentError.value }
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 加载中显示的组件
|
||||||
loadingComponent: {
|
loadingComponent: {
|
||||||
render: () =>
|
template: '<VSkeletonLoader type="card"></VSkeletonLoader>',
|
||||||
h('div', { class: 'text-center pa-4' }, [
|
|
||||||
h('VProgressCircular', { indeterminate: true, class: 'mr-2' }),
|
|
||||||
'加载组件中...',
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
errorComponent: {
|
// 如果加载组件超时
|
||||||
render: () => h('div', { class: 'text-error pa-4 text-center' }, '组件加载失败'),
|
timeout: 10000,
|
||||||
},
|
// 在显示loadingComponent之前的延迟 | 默认值:200(毫秒)
|
||||||
onError: error => {
|
delay: 200,
|
||||||
console.error('加载插件组件出错', error)
|
// 定义组件是否可挂起 | 默认值:true
|
||||||
|
suspensible: false,
|
||||||
|
onError(error, retry, fail, attempts) {
|
||||||
|
if (attempts <= 3) {
|
||||||
|
// 重试3次
|
||||||
|
retry()
|
||||||
|
} else {
|
||||||
|
// 超过重试次数后不再重试
|
||||||
|
fail()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// 组件卸载时禁用刷新状态
|
// 组件卸载时禁用刷新状态
|
||||||
emit('update:refreshStatus', false)
|
emit('update:refreshStatus', false)
|
||||||
|
|
||||||
// 清理远程组件缓存
|
|
||||||
if (props.config?.id) {
|
|
||||||
clearRemoteComponentCache(props.config.id, ComponentType.DASHBOARD)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
@@ -103,6 +112,9 @@ onUnmounted(() => {
|
|||||||
<!-- Vue 渲染模式 -->
|
<!-- Vue 渲染模式 -->
|
||||||
<div v-if="pluginRenderMode === 'vue'">
|
<div v-if="pluginRenderMode === 'vue'">
|
||||||
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" />
|
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" />
|
||||||
|
<div v-if="remoteComponentError" class="mt-2">
|
||||||
|
<VAlert type="error" title="组件加载失败"> 无法加载远程组件: {{ remoteComponentError }} </VAlert>
|
||||||
|
</div>
|
||||||
<!-- Vue 模式下也可以显示拖拽句柄 -->
|
<!-- Vue 模式下也可以显示拖拽句柄 -->
|
||||||
<div class="absolute right-5 top-5">
|
<div class="absolute right-5 top-5">
|
||||||
<VIcon class="cursor-move">mdi-drag</VIcon>
|
<VIcon class="cursor-move">mdi-drag</VIcon>
|
||||||
|
|||||||
+67
-300
@@ -1,333 +1,100 @@
|
|||||||
/**
|
|
||||||
* 动态模块联邦加载器
|
|
||||||
* 支持运行时动态注册和加载远程模块联邦组件
|
|
||||||
*/
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Component } from 'vue'
|
|
||||||
|
|
||||||
// 远程组件配置接口
|
// 为Window接口扩展__FEDERATION__属性
|
||||||
export interface RemoteComponentConfig {
|
|
||||||
id: string
|
|
||||||
url?: string
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件类型
|
|
||||||
export enum ComponentType {
|
|
||||||
PAGE = 'page',
|
|
||||||
CONFIG = 'config',
|
|
||||||
DASHBOARD = 'dashboard',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 远程组件映射
|
|
||||||
interface RemoteModuleMap {
|
|
||||||
[pluginId: string]: {
|
|
||||||
[componentType in ComponentType]?: {
|
|
||||||
loaded: boolean
|
|
||||||
loading: boolean
|
|
||||||
error: Error | null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 已加载的远程组件状态
|
|
||||||
const remoteModules: RemoteModuleMap = {}
|
|
||||||
|
|
||||||
// 全局联邦容器
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__FEDERATION__: Record<string, any>
|
__FEDERATION__: Record<string, { url: string }>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保全局联邦对象存在
|
// 定义远程模块接口
|
||||||
if (!window.__FEDERATION__) {
|
interface RemoteModule {
|
||||||
window.__FEDERATION__ = {}
|
id: string
|
||||||
|
url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载远程模块的入口文件
|
* 从API获取远程模块列表
|
||||||
* @param url 远程模块URL
|
|
||||||
* @returns Promise<void>
|
|
||||||
*/
|
*/
|
||||||
async function loadRemoteEntry(url: string): Promise<void> {
|
async function fetchRemoteModules(): Promise<RemoteModule[]> {
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
// 创建script标签
|
const response = await api.get('plugin/remotes?token=moviepilot')
|
||||||
const script = document.createElement('script')
|
return (response as any) || []
|
||||||
script.src = `${import.meta.env.VITE_API_BASE_URL}${url}`
|
} catch (error) {
|
||||||
script.type = 'text/javascript'
|
console.error('获取远程模块列表失败:', error)
|
||||||
script.async = true
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载成功
|
/**
|
||||||
script.onload = () => {
|
* 动态注入Federation Remote模块
|
||||||
resolve()
|
* @param modules 远程模块列表
|
||||||
}
|
*/
|
||||||
|
function injectRemoteModules(modules: RemoteModule[]): void {
|
||||||
|
if (!modules || modules.length === 0) return
|
||||||
|
|
||||||
// 加载失败
|
// 创建Federation变量
|
||||||
script.onerror = error => {
|
const federation: Record<string, { url: string }> = {}
|
||||||
console.error(`远程模块加载失败: ${url}`, error)
|
|
||||||
reject(new Error(`远程模块加载失败: ${url}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加到文档
|
// 设置每个远程模块
|
||||||
document.head.appendChild(script)
|
modules.forEach(module => {
|
||||||
|
federation[module.id] = { url: module.url }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 全局注入Federation变量供加载远程模块使用
|
||||||
|
window.__FEDERATION__ = federation
|
||||||
|
|
||||||
|
console.log('已注入远程模块:', federation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取模块联邦远程URL的标准化地址
|
* 加载远程组件
|
||||||
* @param pluginId 插件ID
|
* @param id 远程模块ID
|
||||||
* @param url 组件URL (可选)
|
* @param componentName 组件名称 (如 'Page')
|
||||||
* @returns 完整的远程组件URL
|
|
||||||
*/
|
*/
|
||||||
function getRemoteEntryUrl(pluginId: string, url?: string): string {
|
export async function loadRemoteComponent(id: string, componentName: string = 'Page') {
|
||||||
// 如果提供了完整URL则直接使用
|
|
||||||
if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'))) {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
// 否则使用默认路径格式
|
|
||||||
return `/api/v1/plugin/file/${pluginId.toLowerCase()}/dist/remoteEntry.js`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取组件类型对应的模块名称
|
|
||||||
* @param componentType 组件类型
|
|
||||||
* @returns 模块名称
|
|
||||||
*/
|
|
||||||
function getComponentModule(componentType: ComponentType): string {
|
|
||||||
switch (componentType) {
|
|
||||||
case ComponentType.PAGE:
|
|
||||||
return './Page'
|
|
||||||
case ComponentType.CONFIG:
|
|
||||||
return './Config'
|
|
||||||
case ComponentType.DASHBOARD:
|
|
||||||
return './Dashboard'
|
|
||||||
default:
|
|
||||||
return './Component'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从后端API获取远程组件列表并加载
|
|
||||||
* @returns Promise<boolean> 是否加载成功
|
|
||||||
*/
|
|
||||||
export async function loadRemoteComponents(): Promise<boolean> {
|
|
||||||
try {
|
try {
|
||||||
// 调用后端API获取远程组件列表
|
// 检查远程模块是否已经注册
|
||||||
const result = await api.get('plugin/remotes?token=moviepilot')
|
if (!window.__FEDERATION__ || !window.__FEDERATION__[id]) {
|
||||||
|
// 如果未注册,尝试重新初始化
|
||||||
|
const modules = await fetchRemoteModules()
|
||||||
|
injectRemoteModules(modules)
|
||||||
|
|
||||||
if (!result || !Array.isArray(result)) {
|
// 再次检查
|
||||||
console.error('获取远程组件列表失败:无效的响应格式')
|
if (!window.__FEDERATION__ || !window.__FEDERATION__[id]) {
|
||||||
return false
|
throw new Error(`远程模块 ${id} 未注册`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载所有远程组件
|
// 模块联邦格式导入,不需要.vue扩展名
|
||||||
const remotes = result as RemoteComponentConfig[]
|
console.log(`正在加载远程组件: ${id}/${componentName}`)
|
||||||
const loadPromises = remotes.map(remote => {
|
// @ts-ignore - 动态导入远程模块
|
||||||
if (remote.id) {
|
return await import(`${id}/${componentName}`)
|
||||||
return registerRemotePlugin(remote.id, remote.url).catch(err => {
|
|
||||||
console.error(`注册插件失败: ${remote.id}`, err)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return Promise.resolve(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.allSettled(loadPromises)
|
|
||||||
console.log(`已加载远程组件列表, 成功加载: ${loadPromises.length} 个`)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载远程组件列表失败', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册远程插件
|
|
||||||
* @param pluginId 插件ID
|
|
||||||
* @param url 远程URL (可选)
|
|
||||||
* @returns Promise<boolean> 是否注册成功
|
|
||||||
*/
|
|
||||||
export async function registerRemotePlugin(pluginId: string, url?: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
// 生成远程插件的标识符
|
|
||||||
const remoteId = `plugin_${pluginId.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
|
||||||
|
|
||||||
// 获取完整的远程入口URL
|
|
||||||
const remoteEntryUrl = getRemoteEntryUrl(pluginId, url)
|
|
||||||
|
|
||||||
// 加载远程入口
|
|
||||||
await loadRemoteEntry(remoteEntryUrl)
|
|
||||||
|
|
||||||
// 初始化插件状态
|
|
||||||
if (!remoteModules[pluginId]) {
|
|
||||||
remoteModules[pluginId] = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注册到全局联邦对象 (如果尚未注册)
|
|
||||||
if (!window.__FEDERATION__[remoteId]) {
|
|
||||||
// 在这里我们假设远程模块已经挂载到全局作用域
|
|
||||||
// 真实环境中这部分可能需要根据模块联邦的实际工作方式调整
|
|
||||||
window.__FEDERATION__[remoteId] = {
|
|
||||||
get: (module: string) => {
|
|
||||||
// 动态导入远程模块
|
|
||||||
return async () => {
|
|
||||||
// 这里可能需要根据实际情况调整
|
|
||||||
const moduleName = module.replace('./', '')
|
|
||||||
try {
|
|
||||||
// 理论上这里应该使用模块联邦的import机制
|
|
||||||
// 但由于我们是运行时加载,需要用一种变通方式
|
|
||||||
const moduleUrl = `${remoteEntryUrl.replace('remoteEntry.js', '')}${moduleName}.js`
|
|
||||||
// 使用动态导入
|
|
||||||
const moduleExports = await import(/* @vite-ignore */ moduleUrl)
|
|
||||||
return { default: moduleExports.default }
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`加载远程模块失败: ${remoteId}/${moduleName}`, error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`已注册远程插件: ${pluginId} (${remoteId})`)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`注册远程插件失败: ${pluginId}`, error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 异步加载远程组件
|
|
||||||
* @param pluginId 插件ID
|
|
||||||
* @param componentType 组件类型
|
|
||||||
* @returns Promise<Component> 组件
|
|
||||||
*/
|
|
||||||
export async function loadRemoteComponent(pluginId: string, componentType: ComponentType): Promise<Component | null> {
|
|
||||||
try {
|
|
||||||
// 检查插件是否已初始化
|
|
||||||
if (!remoteModules[pluginId]) {
|
|
||||||
remoteModules[pluginId] = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查组件状态
|
|
||||||
const componentState = remoteModules[pluginId][componentType]
|
|
||||||
|
|
||||||
// 如果已经在加载中,等待加载完成
|
|
||||||
if (componentState?.loading) {
|
|
||||||
while (componentState.loading && !componentState.loaded) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100))
|
|
||||||
}
|
|
||||||
if (componentState.error) {
|
|
||||||
throw componentState.error
|
|
||||||
}
|
|
||||||
return await loadRemoteComponentModule(pluginId, componentType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标记为正在加载
|
|
||||||
remoteModules[pluginId][componentType] = {
|
|
||||||
loading: true,
|
|
||||||
loaded: false,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载组件
|
|
||||||
const component = await loadRemoteComponentModule(pluginId, componentType)
|
|
||||||
|
|
||||||
// 更新状态
|
|
||||||
remoteModules[pluginId][componentType] = {
|
|
||||||
loading: false,
|
|
||||||
loaded: true,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
return component
|
|
||||||
} catch (error: any) {
|
|
||||||
// 更新错误状态
|
|
||||||
if (remoteModules[pluginId]?.[componentType]) {
|
|
||||||
remoteModules[pluginId][componentType] = {
|
|
||||||
loading: false,
|
|
||||||
loaded: false,
|
|
||||||
error: error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从远程模块加载特定组件
|
|
||||||
* @param pluginId 插件ID
|
|
||||||
* @param componentType 组件类型
|
|
||||||
* @returns Promise<Component> 组件
|
|
||||||
*/
|
|
||||||
async function loadRemoteComponentModule(pluginId: string, componentType: ComponentType): Promise<Component | null> {
|
|
||||||
// 生成远程插件的标识符
|
|
||||||
const remoteId = `plugin_${pluginId.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
|
||||||
|
|
||||||
// 获取组件模块名称
|
|
||||||
const moduleName = getComponentModule(componentType)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 通过模块联邦获取组件
|
|
||||||
const factory = window.__FEDERATION__[remoteId].get(moduleName)
|
|
||||||
const Module = await factory()
|
|
||||||
|
|
||||||
// 返回组件
|
|
||||||
return Module.default
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(`加载远程组件失败: ${id}/${componentName}`, error)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查远程组件是否已加载
|
* 初始化并加载所有远程组件
|
||||||
* @param pluginId 插件ID
|
|
||||||
* @param componentType 组件类型
|
|
||||||
* @returns boolean 是否已加载
|
|
||||||
*/
|
*/
|
||||||
export function isRemoteComponentLoaded(pluginId: string, componentType: ComponentType): boolean {
|
export async function loadRemoteComponents(): Promise<boolean> {
|
||||||
return !!remoteModules[pluginId]?.[componentType]?.loaded
|
try {
|
||||||
}
|
// 获取远程模块列表
|
||||||
|
const modules = await fetchRemoteModules()
|
||||||
|
|
||||||
/**
|
// 确保有模块才注入
|
||||||
* 获取远程组件加载错误
|
if (modules && modules.length > 0) {
|
||||||
* @param pluginId 插件ID
|
// 注入远程模块
|
||||||
* @param componentType 组件类型
|
injectRemoteModules(modules)
|
||||||
* @returns Error | null 错误
|
return true
|
||||||
*/
|
} else {
|
||||||
export function getRemoteComponentError(pluginId: string, componentType: ComponentType): Error | null {
|
console.log('没有发现可用的远程模块')
|
||||||
return remoteModules[pluginId]?.[componentType]?.error || null
|
return false
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
/**
|
console.error('加载远程组件失败:', error)
|
||||||
* 清除远程组件缓存
|
return false
|
||||||
* @param pluginId 插件ID (可选,不提供则清除所有)
|
|
||||||
* @param componentType 组件类型 (可选,不提供则清除插件的所有组件)
|
|
||||||
*/
|
|
||||||
export function clearRemoteComponentCache(pluginId?: string, componentType?: ComponentType): void {
|
|
||||||
if (!pluginId) {
|
|
||||||
// 清除所有缓存
|
|
||||||
Object.keys(remoteModules).forEach(id => {
|
|
||||||
delete remoteModules[id]
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!remoteModules[pluginId]) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!componentType) {
|
|
||||||
// 清除插件的所有组件
|
|
||||||
delete remoteModules[pluginId]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除特定组件
|
|
||||||
if (remoteModules[pluginId][componentType]) {
|
|
||||||
delete remoteModules[pluginId][componentType]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,12 +199,6 @@ export default defineConfig({
|
|||||||
secure: false,
|
secure: false,
|
||||||
cookieDomainRewrite: 'localhost',
|
cookieDomainRewrite: 'localhost',
|
||||||
},
|
},
|
||||||
'/plugin_static': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
rewrite: path => path.replace(/^\/plugin_static/, '/api/v1/plugin/file'),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
css: {
|
css: {
|
||||||
|
|||||||
Reference in New Issue
Block a user