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