diff --git a/docs/module-federation-guide.md b/docs/module-federation-guide.md index 9ff5a724..d442ab72 100644 --- a/docs/module-federation-guide.md +++ b/docs/module-federation-guide.md @@ -16,13 +16,17 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态 ## 3. 核心概念 -每个插件需要提供三个标准组件: +每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口): -| 组件名称 | 文件名 | 用途 | -|---------|-------|------| -| Page | Page.vue | 插件详情页面 | -| Config | Config.vue | 插件配置页面 | -| Dashboard | Dashboard.vue | 仪表板组件 | +| 组件名称 | 暴露名 | 文件名 | 用途 | +|---------|--------|--------|------| +| 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. 快速开始 @@ -56,6 +60,8 @@ export default defineConfig({ './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: { @@ -264,6 +270,91 @@ const props = defineProps({ ``` +### 5.4 AppPage 组件(侧栏全页) + +用于主应用左侧导航中的独立页面(路由 `#/plugin-app/:pluginId/:navKey?`),占据默认布局下的主内容区;与 `Page` 不同,不嵌在插件管理弹窗中。 + +主应用传入的 props: + +| 属性 | 说明 | +|------|------| +| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 | +| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 | +| `pluginId` | 当前插件 ID | + +```vue + + + + + 侧栏全页示例({{ pluginId }} / {{ navKey }}) + 通知主应用 + + +``` + +#### 后端:注册侧栏入口 + +插件需为 **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` | 可选:同组内排序,数值越小越靠前 | + +```python +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` | 依次尝试的联邦暴露名 | +|-----------|----------------------| +| `main` 或省略 | `./AppPage` → `./Page` | +| 其它(如 `settings`、`my_tool`) | `./AppPage{PascalCase}` → `./AppPage` → `./Page` | + +`PascalCase` 规则:按 `-`、`_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings`;`my_tool` → `./AppPageMyTool`。 + +**两种实现方式(二选一或混用):** + +1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `` 切换子界面。 +2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`。 + +`vite.config` 多暴露示例: + +```typescript +exposes: { + './AppPage': './src/components/AppPage.vue', + './AppPageSettings': './src/components/AppPageSettings.vue', + // ... +} +``` + ## 6. 构建和部署 ### 构建项目 diff --git a/examples/plugin-component/README.md b/examples/plugin-component/README.md index 7f1a0678..5e456ea6 100644 --- a/examples/plugin-component/README.md +++ b/examples/plugin-component/README.md @@ -1,6 +1,6 @@ # MoviePilot 插件远程组件示例 -这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例实现了三个标准组件:Page(详情页面)、Config(配置页面)和Dashboard(仪表板组件)。 +这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例包含 Page、Config、Dashboard、AppPage,以及可选的 `AppPageSettings`(`nav_key=settings` 时由主应用优先加载,用于演示「一插件多全页界面」)。 ## 1. 开发环境准备 @@ -28,7 +28,9 @@ plugin-component/ │ ├── components/ │ │ ├── Page.vue # 插件详情页面组件 │ │ ├── Config.vue # 插件配置页面组件 -│ │ └── Dashboard.vue # 插件仪表板组件 +│ │ ├── Dashboard.vue # 插件仪表板组件 +│ │ ├── AppPage.vue # 侧栏全页(主内容区,nav_key=main) +│ │ └── AppPageSettings.vue # 可选第二全页(nav_key=settings) │ ├── App.vue # 本地开发入口组件 │ └── main.js # 本地开发入口文件 ├── vite.config.js # Vite和模块联邦配置 diff --git a/examples/plugin-component/src/components/AppPage.vue b/examples/plugin-component/src/components/AppPage.vue new file mode 100644 index 00000000..d7a47d73 --- /dev/null +++ b/examples/plugin-component/src/components/AppPage.vue @@ -0,0 +1,32 @@ + + + + + AppPage(侧栏全页) + + pluginId: {{ pluginId }} · navKey: {{ navKey }} + + action + + diff --git a/examples/plugin-component/src/components/AppPageSettings.vue b/examples/plugin-component/src/components/AppPageSettings.vue new file mode 100644 index 00000000..d678b2fb --- /dev/null +++ b/examples/plugin-component/src/components/AppPageSettings.vue @@ -0,0 +1,17 @@ + + + + + Settings 子界面(AppPageSettings) + navKey={{ navKey }} · pluginId={{ pluginId }} + + diff --git a/examples/plugin-component/vite.config.js b/examples/plugin-component/vite.config.js index 6d3fb025..1a084f56 100644 --- a/examples/plugin-component/vite.config.js +++ b/examples/plugin-component/vite.config.js @@ -12,6 +12,8 @@ export default defineConfig({ './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: { diff --git a/src/api/types.ts b/src/api/types.ts index d9b4f7c9..ca0918fe 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -656,6 +656,17 @@ export interface Plugin { page_open?: boolean } +// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐) +export interface PluginSidebarNavItem { + plugin_id: string + nav_key: string + title: string + icon: string + section: 'start' | 'discovery' | 'subscribe' | 'organize' | 'system' + permission?: 'subscribe' | 'discovery' | 'search' | 'manage' | 'admin' | null + order: number +} + // 渲染结构 export interface RenderProps { component: string diff --git a/src/layouts/components/DefaultLayout.vue b/src/layouts/components/DefaultLayout.vue index 0e8677d4..4898045a 100644 --- a/src/layouts/components/DefaultLayout.vue +++ b/src/layouts/components/DefaultLayout.vue @@ -9,8 +9,9 @@ import ShortcutBar from '@/layouts/components/ShortcutBar.vue' import UserProfile from '@/layouts/components/UserProfile.vue' import QuickAccess from '@/layouts/components/QuickAccess.vue' import HeaderTab from '@/layouts/components/HeaderTab.vue' -import { useUserStore } from '@/stores' +import { usePluginSidebarNavStore, useUserStore } from '@/stores' import { getNavMenus } from '@/router/i18n-menu' +import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav' import { NavMenu } from '@/@layouts/types' import { useDisplay } from 'vuetify' import { useI18n } from 'vue-i18n' @@ -30,6 +31,7 @@ const route = useRoute() // 用户 Store const userStore = useUserStore() +const pluginSidebarNavStore = usePluginSidebarNavStore() // 响应式的超级用户状态 const superUser = computed(() => userStore.superUser) @@ -229,7 +231,34 @@ function handlePluginClick() { showPluginQuickAccess.value = false } -onMounted(() => { +function appendPluginSidebarMenus() { + for (const { navMenu, section } of filterPluginSidebarNavEntries( + pluginSidebarNavStore.items, + t, + userPermissions.value, + )) { + switch (section) { + case 'start': + startMenus.value.push(navMenu) + break + case 'discovery': + discoveryMenus.value.push(navMenu) + break + case 'subscribe': + subscribeMenus.value.push(navMenu) + break + case 'organize': + organizeMenus.value.push(navMenu) + break + case 'system': + default: + systemMenus.value.push(navMenu) + break + } + } +} + +onMounted(async () => { // 获取菜单列表 startMenus.value = getMenuList(t('menu.start')) discoveryMenus.value = getMenuList(t('menu.discovery')) @@ -237,6 +266,9 @@ onMounted(() => { organizeMenus.value = getMenuList(t('menu.organize')) systemMenus.value = getMenuList(t('menu.system')) + await pluginSidebarNavStore.ensureSidebarNav() + appendPluginSidebarMenus() + // 监听全局未读消息事件 const unsubscribe = onUnreadMessage(handleUnreadMessage) diff --git a/src/pages/appcenter.vue b/src/pages/appcenter.vue index b7b2f031..b6eb5cf9 100644 --- a/src/pages/appcenter.vue +++ b/src/pages/appcenter.vue @@ -1,15 +1,16 @@ + + + + + 无法加载插件全页组件。多入口时请暴露 AppPage 或 AppPage{Pascal}(见文档),并确认插件已启用。 + + + {}" + /> + + diff --git a/src/router/i18n-menu.ts b/src/router/i18n-menu.ts index 43a9431d..d1d9d989 100644 --- a/src/router/i18n-menu.ts +++ b/src/router/i18n-menu.ts @@ -283,3 +283,20 @@ export function getWorkflowTabs(t: Composer['t']) { }, ] } + +/** 插件侧栏分组(与后端 get_sidebar_nav 的 section 一致) */ +export type PluginSidebarSection = 'start' | 'discovery' | 'subscribe' | 'organize' | 'system' + +/** + * 将插件声明的 section 映射为与 getNavMenus 一致的已翻译 header(用于 NavMenu.header) + */ +export function pluginSidebarSectionToHeaderKey(section: string, t: Composer['t']): string { + const map: Record = { + start: 'menu.start', + discovery: 'menu.discovery', + subscribe: 'menu.subscribe', + organize: 'menu.organize', + system: 'menu.system', + } + return t(map[section] ?? 'menu.system') +} diff --git a/src/router/index.ts b/src/router/index.ts index 4866720b..c9e5d8af 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -140,6 +140,15 @@ const router = createRouter({ requiresAuth: true, }, }, + { + path: '/plugin-app/:pluginId/:navKey?', + name: 'plugin-app', + component: () => import('../pages/plugin-app.vue'), + meta: { + keepAlive: true, + requiresAuth: true, + }, + }, { path: '/setting', component: () => import('../pages/setting.vue'), diff --git a/src/stores/auth.ts b/src/stores/auth.ts index dd34deeb..386dda94 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' import type { authState } from '@/stores/types' +import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav' export const useAuthStore = defineStore('auth', { state: (): authState => ({ @@ -31,6 +32,7 @@ export const useAuthStore = defineStore('auth', { logout() { this.clearToken() this.setOriginalPath(null) + usePluginSidebarNavStore().reset() }, }, diff --git a/src/stores/index.ts b/src/stores/index.ts index 13587680..d4f064a0 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -13,5 +13,6 @@ export default pinia import { useAuthStore } from './auth' import { useUserStore } from './user' import { useGlobalSettingsStore } from './global' +import { usePluginSidebarNavStore } from './pluginSidebarNav' -export { useAuthStore, useUserStore, useGlobalSettingsStore } +export { useAuthStore, useUserStore, useGlobalSettingsStore, usePluginSidebarNavStore } diff --git a/src/stores/pluginSidebarNav.ts b/src/stores/pluginSidebarNav.ts new file mode 100644 index 00000000..b98c36fc --- /dev/null +++ b/src/stores/pluginSidebarNav.ts @@ -0,0 +1,49 @@ +import { defineStore } from 'pinia' +import api from '@/api' +import type { PluginSidebarNavItem } from '@/api/types' + +/** + * 缓存 GET plugin/sidebar_nav 结果,供 DefaultLayout 与 appcenter 等共用,避免重复请求。 + */ +export const usePluginSidebarNavStore = defineStore('pluginSidebarNav', { + state: () => ({ + items: [] as PluginSidebarNavItem[], + /** 是否已成功拉取过一次(含空数组) */ + loaded: false, + /** 并发去重:同一时刻只进行一次请求 */ + inflight: null as Promise | null, + }), + + actions: { + /** + * 确保侧栏导航数据已加载;已缓存则直接返回,并发调用共享同一请求。 + * @param force 为 true 时忽略缓存重新请求(如登出后再登录可配合 reset + ensure) + */ + async ensureSidebarNav(force = false): Promise { + if (!force && this.loaded) { + return + } + if (this.inflight) { + return this.inflight + } + this.inflight = (async () => { + try { + const res = await api.get('plugin/sidebar_nav') + this.items = Array.isArray(res) ? res : [] + } catch { + this.items = [] + } finally { + this.loaded = true + this.inflight = null + } + })() + return this.inflight + }, + + reset() { + this.items = [] + this.loaded = false + this.inflight = null + }, + }, +}) diff --git a/src/utils/federationLoader.ts b/src/utils/federationLoader.ts index 7e7fb6c1..a535baeb 100644 --- a/src/utils/federationLoader.ts +++ b/src/utils/federationLoader.ts @@ -29,6 +29,58 @@ async function fetchSingleRemoteModule(id: string): Promise } } +/** + * 将 nav_key 转为联邦暴露名的 Pascal 片段(如 settings -> Settings,my-tool -> MyTool) + */ +function navKeyToPascalSegment(navKey: string): string { + return navKey + .trim() + .split(/[-_\s]+/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join('') +} + +/** + * 加载插件全页组件(支持同一插件多界面)。 + * + * 解析顺序(nav_key 为 main 或空时): + * `AppPage` → `Page` + * + * 其它 nav_key(例如 settings、my_tool): + * `AppPage{Pascal}` → `AppPage` → `Page` + * 例:nav_key=settings → 尝试 `AppPageSettings`,再回退 `AppPage`、`Page` + * + * 也可在单个 `AppPage.vue` 内根据 `navKey` prop 分支渲染,无需多文件。 + */ +export async function loadRemoteAppPageComponent(id: string, navKey: string = 'main') { + const raw = (navKey || 'main').trim() + const isMain = raw === '' || raw.toLowerCase() === 'main' + + const candidateNames: string[] = [] + if (isMain) { + candidateNames.push('AppPage', 'Page') + } else { + const pascal = navKeyToPascalSegment(raw) + if (pascal) { + candidateNames.push(`AppPage${pascal}`) + } + candidateNames.push('AppPage', 'Page') + } + + let lastError: unknown + for (const name of candidateNames) { + try { + return await loadRemoteComponent(id, name) + } catch (error) { + lastError = error + console.debug(`[federation] 插件 ${id} 全页尝试 ./${name} 失败,回退下一候选`) + } + } + console.warn(`[federation] 插件 ${id} 全页均加载失败 (navKey=${raw})`, lastError) + throw lastError ?? new Error(`无法加载插件 ${id} 的全页组件`) +} + /** * 加载远程组件 * @param id 远程模块ID diff --git a/src/utils/pluginSidebarNav.ts b/src/utils/pluginSidebarNav.ts new file mode 100644 index 00000000..097d7ead --- /dev/null +++ b/src/utils/pluginSidebarNav.ts @@ -0,0 +1,54 @@ +import type { Composer } from 'vue-i18n' +import type { NavMenu } from '@/@layouts/types' +import type { PluginSidebarNavItem } from '@/api/types' +import { pluginSidebarSectionToHeaderKey } from '@/router/i18n-menu' +import { filterMenusByPermission } from '@/utils/permission' + +export type PluginNavMenuEntry = { + navMenu: NavMenu & { permission?: string } + section: string +} + +/** + * 将后端 sidebar_nav 单项转为侧栏 / 应用中心 共用的 NavMenu + */ +export function navMenuFromPluginSidebarItem( + item: PluginSidebarNavItem, + t: Composer['t'], +): NavMenu & { permission?: string } { + const section = item.section || 'system' + const header = pluginSidebarSectionToHeaderKey(section, t) + return { + title: item.title, + icon: item.icon, + to: { + name: 'plugin-app', + params: { + pluginId: item.plugin_id, + navKey: item.nav_key, + }, + }, + header, + permission: item.permission ?? undefined, + } as NavMenu & { permission?: string } +} + +/** + * 过滤有权限的插件导航项,并保留 section 供 DefaultLayout 分栏插入 + */ +export function filterPluginSidebarNavEntries( + items: PluginSidebarNavItem[], + t: Composer['t'], + userPermissions: Record, +): PluginNavMenuEntry[] { + const out: PluginNavMenuEntry[] = [] + for (const item of items) { + const section = item.section || 'system' + const navMenu = navMenuFromPluginSidebarItem(item, t) + if (!filterMenusByPermission([navMenu], userPermissions).length) { + continue + } + out.push({ navMenu, section }) + } + return out +}