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
+72 -58
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+
@@ -18,13 +17,13 @@ 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 | 仪表盘小组件 |
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) | | AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 | | (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage``Page``nav_key``main` 时仅尝试 `AppPage``Page` 主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage``Page``nav_key``main` 时仅尝试 `AppPage``Page`
@@ -79,55 +78,52 @@ export default defineConfig({
singleton: true, singleton: true,
}, },
}, },
format: 'esm' format: 'esm',
}) }),
], ],
build: { build: {
target: 'esnext', // 必须设置为esnext以支持顶层await target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆 minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: true, // 改为true以便能分离样式文件 cssCodeSplit: true, // 改为true以便能分离样式文件
}, },
css: { css: {
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,16 +179,16 @@ function notifyClose() {
const props = defineProps({ const props = defineProps({
initialConfig: { initialConfig: {
type: Object, type: Object,
default: () => ({}) default: () => ({}),
}, },
api: { api: {
type: Object, type: Object,
default: () => {} default: () => {},
} },
}) })
// 配置数据 // 配置数据
const config = ref({...props.initialConfig}) const config = ref({ ...props.initialConfig })
// 自定义事件,用于保存配置 // 自定义事件,用于保存配置
const emit = defineEmits(['save', 'close', 'switch']) const emit = defineEmits(['save', 'close', 'switch'])
@@ -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,
} },
}) })
// 仪表板逻辑... // 仪表板逻辑...
@@ -276,11 +272,11 @@ const props = defineProps({
主应用传入的 props 主应用传入的 props
| 属性 | 说明 | | 属性 | 说明 |
|------|------| | ---------- | ----------------------------------------------------- |
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 | | `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 | | `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
| `pluginId` | 当前插件 ID | | `pluginId` | 当前插件 ID |
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
@@ -300,18 +296,37 @@ 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` |
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` | | `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 | | `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
| `order` | 可选:同组内排序,数值越小越靠前 | | `order` | 可选:同组内排序,数值越小越靠前 |
```python ```python
def get_sidebar_nav(self) -> List[Dict[str, Any]]: def get_sidebar_nav(self) -> List[Dict[str, Any]]:
@@ -333,9 +348,9 @@ 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` |
`PascalCase` 规则:按 `-``_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings``my_tool``./AppPageMyTool` `PascalCase` 规则:按 `-``_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings``my_tool``./AppPageMyTool`
@@ -365,8 +380,7 @@ yarn build
- 将生成的dist文件夹上传到插件后端目录下(默认为`dist/assets` - 将生成的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代码中,实现以下方法来集成远程组件: - 在插件的后端python代码中,实现以下方法来集成远程组件:
@@ -380,7 +394,8 @@ def get_render_mode() -> Tuple[str, str]:
return "vue", "dist/assets" return "vue", "dist/assets"
``` ```
- 需要在插件前端页面调用后端接口时,通过传入的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]) => {