增强模块联邦支持,添加动态导入远程模块的声明,更新示例项目以展示新组件结构和配置,调整 Vite 配置以支持更灵活的远程组件加载。

This commit is contained in:
jxxghp
2025-05-06 08:53:33 +08:00
parent 643ca35aed
commit d349d2b500
19 changed files with 1834 additions and 459 deletions

View File

@@ -17,10 +17,10 @@ MoviePilot 现已支持模块联邦Module Federation功能允许插件
### 相关文档
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
## 开发部署
### 推荐的IDE设置

View File

@@ -0,0 +1,309 @@
# MoviePilot前端远程模块开发指南
## 1. 概述
MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态加载和集成。本文档详细说明如何开发符合要求的远程模块以便在MoviePilot中作为插件使用。
## 2. 技术要求
- Node.js 16+
- Vue 3
- Vite 4+
- TypeScript 5+
## 3. 核心概念
每个插件需要提供三个标准组件:
| 组件名称 | 文件名 | 用途 |
|---------|-------|------|
| Page | Page.js | 插件详情页面 |
| Config | Config.js | 插件配置页面 |
| Dashboard | Dashboard.js | 仪表板组件 |
## 4. 快速开始
### 创建项目
```bash
# 创建项目
npm create vite@latest my-plugin -- --template vue-ts
# 进入项目目录
cd my-plugin
# 安装依赖
npm install
# 安装模块联邦插件
npm install @originjs/vite-plugin-federation --save-dev
# 安装Vuetify(可选)
npm install vuetify
```
### 配置vite.config.ts
```typescript
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'my_plugin', // 插件名称建议与插件ID保持一致
filename: 'remoteEntry.js',
exposes: {
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
},
shared: {
vue: { requiredVersion: false },
vuetify: { requiredVersion: false }
}
})
],
build: {
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: false,
rollupOptions: {
output: {
format: 'esm', // 必须使用ESM格式
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
}
}
}
})
```
## 5. 组件开发规范
### 5.1 Page组件详情页面
```vue
<script setup lang="ts">
// 自定义事件,用于通知主应用刷新数据
const emit = defineEmits(['action'])
// 页面逻辑代码...
// 通知主应用刷新数据
function notifyRefresh() {
emit('action')
}
</script>
<template>
<div class="plugin-page">
<!-- 插件详情页面内容 -->
<v-btn @click="notifyRefresh">刷新数据</v-btn>
</div>
</template>
```
### 5.2 Config组件配置页面
```vue
<script setup lang="ts">
// 接收初始配置
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({})
}
})
// 配置数据
const config = ref({...props.initialConfig})
// 自定义事件,用于保存配置
const emit = defineEmits(['save'])
// 保存配置
function saveConfig() {
emit('save', config.value)
}
</script>
<template>
<div class="plugin-config">
<!-- 配置表单 -->
<v-text-field v-model="config.someField" label="配置项"></v-text-field>
<!-- 保存按钮 -->
<v-btn color="primary" @click="saveConfig">保存配置</v-btn>
</div>
</template>
```
### 5.3 Dashboard组件仪表板
```vue
<script setup lang="ts">
// 接收配置和刷新控制
const props = defineProps({
config: {
type: Object,
default: () => ({})
},
allowRefresh: {
type: Boolean,
default: true
}
})
// 仪表板逻辑...
</script>
<template>
<div class="dashboard-widget">
<!-- 仪表板内容 -->
<v-card>
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
<v-card-text>
<!-- 组件内容 -->
</v-card-text>
</v-card>
</div>
</template>
```
## 6. 构建和部署
### 构建项目
```bash
npm run build
```
### 输出文件
构建后会在`dist`目录生成以下核心文件:
- `remoteEntry.js` - 模块联邦入口文件
- `Page.js` - 详情页面组件
- `Config.js` - 配置页面组件
- `Dashboard.js` - 仪表板组件
### 部署要求
这些文件需要部署到后端并通过以下URL可访问
- `/api/plugin/component/{插件ID}/remoteEntry.js`
- `/api/plugin/component/{插件ID}/Page.js`
- `/api/plugin/component/{插件ID}/Config.js`
- `/api/plugin/component/{插件ID}/Dashboard.js`
## 7. 后端API要求
### 7.1 注册远程组件API
后端需要实现以下API用于注册远程组件已公共实现插件按第三方插件开发要求实现`get_form_file``get_page_file``get_dashboard_file`即可):
```
GET /api/plugins/remotes
```
返回结构:
```json
[
{
"id": "my-plugin", // 插件ID必需
"url": "/custom/path/to/plugin" // 自定义组件路径,可选
},
{
"id": "another-plugin" // 使用默认路径
}
]
```
### 7.2 组件访问路径
指定了`url`后使用:
- `{url}/remoteEntry.js`
- `{url}/Page.js`
- `{url}/Config.js`
- `{url}/Dashboard.js`
## 8. 调试与排错
### 常见问题
1. **模块无法加载**
- 检查网络请求是否成功状态码200
- 确认文件路径是否正确
- 检查CORS跨域设置
2. **模块加载但组件不显示**
- 检查控制台错误信息
- 确认组件是否正确导出
- 验证共享依赖配置
3. **"Module name 'vue' does not resolve to a valid URL"**
- 检查`shared`配置是否正确
- 设置`requiredVersion: false`尝试解决
4. **"Top-level await is not available"**
- 确保`build.target`设置为`esnext`
## 9. 高级配置
### 9.1 CSS隔离
为防止样式冲突建议使用CSS Modules或scoped样式
```vue
<style scoped>
/* 组件样式 */
</style>
```
### 9.2 共享更多依赖
如果您的插件需要共享更多依赖可以扩展shared配置
```js
shared: {
vue: { requiredVersion: false },
vuetify: { requiredVersion: false },
'@vueuse/core': { requiredVersion: false },
pinia: { requiredVersion: false }
}
```
### 9.3 开发环境测试
开发期间可以使用以下配置在本地测试:
```typescript
// vite.config.ts
export default defineConfig({
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
}
})
```
## 10. 示例代码
完整的示例插件代码可在以下仓库获取:
[https://github.com/example/moviepilot-plugin-example](https://github.com/example/moviepilot-plugin-example) (示例链接,实际链接需替换)
## 11. 参考资料
- [Module Federation官方文档](https://webpack.js.org/concepts/module-federation/)
- [Vite Plugin Federation](https://github.com/originjs/vite-plugin-federation)
- [Vue 3官方文档](https://vuejs.org/)
---
如有问题请提交Issue。

7
env.d.ts vendored
View File

@@ -8,3 +8,10 @@ declare module 'vue-router' {
navActiveLink?: RouteLocationRaw
}
}
// 支持动态导入远程模块
declare module '*' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -1,24 +1,42 @@
# MoviePilot 插件远程组件示例
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例实现了三个标准组件Page详情页面、Config配置页面和Dashboard仪表板组件
## 开发环境准备
## 1. 开发环境准备
### 安装依赖
1. 安装依赖:
```bash
npm install
# 或
yarn
```
2. 开发模式运行
### 开发模式运行
```bash
npm run dev
# 或
yarn dev
```
## 配置说明
## 2. 项目结构
```
plugin-component/
├── src/
│ ├── components/
│ │ ├── Page.vue # 插件详情页面组件
│ │ ├── Config.vue # 插件配置页面组件
│ │ └── Dashboard.vue # 插件仪表板组件
│ ├── App.vue # 本地开发入口组件
│ └── main.js # 本地开发入口文件
├── vite.config.js # Vite和模块联邦配置
├── index.html # 本地开发HTML入口
└── package.json # 依赖配置
```
## 3. 配置说明
### vite.config.js
@@ -31,82 +49,113 @@ export default defineConfig({
plugins: [
vue(),
federation({
name: 'remoteApp',
name: 'my_plugin', // 插件名称建议与插件ID保持一致
filename: 'remoteEntry.js',
exposes: {
'./PluginComponent': './src/App.vue', // 暴露组件
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
},
shared: {
vue: {
singleton: true,
requiredVersion: false
}
vue: { requiredVersion: false },
vuetify: { requiredVersion: false }
}
})
],
build: {
target: 'esnext', // 支持顶层await
minify: false,
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: false,
rollupOptions: {
output: {
minifyInternalExports: false
format: 'esm', // 必须使用ESM格式
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
}
}
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
}
})
```
### 组件开发
## 4. 组件规范
主组件 (src/App.vue) 需要遵循以下规则:
- 使用 Vue 3 组合式 API
- 注册 `action` 事件用于与主应用通信
- 避免直接使用 Vue Router 等全局依赖
### Page.vue详情页面
示例组件结构
详情页面用于展示插件的数据和状态
- 接收 `action` 事件用于通知主应用刷新数据
- 可以包含交互功能和数据展示
```vue
<template>
<div class="plugin-component">
<h2>{{ title }}</h2>
<div v-if="loading">加载中...</div>
<div v-else>
<!-- 组件内容 -->
<pre>{{ data }}</pre>
<button @click="refreshData">刷新</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 组件状态
const title = ref('插件示例')
const loading = ref(true)
const data = ref(null)
// 向主应用发送事件
// 自定义事件,用于通知主应用刷新数据
const emit = defineEmits(['action'])
// 刷新数据
async function refreshData() {
loading.value = true
// API 调用...
loading.value = false
// 通知主应用
// 通知主应用刷新数据
function notifyRefresh() {
emit('action')
}
</script>
```
// 初始化
onMounted(() => {
refreshData()
### Config.vue配置页面
配置页面用于接收和保存插件配置:
- 接收 `initialConfig` 属性获取初始配置
- 发出 `save` 事件保存配置数据
```vue
<script setup>
// 接收初始配置
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({})
}
})
// 自定义事件,用于保存配置
const emit = defineEmits(['save'])
// 保存配置
function saveConfig() {
emit('save', configData)
}
</script>
```
### Dashboard.vue仪表板组件
仪表板组件用于在主页上显示插件数据:
- 接收 `config` 属性获取仪表板配置
- 接收 `allowRefresh` 属性控制是否允许自动刷新
```vue
<script setup>
// 接收配置和刷新控制
const props = defineProps({
config: {
type: Object,
default: () => ({})
},
allowRefresh: {
type: Boolean,
default: true
}
})
</script>
```
## 构建生产版本
## 5. 构建和部署
### 构建生产版本
```bash
npm run build
@@ -114,11 +163,25 @@ npm run build
yarn build
```
构建后 `dist/remoteEntry.js` 是远程组件的入口文件,需要配置到后端让 MoviePilot 能够访问。
构建后 `dist` 目录生成以下关键文件:
## 插件后端配置
- `remoteEntry.js` - 模块联邦入口文件
- `Page.js` - 详情页面组件
- `Config.js` - 配置页面组件
- `Dashboard.js` - 仪表板组件
插件后端代码中,需要实现以下方法来提供组件信息:
### 部署到插件后端
将构建后的文件部署到插件后端确保可通过以下URL访问
- `/api/plugin/component/{插件ID}/remoteEntry.js`
- `/api/plugin/component/{插件ID}/Page.js`
- `/api/plugin/component/{插件ID}/Config.js`
- `/api/plugin/component/{插件ID}/Dashboard.js`
## 6. 插件后端集成
在插件的后端代码中,实现以下方法来集成远程组件:
```python
def get_render_mode() -> str:
@@ -128,41 +191,34 @@ def get_render_mode() -> str:
"""
return "vue"
def get_form_file() -> Tuple[str, Dict[str, Any]]:
def get_remote_urls() -> Dict[str, Any]:
"""
获取插件配置页面JS代码源文件与get_from二选一使用
:return: 1、编译后的JS代码插件目录下相对路径2、默认数据结构
获取远程组件地址
:return: 远程组件信息包含id和url
"""
return "/dist/page.js", {}
def get_page_file() -> Optional[str]:
"""
获取插件数据页面JS代码源文件与get_page二选一使用
:return: 编译后的JS代码插件目录下相对路径
"""
return "/dist/config.js", {}
return {
"id": "my_plugin", # 插件ID
"url": "/path/to/component" # 可选,自定义组件路径
}
```
## 排查常见问题
## 7. 常见问题排查
### 顶层 await 报错
### 模块加载问题
确保在 `vite.config.js` 中设置 `build.target``'esnext'`
如果遇到模块加载问题,请检查:
### 共享依赖加载失败
1. 确保 `build.target` 设置为 `esnext`
2. 验证共享依赖配置是否正确
3. 检查网络请求是否成功
4. 查看浏览器控制台错误信息
确保正确配置共享依赖:
```js
shared: {
vue: {
singleton: true,
requiredVersion: false // 关闭版本检查
}
}
```
### 代码调试
### 组件无法加载
在开发阶段可以:
1. 检查网络请求是否成功
2. 确认 JS 文件可以被正确访问
3. 查看浏览器控制台是否有详细错误信息
1. 使用浏览器开发者工具进行调试
2. 启用 Vite 的详细日志:`localStorage.setItem('debug', 'vite:*')`
3. 使用 `console.log` 输出调试信息
更多详细说明请参考 [模块联邦开发指南](../../docs/module-federation-guide.md) 和 [模块联邦问题排查指南](../../docs/federation-troubleshooting.md)。

View File

@@ -1,12 +1,24 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MoviePilot 插件组件示例</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MoviePilot插件组件示例</title>
<link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/@mdi/font@6.x/css/materialdesignicons.min.css" rel="stylesheet" />
<style>
body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -9,11 +9,15 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4"
"vue": "^3.3.4",
"vuetify": "^3.4.0",
"echarts": "^5.4.3",
"vue-echarts": "^6.6.1",
"@vueuse/core": "^10.6.0"
},
"devDependencies": {
"@originjs/vite-plugin-federation": "^1.3.5",
"@vitejs/plugin-vue": "^4.4.0",
"vite": "^5.0.0"
}
}
}

View File

@@ -1,93 +1,128 @@
<template>
<div class="plugin-component pa-4">
<v-card>
<v-card-title>{{ title }}</v-card-title>
<v-card-text>
<v-progress-circular v-if="loading" indeterminate color="primary"></v-progress-circular>
<div v-else>
<v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
<v-simple-table v-if="data && data.stats">
<template v-slot:default>
<thead>
<tr>
<th>类型</th>
<th>数量</th>
</tr>
</thead>
<tbody>
<tr v-for="(value, key) in data.stats" :key="key">
<td>{{ key }}</td>
<td>{{ value }}</td>
</tr>
</tbody>
</template>
</v-simple-table>
<div v-if="data">
<div><strong>状态:</strong> {{ data.status }}</div>
<div><strong>最后更新:</strong> {{ data.last_updated }}</div>
</div>
<div v-else>无数据</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="refreshData"> 刷新 </v-btn>
</v-card-actions>
</v-card>
<div class="app-container">
<v-app>
<v-app-bar color="primary" app>
<v-app-bar-title>MoviePilot插件组件示例</v-app-bar-title>
</v-app-bar>
<v-main>
<v-container>
<v-tabs v-model="activeTab" bg-color="primary">
<v-tab value="page">详情页面</v-tab>
<v-tab value="config">配置页面</v-tab>
<v-tab value="dashboard">仪表板</v-tab>
</v-tabs>
<v-window v-model="activeTab" class="mt-4">
<v-window-item value="page">
<h2 class="text-h5 mb-4">Page组件</h2>
<div class="component-preview">
<page-component @action="handleAction"></page-component>
</div>
</v-window-item>
<v-window-item value="config">
<h2 class="text-h5 mb-4">Config组件</h2>
<div class="component-preview">
<config-component :initial-config="initialConfig" @save="handleConfigSave"></config-component>
</div>
</v-window-item>
<v-window-item value="dashboard">
<h2 class="text-h5 mb-4">Dashboard组件</h2>
<v-switch v-model="dashboardConfig.attrs.border" label="显示边框" color="primary" class="mb-4"></v-switch>
<div class="component-preview">
<dashboard-component :config="dashboardConfig" :allow-refresh="true"></dashboard-component>
</div>
</v-window-item>
</v-window>
</v-container>
</v-main>
<v-footer app color="primary" class="text-center d-flex justify-center">
<span class="text-white">MoviePilot 模块联邦示例 ©{{ new Date().getFullYear() }}</span>
</v-footer>
</v-app>
<!-- 通知弹窗 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="snackbar.timeout">
{{ snackbar.text }}
<template v-slot:actions>
<v-btn variant="text" @click="snackbar.show = false"> 关闭 </v-btn>
</template>
</v-snackbar>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, reactive } from 'vue'
import PageComponent from './components/Page.vue'
import ConfigComponent from './components/Config.vue'
import DashboardComponent from './components/Dashboard.vue'
// 组件状态
const title = ref('插件数据示例')
const loading = ref(true)
const data = ref(null)
const error = ref(null)
// 活动标签页
const activeTab = ref('page')
// 向主应用发送事件
const emit = defineEmits(['action'])
// 获取和刷新数据
async function refreshData() {
loading.value = true
error.value = null
try {
// 模拟API调用 - 实际开发中应使用 fetch 调用真实API
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟数据
data.value = {
status: 'running',
stats: {
'电影': Math.floor(Math.random() * 100) + 50,
'电视剧': Math.floor(Math.random() * 100) + 30,
'动漫': Math.floor(Math.random() * 100) + 20,
'纪录片': Math.floor(Math.random() * 100) + 10,
'综艺': Math.floor(Math.random() * 100) + 5,
},
last_updated: new Date().toLocaleString(),
}
} catch (err) {
console.error('获取数据失败:', err)
error.value = err.message || '获取数据失败'
} finally {
loading.value = false
// 通知主应用组件已更新
emit('action')
}
// 配置初始值
const initialConfig = {
name: '测试插件',
description: '这是一个测试配置',
enable_notifications: true,
update_interval: 30,
api_url: 'https://api.example.com',
api_key: 'test_api_key_123',
concurrent_tasks: 2,
tags: ['电影', '测试'],
}
// 组件挂载时加载数据
onMounted(() => {
refreshData()
// 仪表板配置
const dashboardConfig = reactive({
id: 'test_plugin',
name: '测试插件',
attrs: {
title: '仪表板示例',
subtitle: '插件数据展示',
border: true,
},
})
// 通知状态
const snackbar = reactive({
show: false,
text: '',
color: 'success',
timeout: 3000,
})
// 显示通知
function showNotification(text, color = 'success') {
snackbar.text = text
snackbar.color = color
snackbar.show = true
}
// 处理详情页面操作
function handleAction() {
showNotification('Page组件触发了action事件')
}
// 处理配置保存
function handleConfigSave(config) {
console.log('配置已保存:', config)
showNotification('配置已保存')
}
</script>
<style scoped>
.plugin-component {
width: 100%;
<style>
/* 为了使测试应用更美观 */
.app-container {
block-size: 100vh;
inline-size: 100vw;
}
.component-preview {
overflow: hidden;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,208 @@
<template>
<div class="plugin-config">
<v-card>
<v-card-title>插件配置</v-card-title>
<v-card-text>
<v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
<v-form ref="form" v-model="isFormValid" @submit.prevent="saveConfig">
<!-- 基本设置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">基本设置</div>
<v-text-field
v-model="config.name"
label="插件名称"
variant="outlined"
:rules="[v => !!v || '名称不能为空']"
hint="显示在插件列表中的名称"
></v-text-field>
<v-textarea
v-model="config.description"
label="插件描述"
variant="outlined"
rows="3"
hint="简要说明插件的功能和用途"
></v-textarea>
<!-- 功能配置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">功能配置</div>
<v-switch
v-model="config.enable_notifications"
label="启用通知"
color="primary"
inset
hint="接收插件状态变更通知"
persistent-hint
></v-switch>
<v-select
v-model="config.update_interval"
label="更新频率"
:items="updateIntervalOptions"
variant="outlined"
item-title="text"
item-value="value"
></v-select>
<!-- API配置区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">API设置</div>
<v-text-field
v-model="config.api_url"
label="API地址"
variant="outlined"
hint="外部服务API地址"
:rules="[v => !v || v.startsWith('http') || '请输入有效的URL']"
></v-text-field>
<v-text-field
v-model="config.api_key"
label="API密钥"
variant="outlined"
:append-inner-icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'"
:type="showApiKey ? 'text' : 'password'"
@click:append-inner="showApiKey = !showApiKey"
></v-text-field>
<!-- 高级选项区域 -->
<div class="text-subtitle-1 font-weight-bold mt-4 mb-2">高级选项</div>
<v-expansion-panels variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title>高级选项</v-expansion-panel-title>
<v-expansion-panel-text>
<v-slider
v-model="config.concurrent_tasks"
label="并发任务数"
min="1"
max="10"
step="1"
thumb-label
></v-slider>
<v-combobox
v-model="config.tags"
label="标签"
variant="outlined"
chips
multiple
closable-chips
></v-combobox>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="secondary" variant="outlined" @click="resetForm">重置</v-btn>
<v-btn color="primary" :disabled="!isFormValid" @click="saveConfig" :loading="saving">保存配置</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
// 接收初始配置
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({}),
},
})
// 表单状态
const form = ref(null)
const isFormValid = ref(true)
const error = ref(null)
const saving = ref(false)
const showApiKey = ref(false)
// 更新频率选项
const updateIntervalOptions = [
{ text: '5分钟', value: 5 },
{ text: '15分钟', value: 15 },
{ text: '30分钟', value: 30 },
{ text: '1小时', value: 60 },
{ text: '2小时', value: 120 },
{ text: '6小时', value: 360 },
{ text: '12小时', value: 720 },
{ text: '1天', value: 1440 },
]
// 配置数据,使用默认值和初始配置合并
const defaultConfig = {
name: '我的插件',
description: '',
enable_notifications: true,
update_interval: 60,
api_url: '',
api_key: '',
concurrent_tasks: 3,
tags: [],
}
// 合并默认配置和初始配置
const config = reactive({ ...defaultConfig })
// 初始化配置
onMounted(() => {
// 加载初始配置
if (props.initialConfig) {
Object.keys(props.initialConfig).forEach(key => {
if (key in config) {
config[key] = props.initialConfig[key]
}
})
}
})
// 自定义事件,用于保存配置
const emit = defineEmits(['save'])
// 保存配置
async function saveConfig() {
if (!isFormValid.value) {
error.value = '请修正表单错误'
return
}
saving.value = true
error.value = null
try {
// 模拟API调用等待
await new Promise(resolve => setTimeout(resolve, 1000))
// 发送保存事件
emit('save', { ...config })
} catch (err) {
console.error('保存配置失败:', err)
error.value = err.message || '保存配置失败'
} finally {
saving.value = false
}
}
// 重置表单
function resetForm() {
Object.keys(defaultConfig).forEach(key => {
config[key] = defaultConfig[key]
})
if (form.value) {
form.value.resetValidation()
}
}
</script>
<style scoped>
.plugin-config {
padding: 16px;
inline-size: 100%;
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div class="dashboard-widget">
<v-card v-if="!config?.attrs?.border" flat>
<v-card-text class="pa-0">
<div class="dashboard-content">
<!-- 加载中状态 -->
<div v-if="loading" class="d-flex justify-center align-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<!-- 数据内容 -->
<div v-else>
<!-- 数据图表 -->
<div v-if="chartData" class="chart-container">
<v-chart class="chart" :option="chartOptions" autoresize />
</div>
<!-- 数据列表 -->
<v-list v-if="items.length" density="compact" class="py-0">
<v-list-item v-for="(item, index) in items" :key="index" :title="item.title" :subtitle="item.subtitle">
<template v-slot:prepend>
<v-avatar :color="getStatusColor(item.status)" size="small">
<v-icon size="small" color="white">{{ getStatusIcon(item.status) }}</v-icon>
</v-avatar>
</template>
<template v-slot:append v-if="item.value">
<span class="text-caption">{{ item.value }}</span>
</template>
</v-list-item>
</v-list>
</div>
</div>
</v-card-text>
</v-card>
<!-- 带边框的卡片 -->
<v-card v-else>
<v-card-item>
<v-card-title>{{ config?.attrs?.title || '仪表板组件' }}</v-card-title>
<v-card-subtitle v-if="config?.attrs?.subtitle">{{ config.attrs.subtitle }}</v-card-subtitle>
</v-card-item>
<v-card-text>
<!-- 加载中状态 -->
<div v-if="loading" class="d-flex justify-center align-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<!-- 数据内容 -->
<div v-else>
<!-- 数据图表 -->
<div v-if="chartData" class="chart-container">
<v-chart class="chart" :option="chartOptions" autoresize />
</div>
<!-- 数据列表 -->
<v-list v-if="items.length" density="compact" class="rounded pa-0">
<v-list-item v-for="(item, index) in items" :key="index" :title="item.title" :subtitle="item.subtitle">
<template v-slot:prepend>
<v-avatar :color="getStatusColor(item.status)" size="small">
<v-icon size="small" color="white">{{ getStatusIcon(item.status) }}</v-icon>
</v-avatar>
</template>
<template v-slot:append v-if="item.value">
<span class="text-caption">{{ item.value }}</span>
</template>
</v-list-item>
</v-list>
</div>
</v-card-text>
</v-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, PieChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'
// 注册ECharts组件
try {
use([CanvasRenderer, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent])
} catch (e) {
console.warn('ECharts components registration failed', e)
}
// 接收仪表板配置
const props = defineProps({
config: {
type: Object,
default: () => ({}),
},
allowRefresh: {
type: Boolean,
default: true,
},
})
// 组件状态
const loading = ref(true)
const items = ref([])
const chartData = ref(null)
let refreshTimer = null
// 获取状态图标
function getStatusIcon(status) {
const icons = {
'success': 'mdi-check-circle',
'warning': 'mdi-alert',
'error': 'mdi-alert-circle',
'info': 'mdi-information',
'running': 'mdi-play-circle',
'pending': 'mdi-clock-outline',
'completed': 'mdi-check-circle-outline',
}
return icons[status] || 'mdi-help-circle'
}
// 获取状态颜色
function getStatusColor(status) {
const colors = {
'success': 'success',
'warning': 'warning',
'error': 'error',
'info': 'info',
'running': 'primary',
'pending': 'secondary',
'completed': 'success',
}
return colors[status] || 'grey'
}
// 图表选项
const chartOptions = computed(() => {
if (!chartData.value) return {}
const { type, data } = chartData.value
if (type === 'line') {
return {
tooltip: {
trigger: 'axis',
},
xAxis: {
type: 'category',
data: data.xAxis,
axisLabel: {
color: '#888',
},
},
yAxis: {
type: 'value',
axisLabel: {
color: '#888',
},
},
series: data.series.map(series => ({
name: series.name,
type: 'line',
smooth: true,
data: series.data,
areaStyle: { opacity: 0.1 },
})),
}
}
if (type === 'pie') {
return {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
},
series: [
{
name: data.name,
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: false,
position: 'center',
},
emphasis: {
label: {
show: true,
fontSize: '12',
fontWeight: 'bold',
},
},
labelLine: {
show: false,
},
data: data.items,
},
],
}
}
return {}
})
// 获取仪表板数据
async function fetchDashboardData() {
if (!props.allowRefresh) return
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 随机决定显示饼图或折线图
const showPie = Math.random() > 0.5
if (showPie) {
// 饼图数据
chartData.value = {
type: 'pie',
data: {
name: '文件分布',
items: [
{ value: Math.floor(Math.random() * 50) + 30, name: '电影' },
{ value: Math.floor(Math.random() * 40) + 20, name: '电视剧' },
{ value: Math.floor(Math.random() * 30) + 10, name: '动漫' },
{ value: Math.floor(Math.random() * 20) + 5, name: '纪录片' },
],
},
}
} else {
// 折线图数据
const days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
chartData.value = {
type: 'line',
data: {
xAxis: days,
series: [
{
name: '下载量',
data: days.map(() => Math.floor(Math.random() * 10) + 1),
},
{
name: '完成量',
data: days.map(() => Math.floor(Math.random() * 8) + 1),
},
],
},
}
}
// 生成列表数据
const statuses = ['success', 'warning', 'error', 'info', 'running', 'pending', 'completed']
items.value = Array.from({ length: 5 }, (_, i) => {
const status = statuses[Math.floor(Math.random() * statuses.length)]
return {
title: `项目 ${i + 1}`,
subtitle: `上次更新: ${new Date().toLocaleTimeString()}`,
status,
value: Math.floor(Math.random() * 100) + '%',
}
})
} catch (error) {
console.error('获取仪表板数据失败:', error)
} finally {
loading.value = false
}
}
// 设置定时刷新
function setupRefreshTimer() {
if (props.allowRefresh) {
// 每30秒刷新一次
refreshTimer = setInterval(() => {
fetchDashboardData()
}, 30000)
}
}
// 初始化
onMounted(() => {
fetchDashboardData()
setupRefreshTimer()
})
// 清理
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
}
})
</script>
<style scoped>
.dashboard-widget {
inline-size: 100%;
}
.chart-container {
block-size: 200px;
margin-block-end: 16px;
}
.chart {
block-size: 100%;
inline-size: 100%;
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<div class="plugin-page">
<v-card>
<v-card-title>{{ title }}</v-card-title>
<v-card-text>
<v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
<v-skeleton-loader v-if="loading" type="card"></v-skeleton-loader>
<div v-else>
<!-- 数据统计展示 -->
<v-row v-if="stats">
<v-col v-for="(value, key) in stats" :key="key" cols="12" sm="6" md="4">
<v-card variant="outlined" class="text-center">
<v-card-text>
<div class="text-h4 font-weight-bold">{{ value }}</div>
<div class="text-subtitle-1">{{ key }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 最近记录展示 -->
<div v-if="recentItems && recentItems.length" class="mt-4">
<div class="text-h6 mb-2">最近记录</div>
<v-timeline density="compact">
<v-timeline-item
v-for="(item, index) in recentItems"
:key="index"
:dot-color="getItemColor(item.type)"
size="small"
>
<div class="d-flex align-center">
<v-icon :color="getItemColor(item.type)" size="small" class="mr-2">
{{ getItemIcon(item.type) }}
</v-icon>
<span class="font-weight-medium">{{ item.title }}</span>
</div>
<div class="text-caption text-secondary">{{ item.time }}</div>
</v-timeline-item>
</v-timeline>
</div>
<!-- 当前状态 -->
<div class="mt-4 text-subtitle-2">
<div>
<strong>状态:</strong>
<v-chip size="small" :color="status === 'running' ? 'success' : 'warning'">{{ status }}</v-chip>
</div>
<div><strong>最后更新:</strong> {{ lastUpdated }}</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="refreshData" :loading="loading">
<v-icon left>mdi-refresh</v-icon>
刷新数据
</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
// 组件状态
const title = ref('插件详情页面')
const loading = ref(true)
const error = ref(null)
const stats = ref(null)
const recentItems = ref([])
const status = ref('running')
const lastUpdated = ref('')
// 自定义事件,用于通知主应用刷新数据
const emit = defineEmits(['action'])
// 获取状态图标
function getItemIcon(type) {
const icons = {
'movie': 'mdi-movie',
'tv': 'mdi-television-classic',
'download': 'mdi-download',
'error': 'mdi-alert-circle',
'success': 'mdi-check-circle',
}
return icons[type] || 'mdi-information'
}
// 获取状态颜色
function getItemColor(type) {
const colors = {
'movie': 'blue',
'tv': 'green',
'download': 'purple',
'error': 'red',
'success': 'success',
}
return colors[type] || 'grey'
}
// 获取和刷新数据
async function refreshData() {
loading.value = true
error.value = null
try {
// 模拟API调用 - 实际开发中应使用 fetch 调用真实API
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟数据
stats.value = {
'电影': Math.floor(Math.random() * 100) + 50,
'电视剧': Math.floor(Math.random() * 100) + 30,
'动漫': Math.floor(Math.random() * 100) + 20,
'纪录片': Math.floor(Math.random() * 100) + 10,
'综艺': Math.floor(Math.random() * 100) + 5,
}
// 模拟最近记录
recentItems.value = [
{ type: 'movie', title: '肖申克的救赎 (1994)', time: '今天 12:30' },
{ type: 'tv', title: '绝命毒师 S01E01', time: '昨天 18:45' },
{ type: 'download', title: '开始下载:星际穿越', time: '2天前' },
{ type: 'success', title: '下载完成:黑客帝国', time: '3天前' },
{ type: 'error', title: '下载失败:泰坦尼克号', time: '一周前' },
]
status.value = Math.random() > 0.2 ? 'running' : 'paused'
lastUpdated.value = new Date().toLocaleString()
} catch (err) {
console.error('获取数据失败:', err)
error.value = err.message || '获取数据失败'
} finally {
loading.value = false
// 通知主应用组件已更新
emit('action')
}
}
// 组件挂载时加载数据
onMounted(() => {
refreshData()
})
</script>
<style scoped>
.plugin-page {
padding: 16px;
inline-size: 100%;
}
</style>

View File

@@ -1,5 +1,24 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import 'vuetify/styles'
// 创建Vuetify实例
const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: 'dark'
}
})
// 创建应用
const app = createApp(App)
// 使用插件
app.use(vuetify)
// 挂载应用
app.mount('#app')

View File

@@ -6,27 +6,34 @@ export default defineConfig({
plugins: [
vue(),
federation({
name: 'remoteApp',
name: 'my_plugin', // 插件名称建议与实际插件ID保持一致
filename: 'remoteEntry.js',
exposes: {
'./PluginComponent': './src/App.vue', // 暴露组件
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
},
shared: {
vue: {
singleton: true,
requiredVersion: false
}
vue: { requiredVersion: false },
vuetify: { requiredVersion: false }
}
})
],
build: {
target: 'esnext', // 支持顶层await
minify: false,
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: false,
rollupOptions: {
output: {
minifyInternalExports: false
format: 'esm', // 必须使用ESM格式
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
}
}
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001'
}
})

View File

@@ -7,12 +7,14 @@ import { useToast } from 'vue-toast-notification'
import FormRender from '../render/FormRender.vue'
import ProgressDialog from '../dialog/ProgressDialog.vue'
import { useI18n } from 'vue-i18n'
import { defineAsyncComponent } from 'vue'
import {
loadRemoteComponent,
clearRemoteComponentCache,
registerRemoteComponent,
getRemoteComponent,
} from '@/utils/remoteFederationLoader'
registerRemotePlugin,
isRemoteComponentLoaded,
ComponentType,
} from '@/utils/federationLoader'
// 国际化
const { t } = useI18n()
@@ -51,28 +53,54 @@ const isRefreshed = ref(false)
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
//Vue 模式:组件 URL
const vueComponentUrl = ref<string | null>(null)
// 挂载状态
const componentMounted = ref(false)
// Vue 模式:动态加载的组件
const dynamicComponent = computed(() => {
if (renderMode.value === 'vue' && vueComponentUrl.value) {
// 检查是否已经注册,如果没有则进行注册
const remoteInfo = props.plugin?.id ? getRemoteComponent(props.plugin.id) : null
if (!remoteInfo && props.plugin?.id) {
// 动态注册远程组件
registerRemoteComponent(props.plugin.id, vueComponentUrl.value)
// Vue 模式:动态加载的组件
const dynamicComponent = defineAsyncComponent({
loader: async () => {
if (renderMode.value !== 'vue' || !props.plugin?.id) {
return { render: () => null }
}
// 加载远程组件
return loadRemoteComponent(vueComponentUrl.value, {
onError: error => {
console.error(`加载插件组件失败: ${vueComponentUrl.value}`, error)
$toast.error(`加载插件组件失败: ${error.message || '未知错误'}`)
},
})
}
return null
try {
componentMounted.value = false
// 确保插件已注册
if (!isRemoteComponentLoaded(props.plugin.id, ComponentType.CONFIG)) {
await registerRemotePlugin(props.plugin.id)
}
// 加载配置组件
const component = await loadRemoteComponent(props.plugin.id, ComponentType.CONFIG)
componentMounted.value = true
if (!component) {
throw new Error('组件加载失败')
}
return component
} catch (error: any) {
console.error(`加载插件配置组件失败: ${props.plugin.id}`, error)
$toast.error(`加载插件组件失败: ${error.message || '未知错误'}`)
return {
render: () => h('div', { class: 'text-error pa-4' }, `加载失败: ${error.message || '未知错误'}`),
}
}
},
loadingComponent: {
render: () =>
h('div', { class: 'text-center pa-4' }, [
h('v-progress-circular', { indeterminate: true, class: 'mr-2' }),
'加载组件中...',
]),
},
errorComponent: {
render: () => h('div', { class: 'text-error pa-4 text-center' }, '组件加载失败'),
},
onError: error => {
console.error('加载插件组件出错', error)
},
})
//调用API读取UI和配置数据
@@ -82,12 +110,12 @@ async function loadPluginUIData() {
pluginFormItems = []
pluginConfigForm.value = {}
renderMode.value = 'vuetify'
componentMounted.value = false
// 如果存在旧的组件URL清除其缓存
if (vueComponentUrl.value) {
clearRemoteComponentCache(vueComponentUrl.value)
// 清除组件缓存
if (props.plugin?.id) {
clearRemoteComponentCache(props.plugin.id)
}
vueComponentUrl.value = null
try {
// 获取UI定义
@@ -98,15 +126,15 @@ async function loadPluginUIData() {
}
renderMode.value = result.render_mode
if (renderMode.value === 'vue') {
// 使用 component_url
vueComponentUrl.value = result.component_url
// 注册远程插件 (如果提供了组件URL则使用它)
if (props.plugin?.id) {
registerRemotePlugin(props.plugin.id, 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 || []
@@ -153,8 +181,8 @@ onBeforeMount(async () => {
// 组件卸载时清理资源
onUnmounted(() => {
if (vueComponentUrl.value) {
clearRemoteComponentCache(vueComponentUrl.value)
if (props.plugin?.id) {
clearRemoteComponentCache(props.plugin.id, ComponentType.CONFIG)
}
})
</script>
@@ -170,7 +198,7 @@ onUnmounted(() => {
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
</div>
<!-- Vue 渲染模式 -->
<div v-else-if="renderMode === 'vue' && dynamicComponent">
<div v-else-if="renderMode === 'vue'">
<component :is="dynamicComponent" :initial-config="pluginConfigForm" @save="handleVueComponentSave" />
</div>
<!-- 加载中或错误 -->

View File

@@ -4,12 +4,14 @@ import type { Plugin } from '@/api/types'
import PageRender from '@/components/render/PageRender.vue'
import api from '@/api'
import { useToast } from 'vue-toast-notification'
import { defineAsyncComponent } from 'vue'
import {
loadRemoteComponent,
clearRemoteComponentCache,
registerRemoteComponent,
getRemoteComponent,
} from '@/utils/remoteFederationLoader'
registerRemotePlugin,
isRemoteComponentLoaded,
ComponentType,
} from '@/utils/federationLoader'
// 输入参数
const props = defineProps({
@@ -35,31 +37,57 @@ const isRefreshed = ref(false)
// 渲染模式: 'vuetify' 或 'vue'
const renderMode = ref('vuetify')
// Vue 模式:组件 URL
const vueComponentUrl = ref<string | null>(null)
// 挂载状态
const componentMounted = ref(false)
// 插件数据页面配置项
let pluginPageItems = ref([])
// Vue 模式:动态加载的组件
const dynamicComponent = computed(() => {
if (renderMode.value === 'vue' && vueComponentUrl.value) {
// 检查是否已经注册,如果没有则进行注册
const remoteInfo = props.plugin?.id ? getRemoteComponent(props.plugin.id) : null
if (!remoteInfo && props.plugin?.id) {
// 动态注册远程组件
registerRemoteComponent(props.plugin.id, vueComponentUrl.value)
// Vue 模式:动态加载的组件
const dynamicComponent = defineAsyncComponent({
loader: async () => {
if (renderMode.value !== 'vue' || !props.plugin?.id) {
return { render: () => null }
}
// 加载远程组件
return loadRemoteComponent(vueComponentUrl.value, {
onError: error => {
console.error(`加载插件组件失败: ${vueComponentUrl.value}`, error)
$toast.error(`加载插件组件失败: ${error.message || '未知错误'}`)
},
})
}
return null
try {
componentMounted.value = false
// 确保插件已注册
if (!isRemoteComponentLoaded(props.plugin.id, ComponentType.PAGE)) {
await registerRemotePlugin(props.plugin.id)
}
// 加载页面组件
const component = await loadRemoteComponent(props.plugin.id, ComponentType.PAGE)
componentMounted.value = true
if (!component) {
throw new Error('组件加载失败')
}
return component
} catch (error: any) {
console.error(`加载插件页面组件失败: ${props.plugin.id}`, error)
$toast.error(`加载插件组件失败: ${error.message || '未知错误'}`)
return {
render: () => h('div', { class: 'text-error pa-4' }, `加载失败: ${error.message || '未知错误'}`),
}
}
},
loadingComponent: {
render: () =>
h('div', { class: 'text-center pa-4' }, [
h('v-progress-circular', { indeterminate: true, class: 'mr-2' }),
'加载组件中...',
]),
},
errorComponent: {
render: () => h('div', { class: 'text-error pa-4 text-center' }, '组件加载失败'),
},
onError: error => {
console.error('加载插件组件出错', error)
},
})
// 调用API读取数据页面UI
@@ -67,12 +95,12 @@ async function loadPluginUIData() {
isRefreshed.value = false
pluginPageItems.value = []
renderMode.value = 'vuetify'
componentMounted.value = false
// 如果存在旧的组件URL清除其缓存
if (vueComponentUrl.value) {
clearRemoteComponentCache(vueComponentUrl.value)
// 清除组件缓存
if (props.plugin?.id) {
clearRemoteComponentCache(props.plugin.id)
}
vueComponentUrl.value = null
try {
const result: { [key: string]: any } = await api.get(`plugin/page/${props.plugin?.id}`)
@@ -80,14 +108,15 @@ async function loadPluginUIData() {
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'
// 注册远程插件 (如果提供了组件URL则使用它)
if (props.plugin?.id) {
registerRemotePlugin(props.plugin.id, result.component_url)
}
} else {
// Vuetify模式
pluginPageItems.value = result.page || []
}
} catch (error: any) {
@@ -96,6 +125,7 @@ async function loadPluginUIData() {
isRefreshed.value = true
}
}
// 重新加载数据(可由 PageRender 或 Vue component 触发)
function handleAction() {
loadPluginUIData()
@@ -107,8 +137,8 @@ onMounted(() => {
// 组件卸载时清理资源
onUnmounted(() => {
if (vueComponentUrl.value) {
clearRemoteComponentCache(vueComponentUrl.value)
if (props.plugin?.id) {
clearRemoteComponentCache(props.plugin.id, ComponentType.PAGE)
}
})
</script>
@@ -124,7 +154,7 @@ onUnmounted(() => {
<div v-if="!pluginPageItems || pluginPageItems.length === 0">此插件没有详情页面</div>
</div>
<!-- Vue 渲染模式 -->
<div v-else-if="renderMode === 'vue' && dynamicComponent">
<div v-else-if="renderMode === 'vue'">
<component :is="dynamicComponent" @action="handleAction" />
</div>
</VCardText>

View File

@@ -12,12 +12,14 @@ import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
import DashboardRender from '@/components/render/DashboardRender.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import { defineAsyncComponent } from 'vue'
import {
loadRemoteComponent,
clearRemoteComponentCache,
registerRemoteComponent,
getRemoteComponent,
} from '@/utils/remoteFederationLoader'
registerRemotePlugin,
isRemoteComponentLoaded,
ComponentType,
} from '@/utils/federationLoader'
// 输入参数
const props = defineProps({
@@ -37,27 +39,53 @@ const emit = defineEmits(['update:refreshStatus'])
// 插件UI渲染模式 ('vuetify' 或 'vue')
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
// 挂载状态
const componentMounted = ref(false)
// Vue 模式:动态加载的组件
const dynamicPluginComponent = computed(() => {
// 确保 config 存在并且 component_url 也存在
if (pluginRenderMode.value === 'vue' && props.config?.component_url) {
// 如果有插件ID尝试注册远程组件
if (props.config.id) {
const remoteInfo = getRemoteComponent(props.config.id)
if (!remoteInfo) {
// 动态注册远程组件
registerRemoteComponent(props.config.id, props.config.component_url)
}
const dynamicPluginComponent = defineAsyncComponent({
loader: async () => {
if (pluginRenderMode.value !== 'vue' || !props.config?.id) {
return { render: () => null }
}
// 加载远程组件
return loadRemoteComponent(props.config.component_url, {
onError: error => {
console.error(`加载插件组件失败: ${props.config?.component_url}`, error)
},
})
}
return null
try {
componentMounted.value = false
// 确保插件已注册
if (!isRemoteComponentLoaded(props.config.id, ComponentType.DASHBOARD)) {
await registerRemotePlugin(props.config.id, props.config.component_url)
}
// 加载仪表板组件
const component = await loadRemoteComponent(props.config.id, ComponentType.DASHBOARD)
componentMounted.value = true
if (!component) {
throw new Error('组件加载失败')
}
return component
} catch (error: any) {
console.error(`加载插件仪表板组件失败: ${props.config.id}`, error)
return {
render: () => h('div', { class: 'text-error pa-4' }, `加载失败: ${error.message || '未知错误'}`),
}
}
},
loadingComponent: {
render: () =>
h('div', { class: 'text-center pa-4' }, [
h('v-progress-circular', { indeterminate: true, class: 'mr-2' }),
'加载组件中...',
]),
},
errorComponent: {
render: () => h('div', { class: 'text-error pa-4 text-center' }, '组件加载失败'),
},
onError: error => {
console.error('加载插件组件出错', error)
},
})
onUnmounted(() => {
@@ -65,8 +93,8 @@ onUnmounted(() => {
emit('update:refreshStatus', false)
// 清理远程组件缓存
if (pluginRenderMode.value === 'vue' && props.config?.component_url) {
clearRemoteComponentCache(props.config.component_url)
if (props.config?.id) {
clearRemoteComponentCache(props.config.id, ComponentType.DASHBOARD)
}
})
</script>
@@ -85,7 +113,7 @@ onUnmounted(() => {
<!-- 插件仪表板 -->
<template v-else-if="!isNullOrEmptyObject(props.config)">
<!-- Vue 渲染模式 -->
<div v-if="pluginRenderMode === 'vue' && dynamicPluginComponent">
<div v-if="pluginRenderMode === 'vue'">
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" />
<!-- Vue 模式下也可以显示拖拽句柄 -->
<div class="absolute right-5 top-5">

View File

@@ -20,6 +20,7 @@ import { CronVuetify } from '@vue-js-cron/vuetify'
// 4. 工具函数和其他辅助模块
import { fetchGlobalSettings } from './api'
import { isPWA } from './@core/utils/navigator'
import { loadRemoteComponents } from './utils/federationLoader'
// 5. 其他插件和功能模块
import ToastPlugin from 'vue-toast-notification'
@@ -60,6 +61,9 @@ async function initializeApp() {
// 全局设置
const globalSettings = await fetchGlobalSettings()
app.provide('globalSettings', globalSettings)
// 加载并注册远程联邦组件
await loadRemoteComponents()
} catch (error) {
console.error('Failed to initialize app', error)
}

View File

@@ -0,0 +1,337 @@
/**
* 动态模块联邦加载器
* 支持运行时动态注册和加载远程模块联邦组件
*/
import api from '@/api'
import type { Component } from 'vue'
// 远程组件配置接口
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 {
interface Window {
__FEDERATION__: Record<string, any>
}
}
// 确保全局联邦对象存在
if (!window.__FEDERATION__) {
window.__FEDERATION__ = {}
}
/**
* 加载远程模块的入口文件
* @param url 远程模块URL
* @returns Promise<void>
*/
async function loadRemoteEntry(url: string): Promise<void> {
return new Promise((resolve, reject) => {
// 创建script标签
const script = document.createElement('script')
script.src = url
script.type = 'text/javascript'
script.async = true
// 加载成功
script.onload = () => {
console.log(`远程模块加载成功: ${url}`)
resolve()
}
// 加载失败
script.onerror = error => {
console.error(`远程模块加载失败: ${url}`, error)
reject(new Error(`远程模块加载失败: ${url}`))
}
// 添加到文档
document.head.appendChild(script)
})
}
/**
* 获取模块联邦远程URL的标准化地址
* @param pluginId 插件ID
* @param url 组件URL (可选)
* @returns 完整的远程组件URL
*/
function getRemoteEntryUrl(pluginId: string, url?: string): string {
// 如果提供了完整URL则直接使用
if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'))) {
return url.endsWith('/remoteEntry.js') ? url : `${url}/remoteEntry.js`
}
// 否则使用默认路径格式
return `/api/plugin/component/${pluginId}/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 {
// 调用后端API获取远程组件列表
const result = await api.get('plugins/remotes')
if (!result || !Array.isArray(result)) {
console.error('获取远程组件列表失败:无效的响应格式')
return false
}
// 加载所有远程组件
const remotes = result as RemoteComponentConfig[]
const loadPromises = remotes.map(remote => {
if (remote.id) {
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 () => {
// 这里可能需要根据实际情况调整
try {
// 理论上这里应该使用模块联邦的import机制
// 但由于我们是运行时加载,需要用一种变通方式
const moduleUrl = `${remoteEntryUrl.replace('remoteEntry.js', '')}${module.replace('./', '')}.js`
// 使用动态导入
const moduleExports = await import(/* @vite-ignore */ moduleUrl)
return { default: moduleExports.default }
} catch (error) {
console.error(`加载远程模块失败: ${remoteId}/${module}`, 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,
}
}
console.error(`加载远程组件失败: ${pluginId}/${componentType}`, 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) {
console.error(`加载远程组件模块失败: ${remoteId}/${moduleName}`, error)
throw error
}
}
/**
* 检查远程组件是否已加载
* @param pluginId 插件ID
* @param componentType 组件类型
* @returns boolean 是否已加载
*/
export function isRemoteComponentLoaded(pluginId: string, componentType: ComponentType): boolean {
return !!remoteModules[pluginId]?.[componentType]?.loaded
}
/**
* 获取远程组件加载错误
* @param pluginId 插件ID
* @param componentType 组件类型
* @returns Error | null 错误
*/
export function getRemoteComponentError(pluginId: string, componentType: ComponentType): Error | null {
return remoteModules[pluginId]?.[componentType]?.error || null
}
/**
* 清除远程组件缓存
* @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]
}
}

View File

@@ -1,184 +0,0 @@
/**
* 模块联邦动态加载器
* 用于动态加载远程组件
*/
import { defineAsyncComponent, type Component } from 'vue'
import api from '@/api'
// 远程组件加载状态
interface RemoteModuleState {
loading: boolean
loaded: boolean
module: any
error: Error | null
}
// 已加载组件的缓存
const loadedRemotes: Record<string, RemoteModuleState> = {}
// 动态注册的远程组件映射
interface RemotePluginInfo {
url: string
pluginId: string
moduleName: string
}
// 已注册的远程模块
const registeredRemotes: Record<string, RemotePluginInfo> = {}
/**
* 动态注册远程组件
* @param pluginId 插件ID
* @param remoteUrl 远程组件URL
* @returns 注册成功返回true否则返回false
*/
export async function registerRemoteComponent(pluginId: string, remoteUrl: string): Promise<boolean> {
try {
// 生成远程模块名称使用插件ID作为标识
const moduleName = `plugin_${pluginId.replace(/[^a-zA-Z0-9_]/g, '_')}`
// 注册到映射表中
registeredRemotes[pluginId] = {
url: remoteUrl,
pluginId,
moduleName,
}
console.log(`已注册远程组件: ${pluginId} -> ${moduleName} (${remoteUrl})`)
return true
} catch (error) {
console.error(`注册远程组件失败: ${pluginId}`, error)
return false
}
}
/**
* 获取远程组件信息
* @param pluginId 插件ID
* @returns 远程组件信息
*/
export function getRemoteComponent(pluginId: string): RemotePluginInfo | null {
return registeredRemotes[pluginId] || null
}
/**
* 获取和初始化远程组件
* @param remoteUrl 远程组件URL
* @param options 组件选项
* @returns 异步组件
*/
export function loadRemoteComponent(
remoteUrl: string,
options: {
timeout?: number
onError?: (error: Error) => void
} = {},
): Component {
const { timeout = 30000, onError } = options
// 创建异步组件
return defineAsyncComponent({
loader: async () => {
try {
// 检查缓存
if (loadedRemotes[remoteUrl]?.loaded) {
return loadedRemotes[remoteUrl].module
}
// 标记加载状态
if (!loadedRemotes[remoteUrl]) {
loadedRemotes[remoteUrl] = {
loading: false,
loaded: false,
module: null,
error: null,
}
}
// 如果正在加载,等待加载完成
if (loadedRemotes[remoteUrl].loading) {
while (loadedRemotes[remoteUrl].loading && !loadedRemotes[remoteUrl].loaded) {
await new Promise(resolve => setTimeout(resolve, 100))
}
if (loadedRemotes[remoteUrl].error) {
throw loadedRemotes[remoteUrl].error
}
return loadedRemotes[remoteUrl].module
}
loadedRemotes[remoteUrl].loading = true
// 获取远程组件JS
const response = await api.get(remoteUrl)
if (!response) {
throw new Error('无法加载远程组件:请求无响应或响应无数据')
}
// 创建Blob URL并动态导入
const blob = new Blob([response as any], { type: 'text/javascript' })
const blobUrl = URL.createObjectURL(blob)
// 动态导入模块
const moduleExports = await import(/* @vite-ignore */ blobUrl)
// 清理Blob URL
URL.revokeObjectURL(blobUrl)
// 获取默认导出
if (!moduleExports.default) {
throw new Error('远程组件没有默认导出')
}
// 更新缓存
loadedRemotes[remoteUrl].module = moduleExports.default
loadedRemotes[remoteUrl].loaded = true
loadedRemotes[remoteUrl].loading = false
return moduleExports.default
} catch (error: any) {
console.error(`加载远程组件失败: ${remoteUrl}`, error)
loadedRemotes[remoteUrl].error = error
loadedRemotes[remoteUrl].loading = false
// 调用错误处理回调
if (onError) onError(error)
// 返回一个简单的错误组件
return {
render: () =>
h('div', { class: 'text-error pa-4 text-center' }, `组件加载失败: ${error.message || '未知错误'}`),
}
}
},
timeout,
// 可以定义加载中和错误状态的组件
loadingComponent: {
render: () => h('div', { class: 'text-center pa-4' }, '加载组件中...'),
},
errorComponent: {
render: () => h('div', { class: 'text-error pa-4 text-center' }, '组件加载失败'),
},
})
}
// 清除缓存的组件
export function clearRemoteComponentCache(remoteUrl?: string) {
if (remoteUrl) {
delete loadedRemotes[remoteUrl]
} else {
// 清除所有缓存
Object.keys(loadedRemotes).forEach(key => {
delete loadedRemotes[key]
})
}
}
// 取消注册远程组件
export function unregisterRemoteComponent(pluginId: string) {
if (registeredRemotes[pluginId]) {
delete registeredRemotes[pluginId]
console.log(`已取消注册远程组件: ${pluginId}`)
return true
}
return false
}

View File

@@ -34,9 +34,18 @@ export default defineConfig({
}),
federation({
name: 'host',
remotes: {},
exposes: {},
shared: ['vue', 'vuetify'],
filename: 'remoteEntry.js',
remotes: {
// 动态remotes将在运行时通过__FEDERATION__注入
},
shared: {
vue: {
requiredVersion: false,
},
vuetify: {
requiredVersion: false,
},
},
}),
VitePWA({
injectRegister: 'script',