mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
Merge pull request #458 from DDSRem-Dev/v2
This commit is contained in:
@@ -16,13 +16,17 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
|
|||||||
|
|
||||||
## 3. 核心概念
|
## 3. 核心概念
|
||||||
|
|
||||||
每个插件需要提供三个标准组件:
|
每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口):
|
||||||
|
|
||||||
| 组件名称 | 文件名 | 用途 |
|
| 组件名称 | 暴露名 | 文件名 | 用途 |
|
||||||
|---------|-------|------|
|
|---------|--------|--------|------|
|
||||||
| Page | Page.vue | 插件详情页面 |
|
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
|
||||||
| Config | Config.vue | 插件配置页面 |
|
| Config | `./Config` | Config.vue | 插件配置页面 |
|
||||||
| Dashboard | Dashboard.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. 快速开始
|
## 4. 快速开始
|
||||||
|
|
||||||
@@ -56,6 +60,8 @@ export default defineConfig({
|
|||||||
'./Page': './src/components/Page.vue',
|
'./Page': './src/components/Page.vue',
|
||||||
'./Config': './src/components/Config.vue',
|
'./Config': './src/components/Config.vue',
|
||||||
'./Dashboard': './src/components/Dashboard.vue',
|
'./Dashboard': './src/components/Dashboard.vue',
|
||||||
|
'./AppPage': './src/components/AppPage.vue',
|
||||||
|
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||||
},
|
},
|
||||||
shared: {
|
shared: {
|
||||||
vue: {
|
vue: {
|
||||||
@@ -264,6 +270,91 @@ const props = defineProps({
|
|||||||
</template>
|
</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. 构建和部署
|
## 6. 构建和部署
|
||||||
|
|
||||||
### 构建项目
|
### 构建项目
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# MoviePilot 插件远程组件示例
|
# MoviePilot 插件远程组件示例
|
||||||
|
|
||||||
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例实现了三个标准组件:Page(详情页面)、Config(配置页面)和Dashboard(仪表板组件)。
|
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例包含 Page、Config、Dashboard、AppPage,以及可选的 `AppPageSettings`(`nav_key=settings` 时由主应用优先加载,用于演示「一插件多全页界面」)。
|
||||||
|
|
||||||
## 1. 开发环境准备
|
## 1. 开发环境准备
|
||||||
|
|
||||||
@@ -28,7 +28,9 @@ plugin-component/
|
|||||||
│ ├── components/
|
│ ├── components/
|
||||||
│ │ ├── Page.vue # 插件详情页面组件
|
│ │ ├── Page.vue # 插件详情页面组件
|
||||||
│ │ ├── Config.vue # 插件配置页面组件
|
│ │ ├── Config.vue # 插件配置页面组件
|
||||||
│ │ └── Dashboard.vue # 插件仪表板组件
|
│ │ ├── Dashboard.vue # 插件仪表板组件
|
||||||
|
│ │ ├── AppPage.vue # 侧栏全页(主内容区,nav_key=main)
|
||||||
|
│ │ └── AppPageSettings.vue # 可选第二全页(nav_key=settings)
|
||||||
│ ├── App.vue # 本地开发入口组件
|
│ ├── App.vue # 本地开发入口组件
|
||||||
│ └── main.js # 本地开发入口文件
|
│ └── main.js # 本地开发入口文件
|
||||||
├── vite.config.js # Vite和模块联邦配置
|
├── vite.config.js # Vite和模块联邦配置
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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',
|
'./Page': './src/components/Page.vue',
|
||||||
'./Config': './src/components/Config.vue',
|
'./Config': './src/components/Config.vue',
|
||||||
'./Dashboard': './src/components/Dashboard.vue',
|
'./Dashboard': './src/components/Dashboard.vue',
|
||||||
|
'./AppPage': './src/components/AppPage.vue',
|
||||||
|
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||||
},
|
},
|
||||||
shared: {
|
shared: {
|
||||||
vue: {
|
vue: {
|
||||||
|
|||||||
@@ -656,6 +656,17 @@ export interface Plugin {
|
|||||||
page_open?: boolean
|
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 {
|
export interface RenderProps {
|
||||||
component: string
|
component: string
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
|||||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||||
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
||||||
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
||||||
import { useUserStore } from '@/stores'
|
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||||
import { getNavMenus } from '@/router/i18n-menu'
|
import { getNavMenus } from '@/router/i18n-menu'
|
||||||
|
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||||
import { NavMenu } from '@/@layouts/types'
|
import { NavMenu } from '@/@layouts/types'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -30,6 +31,7 @@ const route = useRoute()
|
|||||||
|
|
||||||
// 用户 Store
|
// 用户 Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||||
|
|
||||||
// 响应式的超级用户状态
|
// 响应式的超级用户状态
|
||||||
const superUser = computed(() => userStore.superUser)
|
const superUser = computed(() => userStore.superUser)
|
||||||
@@ -229,7 +231,34 @@ function handlePluginClick() {
|
|||||||
showPluginQuickAccess.value = false
|
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'))
|
startMenus.value = getMenuList(t('menu.start'))
|
||||||
discoveryMenus.value = getMenuList(t('menu.discovery'))
|
discoveryMenus.value = getMenuList(t('menu.discovery'))
|
||||||
@@ -237,6 +266,9 @@ onMounted(() => {
|
|||||||
organizeMenus.value = getMenuList(t('menu.organize'))
|
organizeMenus.value = getMenuList(t('menu.organize'))
|
||||||
systemMenus.value = getMenuList(t('menu.system'))
|
systemMenus.value = getMenuList(t('menu.system'))
|
||||||
|
|
||||||
|
await pluginSidebarNavStore.ensureSidebarNav()
|
||||||
|
appendPluginSidebarMenus()
|
||||||
|
|
||||||
// 监听全局未读消息事件
|
// 监听全局未读消息事件
|
||||||
const unsubscribe = onUnreadMessage(handleUnreadMessage)
|
const unsubscribe = onUnreadMessage(handleUnreadMessage)
|
||||||
|
|
||||||
|
|||||||
+17
-10
@@ -1,15 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NavMenu } from '@/@layouts/types'
|
import { NavMenu } from '@/@layouts/types'
|
||||||
import { getNavMenus } from '@/router/i18n-menu'
|
import { getNavMenus } from '@/router/i18n-menu'
|
||||||
import { useUserStore } from '@/stores'
|
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||||
import { filterMenusByPermission } from '@/utils/permission'
|
import { filterMenusByPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 从 Store 中获取用户信息
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||||
|
|
||||||
// 获取用户权限信息
|
// 获取用户权限信息
|
||||||
const userPermissions = computed(() => ({
|
const userPermissions = computed(() => ({
|
||||||
@@ -20,14 +21,22 @@ const userPermissions = computed(() => ({
|
|||||||
// 应用分组(以header分组)
|
// 应用分组(以header分组)
|
||||||
const appGroups = ref<Record<string, NavMenu[]>>({})
|
const appGroups = ref<Record<string, NavMenu[]>>({})
|
||||||
|
|
||||||
// 根据header属性对应用进行分类
|
// 根据header属性对应用进行分类(含插件侧栏项,与桌面端侧栏一致)
|
||||||
function categorizeApps() {
|
async function categorizeApps() {
|
||||||
// 获取所有菜单并根据权限过滤
|
|
||||||
const allMenus = getNavMenus(t)
|
const allMenus = getNavMenus(t)
|
||||||
const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)
|
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[]> = {}
|
const groupedMenus: Record<string, NavMenu[]> = {}
|
||||||
|
|
||||||
menus.forEach(menu => {
|
menus.forEach(menu => {
|
||||||
@@ -38,11 +47,9 @@ function categorizeApps() {
|
|||||||
groupedMenus[header].push(menu)
|
groupedMenus[header].push(menu)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 将分组结果赋值给响应式变量
|
|
||||||
appGroups.value = groupedMenus
|
appGroups.value = groupedMenus
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面加载时对应用进行分类
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
categorizeApps()
|
categorizeApps()
|
||||||
})
|
})
|
||||||
@@ -60,7 +67,7 @@ onMounted(() => {
|
|||||||
<VList lines="one" class="settings-list">
|
<VList lines="one" class="settings-list">
|
||||||
<VListItem
|
<VListItem
|
||||||
v-for="(app, appIndex) in apps"
|
v-for="(app, appIndex) in apps"
|
||||||
:key="appIndex"
|
:key="`${header}-${appIndex}-${String(app.to)}`"
|
||||||
:to="app.to || ''"
|
:to="app.to || ''"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="settings-list-item"
|
class="settings-list-item"
|
||||||
|
|||||||
@@ -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,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/plugin-app/:pluginId/:navKey?',
|
||||||
|
name: 'plugin-app',
|
||||||
|
component: () => import('../pages/plugin-app.vue'),
|
||||||
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/setting',
|
path: '/setting',
|
||||||
component: () => import('../pages/setting.vue'),
|
component: () => import('../pages/setting.vue'),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { authState } from '@/stores/types'
|
import type { authState } from '@/stores/types'
|
||||||
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: (): authState => ({
|
state: (): authState => ({
|
||||||
@@ -31,6 +32,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
logout() {
|
logout() {
|
||||||
this.clearToken()
|
this.clearToken()
|
||||||
this.setOriginalPath(null)
|
this.setOriginalPath(null)
|
||||||
|
usePluginSidebarNavStore().reset()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -13,5 +13,6 @@ export default pinia
|
|||||||
import { useAuthStore } from './auth'
|
import { useAuthStore } from './auth'
|
||||||
import { useUserStore } from './user'
|
import { useUserStore } from './user'
|
||||||
import { useGlobalSettingsStore } from './global'
|
import { useGlobalSettingsStore } from './global'
|
||||||
|
import { usePluginSidebarNavStore } from './pluginSidebarNav'
|
||||||
|
|
||||||
export { useAuthStore, useUserStore, useGlobalSettingsStore }
|
export { useAuthStore, useUserStore, useGlobalSettingsStore, usePluginSidebarNavStore }
|
||||||
|
|||||||
@@ -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
|
* @param id 远程模块ID
|
||||||
|
|||||||
@@ -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