2024-11-14 21:10:46 +08:00

473 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
*
* 该文件为 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: 追加操作pushfalse: 覆盖操作。
*/
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'],
},
},
)