mirror of
https://github.com/XiaoDaiGua-Ray/ray-template.git
synced 2025-04-05 07:03:00 +08:00
473 lines
13 KiB
TypeScript
473 lines
13 KiB
TypeScript
/**
|
||
*
|
||
* 该文件为 menu 菜单 pinia store
|
||
*
|
||
* 说明:
|
||
* - BreadcrumbMenu、TagMenu、Menu 统一管理
|
||
* - BreadcrumbMenu、TagMenu、Menu 属性值重度依赖 vue-router routers, 所以需要按照该项目约定方法进行配置
|
||
*
|
||
* 缓存(sessionStorage):
|
||
* - breadcrumbOptions
|
||
* - menuKey
|
||
* - menuTagOptions
|
||
*
|
||
* 备注:
|
||
* - parseAndFindMatchingNodes: 如果需要反向查找完整的父子节点,可以使用该方法
|
||
*/
|
||
|
||
import { NEllipsis } from 'naive-ui'
|
||
|
||
import { setStorage, equalRouterPath, updateObjectValue } from '@/utils'
|
||
import { validRole, validMenuItemShow, canSkipRoute } from '@/router/utils'
|
||
import {
|
||
parseAndFindMatchingNodes,
|
||
updateDocumentTitle,
|
||
createMenuIcon,
|
||
getCatchMenuKey,
|
||
createMenuExtra,
|
||
} from './utils'
|
||
import { useI18n } from '@/hooks'
|
||
import { getAppRawRoutes } from '@/router/app-route-modules'
|
||
import { useKeepAliveActions } from '@/store'
|
||
import { APP_CATCH_KEY } from '@/app-config'
|
||
import { pick } from 'lodash-es'
|
||
import { pickRouteRecordNormalizedConstant } from './constant'
|
||
|
||
import type { AppMenuOption, MenuTagOptions } from '@/types'
|
||
import type { MenuState } from '@/store/modules/menu/types'
|
||
import type { LocationQuery } from 'vue-router'
|
||
import type { UpdateMenuState } from './types'
|
||
|
||
let cachePreNormal: AppMenuOption | undefined = void 0
|
||
|
||
/**
|
||
*
|
||
* @param options 菜单列表或者类似菜单列表的数据结构
|
||
* @param target 目标路径
|
||
*
|
||
* @returns 匹配的菜单项
|
||
*
|
||
* @description
|
||
* 递归查找匹配的菜单项,缓存上一次的匹配项。
|
||
* 并且该方法一旦匹配成功就会立即返回。
|
||
*
|
||
* 通过 fullPath 进行匹配。
|
||
*
|
||
* @example
|
||
* depthSearchAppMenu([{ path: '/dashboard', name: 'Dashboard', meta: { i18nKey: 'menu.Dashboard' } }], '/dashboard')
|
||
*/
|
||
export const depthSearchAppMenu = (
|
||
options: AppMenuOption[],
|
||
target: string,
|
||
) => {
|
||
if (cachePreNormal && equalRouterPath(cachePreNormal.fullPath, target)) {
|
||
return cachePreNormal
|
||
}
|
||
|
||
for (const curr of options) {
|
||
if (equalRouterPath(curr.fullPath, target)) {
|
||
cachePreNormal = curr
|
||
|
||
return curr
|
||
}
|
||
|
||
if (curr.children?.length) {
|
||
depthSearchAppMenu(curr.children, target)
|
||
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
|
||
export const piniaMenuStore = defineStore(
|
||
'menu',
|
||
() => {
|
||
const { push, getRoutes } = useRouter()
|
||
const route = useRoute()
|
||
const { t } = useI18n()
|
||
const { setKeepAliveInclude } = useKeepAliveActions()
|
||
|
||
const menuState = reactive<MenuState>({
|
||
menuKey: getCatchMenuKey(), // 当前菜单 `key`
|
||
options: [], // 菜单列表
|
||
collapsed: false, // 是否折叠菜单
|
||
menuTagOptions: [], // tag 标签菜单
|
||
breadcrumbOptions: [], // 面包屑菜单
|
||
currentMenuOption: null, // 当前激活菜单项
|
||
})
|
||
const isSetupAppMenuLock = ref(true)
|
||
|
||
/**
|
||
*
|
||
* @param key menu state key
|
||
* @param value updated value
|
||
*
|
||
* @description
|
||
* 更新 menu state 指定 key 的值。
|
||
*/
|
||
const updateMenuState: UpdateMenuState = (key, value, cb) => {
|
||
updateObjectValue(menuState, key, value, cb)
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param option 菜单项(类似于菜单项的数据结构也可以)
|
||
*
|
||
* @returns 转换后的菜单项
|
||
*
|
||
* @description
|
||
* 将路由项或者类似于菜单项的数据结构转换为菜单项(AppMenu)。
|
||
* 但是,该方法有一个地方需要注意,那就是需要手动设置一下准确的 fullPath,
|
||
* 其实这是一个设计的失误,因为该方法不能准确的感知到 fullPath 应该是什么。
|
||
*
|
||
* @example
|
||
* resolveOption({ path: '/demo', fullPath: '/demo', name: 'Demo', meta: { ... } })
|
||
* resolveOption({ ...VueRouterRouteOption })
|
||
*/
|
||
const resolveOption = (option: AppMenuOption) => {
|
||
const { meta } = option
|
||
const { i18nKey, noLocalTitle } = meta
|
||
|
||
// 设置 label, i18nKey 优先级最高
|
||
const label = computed(() => (i18nKey ? t(`${i18nKey}`) : noLocalTitle))
|
||
/**
|
||
*
|
||
* 拼装菜单项
|
||
* 容错处理,兼容以前版本 key 选取为 path 的情况
|
||
*/
|
||
const route = {
|
||
...option,
|
||
key: option.fullPath,
|
||
label: () =>
|
||
h(NEllipsis, null, {
|
||
default: () => label.value,
|
||
}),
|
||
breadcrumbLabel: label.value,
|
||
} as AppMenuOption
|
||
// 合并 icon, extra
|
||
const attr: AppMenuOption = Object.assign({}, route, {
|
||
icon: createMenuIcon(option),
|
||
extra: createMenuExtra(option),
|
||
})
|
||
|
||
if (option.fullPath === getCatchMenuKey()) {
|
||
menuState.currentMenuOption = attr
|
||
}
|
||
|
||
attr.show = validMenuItemShow(attr)
|
||
|
||
return attr
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param key menu state key
|
||
*
|
||
* @description
|
||
* 设置面包屑。
|
||
*
|
||
* 如果识别到为平级模式,则会自动追加一层面包屑。
|
||
*/
|
||
const setBreadcrumbOptions = (key: string | number) => {
|
||
menuState.breadcrumbOptions = parseAndFindMatchingNodes(
|
||
menuState.options,
|
||
'fullPath',
|
||
key,
|
||
)
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param options menu tag options
|
||
* @param isAppend is append
|
||
*
|
||
* @description
|
||
* 设置标签菜单。
|
||
* true: 追加操作(push),false: 覆盖操作。
|
||
*/
|
||
const setMenuTagOptions = (
|
||
options: MenuTagOptions | MenuTagOptions[],
|
||
isAppend = true,
|
||
) => {
|
||
const isArray = Array.isArray(options)
|
||
const arr = isArray ? [...options] : [options]
|
||
|
||
isAppend
|
||
? menuState.menuTagOptions.push(...arr)
|
||
: (menuState.menuTagOptions = arr)
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param key full path
|
||
* @param option menu tag option
|
||
*
|
||
* @description
|
||
* 设置当前标签项,如果不存在则追加。
|
||
*/
|
||
const setMenuTagOptionsWhenMenuValueChange = (
|
||
key: string | number,
|
||
option: AppMenuOption,
|
||
) => {
|
||
const tag = menuState.menuTagOptions.find((curr) => curr.fullPath === key)
|
||
|
||
if (!tag) {
|
||
menuState.menuTagOptions.push(option as MenuTagOptions)
|
||
}
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param key 菜单更新后的 key
|
||
* @param option 菜单当前 option 项
|
||
* @param query 路由参数
|
||
*
|
||
* @description
|
||
* 修改 `menu key` 后的回调函数。
|
||
* 修改后,缓存当前选择 key 并且存储标签页与跳转页面(router push 操作)。
|
||
*
|
||
* 如果 windowOpen 存在, 则直接打开新窗口,不会更新当前菜单状态,也不会做其他的操作。
|
||
* 如果 sameLevel 存在,则会追加一层面包屑,并不会触发菜单更新与标签页更新。
|
||
*
|
||
* 在执行更新操作后会做一些缓存操作。
|
||
*
|
||
* 该方法是整个模板的核心驱动: 菜单、标签页、面包屑、浏览器标题等等的更新方法。
|
||
*
|
||
* @example
|
||
* changeMenuModelValue('/dashboard',{ dashboard option }) // 跳转页面至 dashboard,并且更新菜单状态、标签页、面包屑、浏览器标题等等
|
||
* changeMenuModelValue('/dashboard', { dashboard option }, { id: 1 }) // 执行更新操作,并且传递参数
|
||
*/
|
||
const changeMenuModelValue = (
|
||
key: string | number,
|
||
option: AppMenuOption,
|
||
query?: LocationQuery,
|
||
) => {
|
||
const { meta } = option
|
||
|
||
if (meta.windowOpen) {
|
||
window.open(meta.windowOpen)
|
||
} else {
|
||
push({
|
||
path: String(key),
|
||
query,
|
||
})
|
||
|
||
const { sameLevel } = meta
|
||
|
||
/** 更新缓存队列 */
|
||
setKeepAliveInclude(option)
|
||
/** 更新浏览器标题 */
|
||
updateDocumentTitle(option)
|
||
|
||
// 如果不为 sameLevel,则会执行更新:覆盖更新面包屑、添加标签菜单、更新缓存
|
||
if (!sameLevel) {
|
||
/** 更新标签菜单 */
|
||
setMenuTagOptionsWhenMenuValueChange(key, option)
|
||
/** 更新面包屑 */
|
||
setBreadcrumbOptions(key)
|
||
|
||
menuState.menuKey = key
|
||
menuState.currentMenuOption = option
|
||
|
||
/** 缓存菜单 key(sessionStorage) */
|
||
setStorage(APP_CATCH_KEY.appMenuKey, key)
|
||
} else {
|
||
// 使用 pick 提取仅需要的字段,避免 vue 抛错空引用,导致性能损耗
|
||
const breadcrumbOption = pick(
|
||
resolveOption(option),
|
||
pickRouteRecordNormalizedConstant,
|
||
) as unknown as AppMenuOption
|
||
// 查看是否重复
|
||
const find = menuState.breadcrumbOptions.find(
|
||
(curr) => curr.key === breadcrumbOption.key,
|
||
)
|
||
|
||
// 如果未重复追加
|
||
if (!find) {
|
||
menuState.breadcrumbOptions.push({
|
||
...breadcrumbOption,
|
||
fullPath: String(breadcrumbOption.key),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param path 路由地址
|
||
*
|
||
* @description
|
||
* 监听路由地址变化更新菜单状态。
|
||
*/
|
||
const updateMenuKeyWhenRouteUpdate = async (
|
||
path: string,
|
||
query: LocationQuery,
|
||
) => {
|
||
const [routePath] = path.split('?')
|
||
|
||
// 直接使用完整 url,检查是否在 routes 中
|
||
const findMenuOption = getRoutes().find((curr) => curr.path === routePath)
|
||
|
||
// 避免匹配根页面路由
|
||
if (
|
||
findMenuOption?.path === '/' ||
|
||
!findMenuOption?.path ||
|
||
findMenuOption?.path === '/login'
|
||
) {
|
||
return
|
||
}
|
||
|
||
if (findMenuOption) {
|
||
// 使用 pick 提取仅需要的字段,避免 vue 抛错空引用,导致性能损耗
|
||
const pickOption = pick(
|
||
findMenuOption,
|
||
pickRouteRecordNormalizedConstant,
|
||
) as unknown as AppMenuOption
|
||
|
||
changeMenuModelValue(
|
||
routePath,
|
||
resolveOption({
|
||
...pickOption,
|
||
fullPath: pickOption.path,
|
||
}),
|
||
query,
|
||
)
|
||
}
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @description
|
||
* 初始化系统菜单列表,该方法仅执行一次。
|
||
* 会在初始化时拼接完整的 url 地址为 fullPath。
|
||
*
|
||
* 如果你需要手动更新菜单,可以在需要的时候调用该方法,即可刷新整个系统菜单。
|
||
*/
|
||
const setupAppMenu = () => {
|
||
return new Promise<void>((resolve) => {
|
||
const resolveRoutes = (routes: AppMenuOption[], parentPath: string) => {
|
||
const catchArr: AppMenuOption[] = []
|
||
|
||
for (const curr of routes) {
|
||
let fullPath = `${
|
||
parentPath.endsWith('/') ? parentPath : parentPath + '/'
|
||
}${curr.path}`
|
||
|
||
// 使用正则表达式替换重复的 '/'
|
||
fullPath = fullPath.replace(/\/+/g, '/')
|
||
|
||
if (curr.children?.length) {
|
||
curr.children = resolveRoutes(curr.children, fullPath)
|
||
} else if (!validRole(curr.meta)) {
|
||
continue
|
||
}
|
||
|
||
catchArr.push(
|
||
resolveOption({
|
||
...curr,
|
||
fullPath,
|
||
}),
|
||
)
|
||
}
|
||
|
||
return catchArr
|
||
}
|
||
|
||
menuState.options = resolveRoutes(
|
||
getAppRawRoutes() as AppMenuOption[],
|
||
'',
|
||
)
|
||
|
||
const r =
|
||
menuState.currentMenuOption ||
|
||
(canSkipRoute(menuState.options) as AppMenuOption)
|
||
|
||
if (r) {
|
||
// 惰性更新面包屑,避免 sameLevel 模式下的面包屑被覆盖
|
||
if (!menuState.breadcrumbOptions.length) {
|
||
setBreadcrumbOptions(r.key)
|
||
}
|
||
|
||
setMenuTagOptionsWhenMenuValueChange(r.fullPath, r)
|
||
updateDocumentTitle(r)
|
||
}
|
||
|
||
resolve()
|
||
})
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param collapsed 折叠菜单开关
|
||
*
|
||
* @description
|
||
* 折叠菜单。
|
||
*/
|
||
const collapsedMenu = (collapsed: boolean) =>
|
||
(menuState.collapsed = collapsed)
|
||
|
||
/**
|
||
*
|
||
* @param idx 当前关闭标签索引
|
||
* @param length 裁剪标签页长度
|
||
*
|
||
* @returns 被关闭标签项
|
||
*
|
||
* @description
|
||
* 关闭 menu tag 标签。
|
||
*/
|
||
const spliceMenTagOptions = (idx: number, length = 1) =>
|
||
menuState.menuTagOptions.splice(idx, length)
|
||
|
||
/**
|
||
*
|
||
* @description
|
||
* 初始化系统菜单列表。
|
||
* 该方法仅执行一次。
|
||
*/
|
||
const setupPiniaMenuStore = async () => {
|
||
if (!isSetupAppMenuLock.value) {
|
||
return
|
||
}
|
||
|
||
await setupAppMenu()
|
||
|
||
isSetupAppMenuLock.value = false
|
||
}
|
||
|
||
// 监听路由变化并且更新路由菜单与菜单标签
|
||
watch(
|
||
() => route.fullPath,
|
||
async (ndata, odata) => {
|
||
await setupPiniaMenuStore()
|
||
|
||
if (ndata !== odata) {
|
||
await updateMenuKeyWhenRouteUpdate(ndata, route.query)
|
||
}
|
||
},
|
||
{
|
||
immediate: true,
|
||
},
|
||
)
|
||
|
||
return {
|
||
...toRefs(menuState),
|
||
changeMenuModelValue,
|
||
collapsedMenu,
|
||
spliceMenTagOptions,
|
||
setMenuTagOptions,
|
||
resolveOption,
|
||
updateMenuState,
|
||
setupAppMenu,
|
||
}
|
||
},
|
||
{
|
||
persist: {
|
||
key: APP_CATCH_KEY.appPiniaMenuStore,
|
||
storage: window.localStorage,
|
||
pick: ['breadcrumbOptions', 'menuKey', 'menuTagOptions', 'collapsed'],
|
||
},
|
||
},
|
||
)
|