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

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

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
}