Files
MoviePilot-Frontend/docs/module-federation-guide.md

19 KiB
Raw Blame History

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),再回退 AppPagePagenav_keymain 时仅尝试 AppPagePage

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 玻璃光学表面

主应用的 PageConfigAppPage 宿主在玻璃主题下默认采用 static-material 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 Dashboard 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学:

<div data-glass-optical-surface data-glass-optical-mode="dynamic">
  <!-- 插件自己的视觉内容 -->
</div>

使用时需同时声明 data-glass-optical-surfacedata-glass-optical-mode="dynamic"。模式会从最近的祖先容器继承,因此显式声明的动态子表面不会沿用宿主的静态模式。该合同适用于插件在 PageConfigAppPage 中自行渲染并控制的区域;主应用生成的插件列表、插件市场卡片、Dashboard 及其他宿主 DOM 不属于插件的修改边界。

动态模式只在主应用启用玻璃主题和实时光学能力时生效。其他主题、降低动态效果或光学能力不可用时,插件必须保持内容与交互正常,不应依赖动态光学表达业务状态或必要反馈。

5.7 调用主应用原生订阅

PageConfigDashboardAppPage 都会收到 nativeSubscribe(mediaInfo) prop。插件传入媒体信息后电视剧会打开主应用的选季抽屉电影会进入现有电影订阅流程。宿主也会用 moviepilot:nativeSubscribe 键提供同一个方法,深层子组件可以使用 inject,无需逐层传递 prop。

媒体信息必须包含:

  • type电影 / 电视剧,也兼容 movie / tv
  • title
  • 至少一个有效媒体标识:tmdb_id / tmdbiddouban_id / doubanidbangumi_id / bangumiidanilist_id / anilistid,或者 media_idmediaid_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.8 调用主应用 Toast

PageConfigDashboardAppPage 的宿主容器会通过固定键提供主应用 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 一致,包括 successinfowarningerror。注入不存在时应静默降级,关键错误仍需保留页面内状态提示。

后端:注册侧栏入口

插件需为 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
其它(如 settingsmy_tool ./AppPage{PascalCase}./AppPage./Page

PascalCase 规则:按 -_、空格分段后首字母大写并拼接。例如 nav_key=settings → 先试 ./AppPageSettingsmy_tool./AppPageMyTool

两种实现方式(二选一或混用):

  1. 单文件分支:只暴露 ./AppPage,在组件内根据 navKey prop 用 v-if / <component> 切换子界面。
  2. 多文件:为某个入口单独暴露 ./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. 调试与排错

常见问题

  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

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 后,源码变化会自动重新构建。使用本地插件仓并启用 DEVPLUGIN_AUTO_RELOADMoviePilot 会同步新的构建产物;刷新页面即可看到修改。

9. 示例代码

10. 参考资料


如有问题请提交Issue。