mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-25 17:44:13 +08:00
增强模块联邦支持,添加动态导入远程模块的声明,更新示例项目以展示新组件结构和配置,调整 Vite 配置以支持更灵活的远程组件加载。
This commit is contained in:
@@ -17,10 +17,10 @@ MoviePilot 现已支持模块联邦(Module Federation)功能,允许插件
|
||||
|
||||
### 相关文档
|
||||
|
||||
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
|
||||
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
|
||||
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
|
||||
|
||||
|
||||
## 开发部署
|
||||
|
||||
### 推荐的IDE设置
|
||||
|
||||
309
docs/module-federation-guide.md
Normal file
309
docs/module-federation-guide.md
Normal 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
7
env.d.ts
vendored
@@ -8,3 +8,10 @@ declare module 'vue-router' {
|
||||
navActiveLink?: RouteLocationRaw
|
||||
}
|
||||
}
|
||||
|
||||
// 支持动态导入远程模块
|
||||
declare module '*' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
208
examples/plugin-component/src/components/Config.vue
Normal file
208
examples/plugin-component/src/components/Config.vue
Normal 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>
|
||||
314
examples/plugin-component/src/components/Dashboard.vue
Normal file
314
examples/plugin-component/src/components/Dashboard.vue
Normal 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>
|
||||
152
examples/plugin-component/src/components/Page.vue
Normal file
152
examples/plugin-component/src/components/Page.vue
Normal 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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
<!-- 加载中或错误 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
337
src/utils/federationLoader.ts
Normal file
337
src/utils/federationLoader.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user