17 KiB
MoviePilot前端远程模块开发指南
1. 概述
MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态加载和集成。本文档详细说明如何开发符合要求的远程模块,以便在MoviePilot中作为插件使用。
关联阅读后端插件开发文档:第三方插件开发说明
2. 技术要求
- Node.js 20+
- Vue 3
- Vite 4+
- TypeScript 5+
3. 核心概念
每个 Vue 联邦插件需要提供下列标准组件(AppPage 为可选,用于主界面侧栏全页入口):
| 组件名称 | 暴露名 | 文件名 | 用途 |
|---|---|---|---|
| Page | ./Page |
Page.vue | 插件管理中的详情弹窗 |
| Config | ./Config |
Config.vue | 插件配置页面 |
| Dashboard | ./Dashboard |
Dashboard.vue | 仪表盘小组件 |
| AppPage | ./AppPage |
AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
| (可选) | ./AppPage{Xxx} |
如 AppPageSettings.vue | 多 nav_key 时按名优先加载,见下文「多界面」 |
主应用在侧栏全页路由中按 nav_key 解析暴露名(如 AppPageSettings),再回退 AppPage → Page;nav_key 为 main 时仅尝试 AppPage → Page。
4. 快速开始
创建项目
# 创建项目
npm create vite@latest my-plugin -- --template vue-ts
# 进入项目目录
cd my-plugin
# 安装依赖
yarn
配置vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
vue(),
federation({
name: 'MyPlugin',
filename: 'remoteEntry.js',
exposes: {
'./Page': './src/components/Page.vue',
'./Config': './src/components/Config.vue',
'./Dashboard': './src/components/Dashboard.vue',
'./AppPage': './src/components/AppPage.vue',
'./AppPageSettings': './src/components/AppPageSettings.vue',
},
shared: {
vue: {
requiredVersion: false,
generate: false,
},
vuetify: {
requiredVersion: false,
generate: false,
singleton: true,
},
'vuetify/styles': {
requiredVersion: false,
generate: false,
singleton: true,
},
},
format: 'esm',
}),
],
build: {
target: 'esnext', // 必须设置为esnext以支持顶层await
minify: false, // 开发阶段建议关闭混淆
cssCodeSplit: true, // 改为true以便能分离样式文件
},
css: {
preprocessorOptions: {
scss: {
additionalData: '/* 覆盖vuetify样式 */',
},
},
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: atRule => {
if (atRule.name === 'charset') {
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()
}
})
},
},
],
},
},
server: {
port: 5001, // 使用不同于主应用的端口
cors: true, // 启用CORS
origin: 'http://localhost:5001',
},
})
5. 组件开发规范
5.1 Page组件(详情页面)
<script setup lang="ts">
// 自定义事件,用于通知主应用刷新数据
const emit = defineEmits(['action', 'switch', 'close'])
// 接收主应用能力
const props = defineProps({
api: {
type: Object,
default: () => {},
},
nativeSubscribe: {
type: Function,
default: null,
},
})
// 页面逻辑代码...
// 通知主应用刷新数据
function notifyRefresh() {
emit('action')
}
// 通知主应用切换到配置页面
function notifySwitch() {
emit('switch')
}
// 通知主应用关闭当前页面
function notifyClose() {
emit('close')
}
</script>
<template>
<div class="plugin-page">
<!-- 插件详情页面操作按钮示例 -->
<v-btn @click="notifyRefresh">刷新数据</v-btn>
<v-btn @click="notifySwitch">配置插件</v-btn>
<v-btn @click="notifyClose">关闭页面</v-btn>
</div>
</template>
5.2 Config组件(配置页面)
<script setup lang="ts">
// 接收初始配置和主应用能力
const props = defineProps({
initialConfig: {
type: Object,
default: () => ({}),
},
api: {
type: Object,
default: () => {},
},
nativeSubscribe: {
type: Function,
default: null,
},
})
// 配置数据
const config = ref({ ...props.initialConfig })
// 自定义事件,用于保存配置
const emit = defineEmits(['save', 'close', 'switch'])
// 保存配置
function saveConfig() {
emit('save', config.value)
}
// 通知主应用切换到详情页面
function notifySwitch() {
emit('switch')
}
// 通知主应用关闭当前页面
function notifyClose() {
emit('close')
}
</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>
<!-- 关闭按钮示例 -->
<v-btn color="primary" @click="notifyClose">关闭页面</v-btn>
<!-- 切换按钮示例 -->
<v-btn color="primary" @click="notifySwitch">切换到详情页面</v-btn>
</div>
</template>
5.3 Dashboard组件(仪表板)
<script setup lang="ts">
// 接收配置、刷新控制和主应用能力
const props = defineProps({
config: {
type: Object,
default: () => ({}),
},
allowRefresh: {
type: Boolean,
default: true,
},
nativeSubscribe: {
type: Function,
default: null,
},
})
// 仪表板逻辑...
</script>
<template>
<div class="dashboard-widget">
<v-hover>
<!-- 仪表板内容 -->
<template #default="{ isHovering, props: hoverProps }">
<v-card v-bind="hoverProps">
<v-card-title>{{ config.title || '仪表板组件' }}</v-card-title>
<v-card-text>
<!-- 组件内容 -->
</v-card-text>
<!-- 只在悬停时显示拖拽图标 -->
<div v-show="isHovering" class="absolute right-5 top-5">
<v-icon class="cursor-move">mdi-drag</v-icon>
</div>
</v-card>
</template>
</v-hover>
</div>
</template>
5.4 AppPage 组件(侧栏全页)
用于主应用左侧导航中的独立页面(路由 #/plugin-app/:pluginId/:navKey?),占据默认布局下的主内容区;与 Page 不同,不嵌在插件管理弹窗中。
主应用传入的 props:
| 属性 | 说明 |
|---|---|
api |
与 Page 相同,用于 bear 认证的插件 HTTP 调用 |
nativeSubscribe |
打开主应用原生订阅交互 |
navKey |
与侧栏声明的 nav_key 一致,同一插件多入口时用于区分 |
pluginId |
当前插件 ID |
<script setup lang="ts">
const props = defineProps({
api: { type: Object, default: () => ({}) },
nativeSubscribe: { type: Function, default: null },
navKey: { type: String, default: 'main' },
pluginId: { type: String, default: '' },
})
const emit = defineEmits(['action'])
</script>
<template>
<div class="pa-4">
<div class="text-h6 mb-2">侧栏全页示例({{ pluginId }} / {{ navKey }})</div>
<v-btn size="small" @click="emit('action')">通知主应用</v-btn>
</div>
</template>
5.5 主应用宿主能力
登录后的联邦组件宿主会向插件开放以下能力:
| 能力 | Page | Config | Dashboard | AppPage | 调用方式 |
|---|---|---|---|---|---|
| 认证 API | ✓ | ✓ | ✓ | ✓ | api prop |
| 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | nativeSubscribe prop 或 inject('moviepilot:nativeSubscribe') |
| 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | inject('moviepilot:toast') |
nativeSubscribe 和 Toast 都由主应用宿主提供。插件不应复制主程序订阅弹窗,也不应自行创建另一套 Toast 容器。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
5.6 调用主应用原生订阅
Page、Config、Dashboard 与 AppPage 都会收到 nativeSubscribe(mediaInfo) prop。插件传入媒体信息后,电视剧会打开主应用的选季抽屉,电影会进入现有电影订阅流程。宿主也会用 moviepilot:nativeSubscribe 键提供同一个方法,深层子组件可以使用 inject,无需逐层传递 prop。
媒体信息必须包含:
type:电影/电视剧,也兼容movie/tv;title;- 至少一个有效媒体标识:
tmdb_id/tmdbid、douban_id/doubanid、bangumi_id/bangumiid、anilist_id/anilistid,或者media_id与mediaid_prefix/source/media_source的组合。
<script setup lang="ts">
import { inject } from 'vue'
type NativeSubscribeResult =
{ success: true } | { success: false; code: 'INVALID_MEDIA' | 'PERMISSION_DENIED'; message: string }
const props = defineProps<{
nativeSubscribe?: (mediaInfo: Record<string, unknown>) => Promise<NativeSubscribeResult>
}>()
const nativeSubscribe = inject('moviepilot:nativeSubscribe', props.nativeSubscribe)
/** 使用主应用订阅交互,宿主不接受时保留插件自己的 fallback。 */
async function subscribeMedia(mediaInfo: Record<string, unknown>) {
const result = await nativeSubscribe?.(mediaInfo)
if (!result?.success) {
// 插件可在这里执行自己的 fallback;宿主已同时显示明确错误提示。
}
}
</script>
success: true 表示主应用已接受调用并启动原生交互,不表示用户已经完成订阅。字段无效或当前用户没有订阅权限时返回 success: false,插件可以依据 code 执行 fallback。
5.7 调用主应用 Toast
Page、Config、Dashboard 与 AppPage 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例,不要自行渲染 VSnackbar 或创建另一套 Toast 容器:
<script setup lang="ts">
import { inject } from 'vue'
interface HostToast {
error(message: string): unknown
info(message: string): unknown
success(message: string): unknown
warning(message: string): unknown
}
const toast = inject<HostToast | null>('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 |
section |
分组:start / discovery / subscribe / organize / system |
permission |
可选:subscribe / discovery / search / manage / admin,与主应用菜单权限一致 |
order |
可选:同组内排序,数值越小越靠前 |
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
return [
{
"nav_key": "main",
"title": "示例订阅页",
"icon": "mdi-rss",
"section": "subscribe",
"permission": "subscribe",
"order": 10,
}
]
同一插件多个全页界面(多 nav_key)
在 get_sidebar_nav 中返回多条记录,每条使用不同的 nav_key / title / section 等,侧栏与「更多」中会出现多个入口,路由形如 #/plugin-app/<插件ID>/<nav_key>。
前端加载远程组件的顺序为:
nav_key |
依次尝试的联邦暴露名 |
|---|---|
main 或省略 |
./AppPage → ./Page |
其它(如 settings、my_tool) |
./AppPage{PascalCase} → ./AppPage → ./Page |
PascalCase 规则:按 -、_、空格分段后首字母大写并拼接。例如 nav_key=settings → 先试 ./AppPageSettings;my_tool → ./AppPageMyTool。
两种实现方式(二选一或混用):
- 单文件分支:只暴露
./AppPage,在组件内根据navKeyprop 用v-if/<component>切换子界面。 - 多文件:为某个入口单独暴露
./AppPageSettings.vue等,主应用会优先加载对应模块,失败再回退到AppPage。
vite.config 多暴露示例:
exposes: {
'./AppPage': './src/components/AppPage.vue',
'./AppPageSettings': './src/components/AppPageSettings.vue',
// ...
}
6. 构建和部署
构建项目
yarn build
- 将生成的dist文件夹上传到插件后端目录下(默认为
dist/assets)
注意: __federation_shared_vuetify 目录以及 index-、date-、runtime- 开头的文件不需要上传,只需要上传以下命名格式文件:__federation_*、_plugin-vue_export-helper-*、remoteEntry.js
- 在插件的后端python代码中,实现以下方法来集成远程组件:
def get_render_mode() -> Tuple[str, str]:
"""
获取插件渲染模式
:return: 1、渲染模式,支持:vue/vuetify,默认vuetify
:return: 2、组件路径,默认 dist/assets
"""
return "vue", "dist/assets"
- 需要在插件前端页面调用后端接口时,通过传入的api模块发起调用,后端api接口声明认证类型为:
bear
// 演示使用api模块调用插件接口
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
def get_api(self) -> List[Dict[str, Any]]:
"""
注册插件API
"""
return [
{
"path": "/history",
"endpoint": self.get_history,
"methods": ["GET"],
"auth": "bear", # 认证类型设为bear
"summary": "查询历史记录"
}
]
7. 调试与排错
常见问题
-
模块无法加载
- 检查网络请求是否成功(状态码200)
- 确认文件路径是否正确
- 检查CORS跨域设置
-
模块加载但组件不显示
- 检查控制台错误信息
- 确认组件是否正确导出
- 验证共享依赖配置
-
"Module name 'vue' does not resolve to a valid URL"
- 检查
shared配置是否正确 - 设置
requiredVersion: false尝试解决
- 检查
-
"Top-level await is not available"
- 确保
build.target设置为esnext
- 确保
8. 高级配置
8.1 CSS隔离
为防止样式冲突,建议使用CSS Modules或scoped样式:
<style scoped>
/* 组件样式 */
</style>
8.2 共享更多依赖
如果您的插件需要共享更多依赖,可以扩展shared配置:
shared: {
vue: { requiredVersion: false },
vuetify: { requiredVersion: false },
'@vueuse/core': { requiredVersion: false },
pinia: { requiredVersion: false }
}
8.3 本地监听构建
插件前端可使用 Vite 的监听构建模式:
yarn dev
将 dev 脚本配置为 vite build --watch 后,源码变化会自动重新构建。使用本地插件仓并启用 DEV 或 PLUGIN_AUTO_RELOAD 时,MoviePilot 会同步新的构建产物;刷新页面即可看到修改。
9. 示例代码
- 插件远程组件示例 - 开发插件组件的完整示例项目
- 模块联邦问题排查指南 - 常见问题排查
10. 参考资料
如有问题,请提交Issue。