mirror of
https://github.com/XiaoDaiGua-Ray/ray-template.git
synced 2025-04-06 03:57:49 +08:00
403 lines
11 KiB
TypeScript
403 lines
11 KiB
TypeScript
/**
|
||
*
|
||
* @author Ray <https://github.com/XiaoDaiGua-Ray>
|
||
*
|
||
* @date 2022-11-03
|
||
*
|
||
* @workspace ray-template
|
||
*
|
||
* @remark 今天也是元气满满撸代码的一天
|
||
*/
|
||
|
||
/**
|
||
*
|
||
* 该文件为 menu 菜单 pinia store
|
||
*
|
||
* 说明:
|
||
* - BreadcrumbMenu、TagMenu、Menu 统一管理
|
||
* - BreadcrumbMenu、TagMenu、Menu 属性值重度依赖 vue-router routers, 所以需要按照该项目约定方法进行配置
|
||
*
|
||
* 缓存(sessionStorage):
|
||
* - breadcrumbOptions
|
||
* - menuKey
|
||
* - menuTagOptions
|
||
*/
|
||
|
||
import { NEllipsis } from 'naive-ui'
|
||
|
||
import { setStorage } from '@/utils/cache'
|
||
import { validRole, validMenuItemShow } from '@/router/helper/routerCopilot'
|
||
import {
|
||
parseAndFindMatchingNodes,
|
||
updateDocumentTitle,
|
||
hasMenuIcon,
|
||
getCatchMenuKey,
|
||
} from './helper'
|
||
import { useI18n } from '@/hooks/web'
|
||
import { getAppRawRoutes } from '@/router/appRouteModules'
|
||
import { throttle } from 'lodash-es'
|
||
import { useKeepAliveActions } from '@/store'
|
||
|
||
import type { AppRouteRecordRaw } from '@/router/type'
|
||
import type { AppMenuOption, MenuTagOptions } from '@/types/modules/app'
|
||
import type { MenuState } from '@/store/modules/menu/type'
|
||
import type { LocationQuery } from 'vue-router'
|
||
|
||
export const piniaMenuStore = defineStore(
|
||
'menu',
|
||
() => {
|
||
const router = 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)
|
||
const isRootPathReg = new RegExp('/', 'g')
|
||
|
||
const resolveOption = (option: AppMenuOption) => {
|
||
const { meta } = option
|
||
|
||
/** 设置 label, i18nKey 优先级最高 */
|
||
const label = computed(() =>
|
||
meta?.i18nKey ? t(`${meta!.i18nKey}`) : meta?.noLocalTitle,
|
||
)
|
||
/** 拼装菜单项 */
|
||
const route = {
|
||
...option,
|
||
key: option.path,
|
||
label: () =>
|
||
h(NEllipsis, null, {
|
||
default: () => label.value,
|
||
}),
|
||
breadcrumbLabel: label.value,
|
||
/** 检查该菜单项是否展示 */
|
||
} as AppMenuOption
|
||
/** 合并 icon */
|
||
const attr: AppMenuOption = Object.assign({}, route, {
|
||
icon: hasMenuIcon(option),
|
||
})
|
||
|
||
if (option.path === getCatchMenuKey()) {
|
||
/** 设置标签页(初始化时执行设置一次, 避免含有平级路由模式情况时出现不能正确设置标签页的情况) */
|
||
setMenuTagOptionsWhenMenuValueChange(option.path, attr)
|
||
}
|
||
|
||
attr.show = validMenuItemShow(attr)
|
||
|
||
return attr
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param options menu options
|
||
* @param key target key
|
||
*
|
||
* @remark 获取完整菜单项
|
||
*/
|
||
const getCompleteRoutePath = (
|
||
options: AppMenuOption[],
|
||
key: string | number,
|
||
) => {
|
||
const ops = parseAndFindMatchingNodes(options, 'key', key)
|
||
|
||
return ops
|
||
}
|
||
|
||
/**
|
||
*
|
||
* 设置面包屑
|
||
*
|
||
* 如果识别到为平级模式, 则会自动追加一层面包屑
|
||
*/
|
||
const setBreadcrumbOptions = (
|
||
key: string | number,
|
||
option: AppMenuOption,
|
||
) => {
|
||
const { meta } = option as unknown as AppRouteRecordRaw
|
||
|
||
menuState.breadcrumbOptions = getCompleteRoutePath(menuState.options, key)
|
||
|
||
if (meta.sameLevel) {
|
||
nextTick().then(() => {
|
||
const fd = menuState.breadcrumbOptions.find((curr) => {
|
||
return curr.path === option.path
|
||
})
|
||
|
||
if (!fd) {
|
||
menuState.breadcrumbOptions.push(option as unknown as AppMenuOption)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param options menu tag option(s)
|
||
* @param isAppend 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)
|
||
}
|
||
|
||
/** 当 url 地址发生变化触发 menuTagOptions 更新 */
|
||
const setMenuTagOptionsWhenMenuValueChange = (
|
||
key: string | number,
|
||
option: AppMenuOption,
|
||
) => {
|
||
const tag = menuState.menuTagOptions.find((curr) => curr.path === key)
|
||
|
||
if (!tag) {
|
||
menuState.menuTagOptions.push(option as MenuTagOptions)
|
||
}
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param key 菜单更新后的 key
|
||
* @param option 菜单当前 option 项
|
||
* @param query 路由参数
|
||
*
|
||
* @remark 修改 `menu key` 后的回调函数
|
||
* @remark 修改后, 缓存当前选择 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, path } = option
|
||
|
||
if (meta.windowOpen) {
|
||
window.open(meta.windowOpen)
|
||
} else {
|
||
/**
|
||
*
|
||
* key 以 `/` 开头, 则说明为根路由, 直接跳转
|
||
* key 开头未匹配到 `/`, 则需要获取到完整路由后再进行跳转
|
||
*
|
||
* 但是, 缓存 key 都以当前点击 key 为准
|
||
*/
|
||
if (!String(key).startsWith('/')) {
|
||
/** 如果不是根路由, 则拼接完整路由并跳转 */
|
||
const _path = getCompleteRoutePath(menuState.options, key)
|
||
.map((curr) => curr.key)
|
||
.join('/')
|
||
|
||
router.push({
|
||
path: _path,
|
||
query,
|
||
})
|
||
} else {
|
||
/** 根路由直接跳转 */
|
||
router.push({
|
||
path,
|
||
query,
|
||
})
|
||
}
|
||
|
||
/** 检查是否为根路由 */
|
||
const count = (path.match(isRootPathReg) || []).length
|
||
|
||
/** 更新缓存队列 */
|
||
setKeepAliveInclude(option as unknown as AppMenuOption)
|
||
/** 更新浏览器标题 */
|
||
updateDocumentTitle(option as unknown as AppMenuOption)
|
||
|
||
if (!meta.sameLevel || (meta.sameLevel && count === 1)) {
|
||
/** 更新标签菜单 */
|
||
setMenuTagOptionsWhenMenuValueChange(key, option)
|
||
/** 更新面包屑 */
|
||
setBreadcrumbOptions(key, option)
|
||
|
||
menuState.menuKey = key
|
||
/** 缓存菜单 key(sessionStorage) */
|
||
setStorage('menuKey', key)
|
||
} else {
|
||
setBreadcrumbOptions(menuState.menuKey || '', option)
|
||
}
|
||
|
||
menuState.currentMenuOption = option
|
||
}
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param path 路由地址
|
||
*
|
||
* @remark 监听路由地址变化更新菜单状态
|
||
* @remark 递归查找匹配项
|
||
*/
|
||
const updateMenuKeyWhenRouteUpdate = async (
|
||
path: string,
|
||
query: LocationQuery,
|
||
) => {
|
||
const [routePath] = path.split('?')
|
||
const count = (routePath.match(new RegExp('/', 'g')) || []).length // 如果获取长度为 1,则视为根路由
|
||
|
||
let combinePath = routePath
|
||
|
||
if (count > 1) {
|
||
// 如果不是跟路径则取出最后一项字符
|
||
const splitPath = routePath.split('/').filter((curr) => curr)
|
||
|
||
combinePath = splitPath[splitPath.length - 1]
|
||
}
|
||
|
||
// 如果当前菜单 key 与路由地址相同,说明不是手动更新 url, 则不会触发更新
|
||
if (combinePath === menuState.menuKey) {
|
||
return
|
||
}
|
||
|
||
const findMenuOption = router
|
||
.getRoutes()
|
||
.find((curr) =>
|
||
count > 1 ? path === curr.path : combinePath === curr.path,
|
||
)
|
||
|
||
if (findMenuOption) {
|
||
changeMenuModelValue(
|
||
count > 1 ? combinePath : path,
|
||
resolveOption(findMenuOption as unknown as AppMenuOption),
|
||
query,
|
||
)
|
||
}
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @remark 初始化菜单列表, 并且按照权限过滤
|
||
* @remark 如果权限发生变动, 则会触发强制弹出页面并且重新登陆
|
||
*/
|
||
const setupAppMenu = () => {
|
||
return new Promise<void>((resolve) => {
|
||
const resolveRoutes = (routes: AppMenuOption[], index: number) => {
|
||
const catchArr: AppMenuOption[] = []
|
||
|
||
for (const curr of routes) {
|
||
if (curr.children?.length) {
|
||
curr.children = resolveRoutes(curr.children, index++)
|
||
} else if (!validRole(curr.meta)) {
|
||
/** 如果校验失败, 则不会添加进 menu options */
|
||
continue
|
||
}
|
||
|
||
catchArr.push(resolveOption(curr))
|
||
}
|
||
|
||
return catchArr
|
||
}
|
||
|
||
/** 缓存菜单列表 */
|
||
menuState.options = resolveRoutes(
|
||
getAppRawRoutes() as AppMenuOption[],
|
||
0,
|
||
)
|
||
|
||
resolve()
|
||
})
|
||
}
|
||
|
||
/**
|
||
*
|
||
* @param collapsed 折叠菜单开关
|
||
*/
|
||
const collapsedMenu = (collapsed: boolean) =>
|
||
(menuState.collapsed = collapsed)
|
||
|
||
/**
|
||
*
|
||
* @param idx 当前关闭标签索引
|
||
* @param length 裁剪标签页长度
|
||
*
|
||
* @returns 被关闭标签项
|
||
*/
|
||
const spliceMenTagOptions = (idx: number, length = 1) =>
|
||
menuState.menuTagOptions.splice(idx, length)
|
||
|
||
/**
|
||
*
|
||
* @remark 置空 menuTagOptions
|
||
*
|
||
* Q: 为什么不直接使用 spliceMenTagOptions 方法置空菜单标签?
|
||
* A: 因为直接将 menuTagOptions 指向新的地址会快一点
|
||
*/
|
||
const emptyMenuTagOptions = () => {
|
||
menuState.menuTagOptions = []
|
||
}
|
||
|
||
/**
|
||
*
|
||
* 初始化系统菜单列表
|
||
* 该方法仅执行一次
|
||
*/
|
||
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: throttle(changeMenuModelValue, 500),
|
||
setupAppMenu,
|
||
collapsedMenu,
|
||
spliceMenTagOptions,
|
||
emptyMenuTagOptions,
|
||
setMenuTagOptions,
|
||
}
|
||
},
|
||
{
|
||
persist: {
|
||
key: 'piniaMenuStore',
|
||
storage: window.sessionStorage,
|
||
paths: ['breadcrumbOptions', 'menuKey', 'menuTagOptions'],
|
||
},
|
||
},
|
||
)
|