mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-06 20:43:03 +08:00
feat(plugin): 侧栏全页 AppPage、多 nav_key 联邦加载与 sidebar_nav 缓存
- 新增路由 plugin-app 与壳页,按 nav_key 尝试 AppPage{Pascal}/AppPage/Page
- DefaultLayout 与 appcenter 合并插件侧栏项;plugin/sidebar_nav 经 Pinia 去重缓存
- 工具 pluginSidebarNav、联邦 loader 与文档/示例更新;登出时清空侧栏缓存
Made-with: Cursor
This commit is contained in:
@@ -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({
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.4 AppPage 组件(侧栏全页)
|
||||
|
||||
用于主应用左侧导航中的独立页面(路由 `#/plugin-app/:pluginId/:navKey?`),占据默认布局下的主内容区;与 `Page` 不同,不嵌在插件管理弹窗中。
|
||||
|
||||
主应用传入的 props:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
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>
|
||||
```
|
||||
|
||||
#### 后端:注册侧栏入口
|
||||
|
||||
插件需为 **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>`。
|
||||
|
||||
前端加载远程组件的顺序为:
|
||||
|
||||
| `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` / `<component>` 切换子界面。
|
||||
2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`。
|
||||
|
||||
`vite.config` 多暴露示例:
|
||||
|
||||
```typescript
|
||||
exposes: {
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 构建和部署
|
||||
|
||||
### 构建项目
|
||||
|
||||
@@ -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和模块联邦配置
|
||||
|
||||
32
examples/plugin-component/src/components/AppPage.vue
Normal file
32
examples/plugin-component/src/components/AppPage.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 侧栏全页:在主应用 #/plugin-app/:pluginId/:navKey 中渲染,占据主内容区。
|
||||
* 需在插件后端实现 get_sidebar_nav 才会出现在侧栏。
|
||||
*/
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
navKey: {
|
||||
type: String,
|
||||
default: 'main',
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-app-page pa-4">
|
||||
<div class="text-h6 mb-2">AppPage(侧栏全页)</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||
pluginId: {{ pluginId }} · navKey: {{ navKey }}
|
||||
</div>
|
||||
<v-btn size="small" variant="tonal" @click="emit('action')">action</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
17
examples/plugin-component/src/components/AppPageSettings.vue
Normal file
17
examples/plugin-component/src/components/AppPageSettings.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 示例:nav_key=settings 时主应用会优先加载 AppPageSettings,再回退 AppPage。
|
||||
*/
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
navKey: { type: String, default: 'settings' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<div class="text-subtitle-1">Settings 子界面(AppPageSettings)</div>
|
||||
<div class="text-caption text-medium-emphasis">navKey={{ navKey }} · pluginId={{ pluginId }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 从 Store 中获取用户信息
|
||||
const userStore = useUserStore()
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
@@ -20,14 +21,22 @@ const userPermissions = computed(() => ({
|
||||
// 应用分组(以header分组)
|
||||
const appGroups = ref<Record<string, NavMenu[]>>({})
|
||||
|
||||
// 根据header属性对应用进行分类
|
||||
function categorizeApps() {
|
||||
// 获取所有菜单并根据权限过滤
|
||||
// 根据header属性对应用进行分类(含插件侧栏项,与桌面端侧栏一致)
|
||||
async function categorizeApps() {
|
||||
const allMenus = getNavMenus(t)
|
||||
const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)
|
||||
const menus = filteredMenus.filter((item: NavMenu) => !item.footer)
|
||||
let menus = filteredMenus.filter((item: NavMenu) => !item.footer)
|
||||
|
||||
await pluginSidebarNavStore.ensureSidebarNav()
|
||||
if (pluginSidebarNavStore.items.length > 0) {
|
||||
const pluginNavMenus = filterPluginSidebarNavEntries(
|
||||
pluginSidebarNavStore.items,
|
||||
t,
|
||||
userPermissions.value,
|
||||
).map(e => e.navMenu)
|
||||
menus = [...menus, ...pluginNavMenus]
|
||||
}
|
||||
|
||||
// 按header属性分组
|
||||
const groupedMenus: Record<string, NavMenu[]> = {}
|
||||
|
||||
menus.forEach(menu => {
|
||||
@@ -38,11 +47,9 @@ function categorizeApps() {
|
||||
groupedMenus[header].push(menu)
|
||||
})
|
||||
|
||||
// 将分组结果赋值给响应式变量
|
||||
appGroups.value = groupedMenus
|
||||
}
|
||||
|
||||
// 页面加载时对应用进行分类
|
||||
onMounted(() => {
|
||||
categorizeApps()
|
||||
})
|
||||
@@ -60,7 +67,7 @@ onMounted(() => {
|
||||
<VList lines="one" class="settings-list">
|
||||
<VListItem
|
||||
v-for="(app, appIndex) in apps"
|
||||
:key="appIndex"
|
||||
:key="`${header}-${appIndex}-${String(app.to)}`"
|
||||
:to="app.to || ''"
|
||||
color="primary"
|
||||
class="settings-list-item"
|
||||
|
||||
50
src/pages/plugin-app.vue
Normal file
50
src/pages/plugin-app.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import api from '@/api'
|
||||
import { loadRemoteAppPageComponent } from '@/utils/federationLoader'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const pluginId = computed(() => route.params.pluginId as string)
|
||||
const navKey = computed(() => (route.params.navKey as string) || 'main')
|
||||
|
||||
const RemoteView = shallowRef<Component | null>(null)
|
||||
const loadError = ref(false)
|
||||
|
||||
watch(
|
||||
[pluginId, navKey],
|
||||
async ([pid, nk]) => {
|
||||
loadError.value = false
|
||||
if (!pid) {
|
||||
RemoteView.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
RemoteView.value = (await loadRemoteAppPageComponent(pid, nk)) as Component
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
RemoteView.value = null
|
||||
loadError.value = true
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-app-page">
|
||||
<VAlert v-if="loadError" type="error" class="ma-4" title="组件加载错误">
|
||||
无法加载插件全页组件。多入口时请暴露 AppPage 或 AppPage{Pascal}(见文档),并确认插件已启用。
|
||||
</VAlert>
|
||||
<VSkeletonLoader v-else-if="!RemoteView" class="ma-4" type="article, article, article" />
|
||||
<component
|
||||
v-else
|
||||
:is="RemoteView"
|
||||
:key="`${pluginId}-${navKey}`"
|
||||
:api="api"
|
||||
:nav-key="navKey"
|
||||
:plugin-id="pluginId"
|
||||
@action="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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<string, string> = {
|
||||
start: 'menu.start',
|
||||
discovery: 'menu.discovery',
|
||||
subscribe: 'menu.subscribe',
|
||||
organize: 'menu.organize',
|
||||
system: 'menu.system',
|
||||
}
|
||||
return t(map[section] ?? 'menu.system')
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
49
src/stores/pluginSidebarNav.ts
Normal file
49
src/stores/pluginSidebarNav.ts
Normal file
@@ -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<void> | null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* 确保侧栏导航数据已加载;已缓存则直接返回,并发调用共享同一请求。
|
||||
* @param force 为 true 时忽略缓存重新请求(如登出后再登录可配合 reset + ensure)
|
||||
*/
|
||||
async ensureSidebarNav(force = false): Promise<void> {
|
||||
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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -29,6 +29,58 @@ async function fetchSingleRemoteModule(id: string): Promise<RemoteModule | null>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 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
|
||||
|
||||
54
src/utils/pluginSidebarNav.ts
Normal file
54
src/utils/pluginSidebarNav.ts
Normal file
@@ -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<string, unknown>,
|
||||
): 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
|
||||
}
|
||||
Reference in New Issue
Block a user