feat(plugin): expose host toast to federated views

This commit is contained in:
jxxghp
2026-07-20 12:35:14 +08:00
parent ad555b3b36
commit 03126d32c1
5 changed files with 95 additions and 63 deletions
+47 -33
View File
@@ -6,7 +6,6 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
关联阅读后端插件开发文档:[第三方插件开发说明](https://github.com/jxxghp/MoviePilot-Plugins/blob/main/README.md) 关联阅读后端插件开发文档:[第三方插件开发说明](https://github.com/jxxghp/MoviePilot-Plugins/blob/main/README.md)
## 2. 技术要求 ## 2. 技术要求
- Node.js 20+ - Node.js 20+
@@ -19,7 +18,7 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口): 每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口):
| 组件名称 | 暴露名 | 文件名 | 用途 | | 组件名称 | 暴露名 | 文件名 | 用途 |
|---------|--------|--------|------| | --------- | ---------------- | ---------------------- | --------------------------------------------- |
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 | | Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
| Config | `./Config` | Config.vue | 插件配置页面 | | Config | `./Config` | Config.vue | 插件配置页面 |
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 | | Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
@@ -79,8 +78,8 @@ export default defineConfig({
singleton: true, singleton: true,
}, },
}, },
format: 'esm' format: 'esm',
}) }),
], ],
build: { build: {
target: 'esnext', // 必须设置为esnext以支持顶层await target: 'esnext', // 必须设置为esnext以支持顶层await
@@ -91,43 +90,40 @@ export default defineConfig({
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
additionalData: '/* 覆盖vuetify样式 */', additionalData: '/* 覆盖vuetify样式 */',
} },
}, },
postcss: { postcss: {
plugins: [ plugins: [
{ {
postcssPlugin: 'internal:charset-removal', postcssPlugin: 'internal:charset-removal',
AtRule: { AtRule: {
charset: (atRule) => { charset: atRule => {
if (atRule.name === 'charset') { if (atRule.name === 'charset') {
atRule.remove(); atRule.remove()
}
}
} }
}, },
},
},
{ {
postcssPlugin: 'vuetify-filter', postcssPlugin: 'vuetify-filter',
Root(root) { Root(root) {
// 过滤掉所有vuetify相关的CSS // 过滤掉所有vuetify相关的CSS
root.walkRules(rule => { root.walkRules(rule => {
if (rule.selector && ( if (rule.selector && (rule.selector.includes('.v-') || rule.selector.includes('.mdi-'))) {
rule.selector.includes('.v-') || rule.remove()
rule.selector.includes('.mdi-'))) {
rule.remove();
}
});
}
}
]
} }
})
},
},
],
},
}, },
server: { server: {
port: 5001, // 使用不同于主应用的端口 port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS cors: true, // 启用CORS
origin: 'http://localhost:5001' origin: 'http://localhost:5001',
}, },
}) })
``` ```
## 5. 组件开发规范 ## 5. 组件开发规范
@@ -143,8 +139,8 @@ const emit = defineEmits(['action', 'switch', 'close'])
const props = defineProps({ const props = defineProps({
api: { api: {
type: Object, type: Object,
default: () => {} default: () => {},
} },
}) })
// 页面逻辑代码... // 页面逻辑代码...
@@ -183,12 +179,12 @@ function notifyClose() {
const props = defineProps({ const props = defineProps({
initialConfig: { initialConfig: {
type: Object, type: Object,
default: () => ({}) default: () => ({}),
}, },
api: { api: {
type: Object, type: Object,
default: () => {} default: () => {},
} },
}) })
// 配置数据 // 配置数据
@@ -238,12 +234,12 @@ function notifyClose() {
const props = defineProps({ const props = defineProps({
config: { config: {
type: Object, type: Object,
default: () => ({}) default: () => ({}),
}, },
allowRefresh: { allowRefresh: {
type: Boolean, type: Boolean,
default: true default: true,
} },
}) })
// 仪表板逻辑... // 仪表板逻辑...
@@ -277,7 +273,7 @@ const props = defineProps({
主应用传入的 props 主应用传入的 props
| 属性 | 说明 | | 属性 | 说明 |
|------|------| | ---------- | ----------------------------------------------------- |
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 | | `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 | | `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
| `pluginId` | 当前插件 ID | | `pluginId` | 当前插件 ID |
@@ -300,12 +296,31 @@ const emit = defineEmits(['action'])
</template> </template>
``` ```
### 5.5 调用主应用 Toast
`Page``Config``Dashboard``AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例,不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
```vue
<script setup lang="ts">
import { inject } from 'vue'
const toast = inject<any>('moviepilot:toast', null)
// 保存完成后调用主应用的统一通知。
function saveComplete() {
toast?.success('保存成功')
}
</script>
```
可用方法与主项目 `vue-toastification` 一致,包括 `success``info``warning``error`。注入不存在时应静默降级,关键错误仍需保留页面内状态提示。
#### 后端:注册侧栏入口 #### 后端:注册侧栏入口
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致: 插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
| 字段 | 说明 | | 字段 | 说明 |
|------|------| | ------------ | ------------------------------------------------------------------------------------- |
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) | | `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
| `title` | 侧栏显示标题 | | `title` | 侧栏显示标题 |
| `icon` | MDI 图标名,如 `mdi-rss` | | `icon` | MDI 图标名,如 `mdi-rss` |
@@ -334,7 +349,7 @@ def get_sidebar_nav(self) -> List[Dict[str, Any]]:
前端加载远程组件的顺序为: 前端加载远程组件的顺序为:
| `nav_key` | 依次尝试的联邦暴露名 | | `nav_key` | 依次尝试的联邦暴露名 |
|-----------|----------------------| | -------------------------------- | ------------------------------------------------ |
| `main` 或省略 | `./AppPage``./Page` | | `main` 或省略 | `./AppPage``./Page` |
| 其它(如 `settings``my_tool` | `./AppPage{PascalCase}``./AppPage``./Page` | | 其它(如 `settings``my_tool` | `./AppPage{PascalCase}``./AppPage``./Page` |
@@ -367,7 +382,6 @@ yarn build
**注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js` **注意: `__federation_shared_vuetify` 目录以及 `index-`、`date-`、`runtime-` 开头的文件不需要上传**,只需要上传以下命名格式文件:`__federation_*``_plugin-vue_export-helper-*``remoteEntry.js`
- 在插件的后端python代码中,实现以下方法来集成远程组件: - 在插件的后端python代码中,实现以下方法来集成远程组件:
```python ```python
@@ -381,6 +395,7 @@ def get_render_mode() -> Tuple[str, str]:
``` ```
- 需要在插件前端页面调用后端接口时,通过传入的api模块发起调用,后端api接口声明认证类型为:`bear` - 需要在插件前端页面调用后端接口时,通过传入的api模块发起调用,后端api接口声明认证类型为:`bear`
```typescript ```typescript
// 演示使用api模块调用插件接口 // 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`) recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
@@ -402,7 +417,6 @@ def get_api(self) -> List[Dict[str, Any]]:
] ]
``` ```
## 7. 调试与排错 ## 7. 调试与排错
### 常见问题 ### 常见问题
@@ -40,6 +40,9 @@ const progressText = ref('')
// 提示框 // 提示框
const $toast = useToast() const $toast = useToast()
// 向联邦插件提供主应用 Toast,避免远程组件自行创建通知容器。
provide('moviepilot:toast', $toast)
// 是否刷新 // 是否刷新
const isRefreshed = ref(false) const isRefreshed = ref(false)
@@ -5,6 +5,7 @@ import PageRender from '@/components/render/PageRender.vue'
import api from '@/api' import api from '@/api'
import { loadRemoteComponent } from '@/utils/federationLoader' import { loadRemoteComponent } from '@/utils/federationLoader'
import { usePWA } from '@/composables/usePWA' import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification'
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
@@ -26,6 +27,10 @@ const display = useDisplay()
// PWA模式检测 // PWA模式检测
const { appMode } = usePWA() const { appMode } = usePWA()
// 向联邦插件提供主应用 Toast,确保通知沿用统一主题和路由逻辑。
const $toast = useToast()
provide('moviepilot:toast', $toast)
// 是否刷新 // 是否刷新
const isRefreshed = ref(false) const isRefreshed = ref(false)
// 组件是否已加载成功 // 组件是否已加载成功
+5
View File
@@ -5,9 +5,14 @@ import { DashboardItem } from '@/api/types'
import DashboardRender from '@/components/render/DashboardRender.vue' import DashboardRender from '@/components/render/DashboardRender.vue'
import { isNullOrEmptyObject } from '@/@core/utils' import { isNullOrEmptyObject } from '@/@core/utils'
import { loadRemoteComponent } from '@/utils/federationLoader' import { loadRemoteComponent } from '@/utils/federationLoader'
import { useToast } from 'vue-toastification'
type DashboardComponentLoader = () => Promise<any> type DashboardComponentLoader = () => Promise<any>
// 仪表板联邦组件复用主应用 Toast 实例。
const $toast = useToast()
provide('moviepilot:toast', $toast)
const DashboardSkeleton = { const DashboardSkeleton = {
setup() { setup() {
const SkeletonLoader = resolveComponent('VSkeletonLoader') const SkeletonLoader = resolveComponent('VSkeletonLoader')
+5
View File
@@ -2,6 +2,7 @@
import type { Component } from 'vue' import type { Component } from 'vue'
import api from '@/api' import api from '@/api'
import { loadRemoteAppPageComponent } from '@/utils/federationLoader' import { loadRemoteAppPageComponent } from '@/utils/federationLoader'
import { useToast } from 'vue-toastification'
const route = useRoute() const route = useRoute()
@@ -11,6 +12,10 @@ const navKey = computed(() => (route.params.navKey as string) || 'main')
const RemoteView = shallowRef<Component | null>(null) const RemoteView = shallowRef<Component | null>(null)
const loadError = ref(false) const loadError = ref(false)
// 侧栏联邦页面复用主应用 Toast 实例。
const $toast = useToast()
provide('moviepilot:toast', $toast)
watch( watch(
[pluginId, navKey], [pluginId, navKey],
async ([pid, nk]) => { async ([pid, nk]) => {