mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +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:
@@ -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)
|
||||
|
||||
|
||||
+17
-10
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
+2
-1
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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