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:
DDSRem
2026-04-09 07:59:40 +08:00
parent 9cf782eb5b
commit e72f9a8374
16 changed files with 449 additions and 21 deletions
+11
View File
@@ -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
+34 -2
View File
@@ -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)
+17 -10
View File
@@ -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
View 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>
+17
View File
@@ -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')
}
+9
View File
@@ -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'),
+2
View File
@@ -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()
},
},
+2 -1
View File
@@ -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
View 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
},
},
})
+52
View File
@@ -29,6 +29,58 @@ async function fetchSingleRemoteModule(id: string): Promise<RemoteModule | null>
}
}
/**
* 将 nav_key 转为联邦暴露名的 Pascal 片段(如 settings -> Settingsmy-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
View 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
}