Files
shanxia2.0/web-admin/src/utils/router/portal-menu.ts
T
2026-10-05 12:57:51 +08:00

366 lines
11 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.
import type { AppRouteRecord } from '@/types/router'
import { getFirstMenuPath, getFirstSecondLevelMenuPath, isIframe } from '@/utils/navigation/route'
import { filterSidebarRoutes, isSidebarNavigableRoute } from '@/utils/router/sidebar-menu-filter'
/** 大面板一行:二级标题 + 三级可点链接 */
export interface PortalMegaPanelRow {
/** 二级菜单(仅展示标题,不可点) */
l2: AppRouteRecord
/** 三级链接(含虚拟三级) */
l3Links: AppRouteRecord[]
}
export interface PortalMegaPanelColumns {
left: PortalMegaPanelRow[]
right: PortalMegaPanelRow[]
}
/** 左侧菜单 / 门户一级模块展示顺序 */
export const PORTAL_TOP_MODULE_ORDER = [
'/home',
'/crm',
'/pl',
'/vehicle-mgmt',
'/db',
'/bx',
'/duty',
'/system',
'/workflow'
] as const
export type PortalTopModulePath = (typeof PORTAL_TOP_MODULE_ORDER)[number]
/** 门户默认一级模块(登录后 / 关闭全部标签等场景的默认落点) */
export const PORTAL_DEFAULT_MODULE_PATH: PortalTopModulePath = '/crm'
export function isDashboardMenuItem(item: AppRouteRecord): boolean {
const path = String(item.path ?? '')
const title = String(item.meta?.title ?? '')
const name = String(item.name ?? '')
return (
path === '/dashboard' ||
title === '控制台' ||
name === 'Dashboard' ||
title === 'menus.dashboard.title'
)
}
/**
* 过滤并排序门户一级模块(排除控制台与 isHide)
*/
export function filterPortalTopModules(menuList: AppRouteRecord[]): AppRouteRecord[] {
const visible = (menuList || []).filter(
(item) =>
!item.meta?.isHide &&
!isDashboardMenuItem(item) &&
String(item.path ?? '') !== '/home' &&
String(item.name ?? '') !== 'PortalHome'
)
const orderMap = new Map<string, number>(
PORTAL_TOP_MODULE_ORDER.map((path, index) => [path, index])
)
return visible.sort((a, b) => {
const orderA = orderMap.get(String(a.path ?? '')) ?? 999
const orderB = orderMap.get(String(b.path ?? '')) ?? 999
return orderA - orderB
})
}
export function resolveTopModulePath(routePath: string): string {
const segment = routePath.split('/').filter(Boolean)[0]
return segment ? `/${segment}` : ''
}
export function resolveActivePortalModule(
menuList: AppRouteRecord[],
routePath: string
): AppRouteRecord | undefined {
const tops = filterPortalTopModules(menuList)
const activeTopPath = resolveTopModulePath(routePath)
return tops.find((menu) => menu.path === activeTopPath)
}
function isRouteInMenuTree(item: AppRouteRecord, targetPath: string): boolean {
const activePath = String(targetPath)
const itemPath = String(item.meta?.activePath || item.path || '')
if (itemPath && (activePath === itemPath || activePath.startsWith(`${itemPath}/`))) {
return true
}
if (item.children?.length) {
return item.children.some((child) => isRouteInMenuTree(child, activePath))
}
return false
}
/** 判断一级模块卡片是否为当前路由所属模块 */
export function isPortalModuleActive(item: AppRouteRecord, routePath: string): boolean {
const modulePath = String(item.path || '')
const activePath = String(routePath)
if (modulePath && (activePath === modulePath || activePath.startsWith(`${modulePath}/`))) {
return true
}
return isRouteInMenuTree(item, activePath)
}
/** 模块首个可访问叶子路径(用于点击卡片跳转) */
export function getFirstAccessiblePath(module: AppRouteRecord): string {
if (module.redirect) {
return String(module.redirect)
}
const children = module.children ? filterSidebarRoutes(module.children) : []
if (children.length) {
const childPath = getFirstMenuPath(children, module.path)
if (childPath) {
return childPath
}
}
return module.path || '/'
}
function findIframeSubMenus(currentPath: string, menuList: AppRouteRecord[]): AppRouteRecord[] {
const hasPath = (items: AppRouteRecord[]): boolean => {
for (const item of items) {
if (item.path === currentPath) {
return true
}
if (item.children?.length && hasPath(item.children)) {
return true
}
}
return false
}
for (const menu of menuList) {
if (menu.children?.length && hasPath(menu.children)) {
return menu.children
}
}
return []
}
/** 解析当前路由所属一级模块(路径前缀 + 菜单树包含) */
export function resolveActiveTopMenu(
menuList: AppRouteRecord[],
routePath: string
): AppRouteRecord | undefined {
const allMenus = (menuList || []).filter((menu) => !menu.meta?.isHide)
const activePath = String(routePath || '')
if (!activePath) return undefined
const bySegment = resolveTopModulePath(activePath)
if (bySegment) {
const exact = allMenus.find((menu) => menu.path === bySegment)
if (exact) return exact
}
return allMenus.find((menu) => isPortalModuleActive(menu, activePath))
}
/** 当前路由所属一级模块下的侧栏/顶栏子菜单(二三级) */
export function resolvePortalSubMenus(
menuList: AppRouteRecord[],
routePath: string,
preferredTopPath?: string
): AppRouteRecord[] {
const allMenus = menuList || []
const activePath = String(routePath)
if (isIframe(activePath)) {
return findIframeSubMenus(activePath, allMenus)
}
const preferred = preferredTopPath
? allMenus.find((menu) => menu.path === preferredTopPath && !menu.meta?.isHide)
: undefined
const currentMenu = preferred || resolveActiveTopMenu(allMenus, activePath)
return currentMenu?.children ?? []
}
/** 登录与根路径默认落点:侧栏「首页」之后的第一个业务模块首个可访问页(优先 CRM) */
export function getPortalDefaultHomePath(menuList: AppRouteRecord[]): string {
const tops = filterPortalTopModules(menuList)
const defaultModule = tops.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || tops[0]
if (defaultModule) {
return getFirstAccessiblePath(defaultModule)
}
return getFirstSecondLevelMenuPath(menuList) || getFirstMenuPath(menuList)
}
/** WorkTab 全部关闭后:当前模块首个二级路径,无则模块首个可访问页 */
export function getPortalDefaultPathForRoute(
menuList: AppRouteRecord[],
routePath: string
): string {
const module = resolveActivePortalModule(menuList, routePath)
if (!module) {
return getPortalDefaultHomePath(menuList)
}
const children = filterSidebarRoutes(resolvePortalSubMenus(menuList, routePath))
if (children.length) {
const childPath = getFirstMenuPath(children, module.path)
if (childPath) {
return childPath
}
}
return getFirstAccessiblePath(module)
}
/**
* 解析二级下的三级可点链接。
* 无子节点且二级自身可导航时,生成与二级同名的虚拟三级(路由继承二级)。
*/
export function resolveMegaPanelL3Links(l2: AppRouteRecord): AppRouteRecord[] {
const children = filterSidebarRoutes(l2.children || [])
if (!children.length) {
if (!isSidebarNavigableRoute(l2)) {
return []
}
return [
{
...l2,
children: undefined,
meta: {
...l2.meta,
title: l2.meta.title
}
}
]
}
const links: AppRouteRecord[] = []
for (const child of children) {
if (isSidebarNavigableRoute(child)) {
links.push(child)
continue
}
const grandChildren = filterSidebarRoutes(child.children || [])
for (const gc of grandChildren) {
if (isSidebarNavigableRoute(gc)) {
links.push(gc)
}
}
}
return links
}
/** 将一级模块下的二三级整理为大面板左右两栏行数据 */
export function buildMegaPanelColumns(
l1Module: AppRouteRecord | undefined
): PortalMegaPanelColumns {
if (!l1Module) {
return { left: [], right: [] }
}
const l2List = filterSidebarRoutes(l1Module.children || [])
const rows: PortalMegaPanelRow[] = []
for (const l2 of l2List) {
const l3Links = resolveMegaPanelL3Links(l2)
if (!l3Links.length) {
continue
}
rows.push({ l2, l3Links })
}
if (!rows.length) {
return { left: [], right: [] }
}
// 仅 1 个二级:单列展示,避免右侧空白
if (rows.length === 1) {
return { left: rows, right: [] }
}
// 左右均分;超出建议可见行数时由面板整体纵向滚动
// 例:4 → 2+2;7 → 4+3;10 → 5+5;12 → 6+6(不再 5+7)
const leftCount = Math.ceil(rows.length / 2)
return {
left: rows.slice(0, leftCount),
right: rows.slice(leftCount)
}
}
/** 判断三级链接是否为当前路由 */
export function isMegaPanelLinkActive(item: AppRouteRecord, routePath: string): boolean {
const activePath = String(routePath)
const itemPath = String(item.meta?.activePath || item.path || '')
if (!itemPath) {
return false
}
return activePath === itemPath || activePath.startsWith(`${itemPath}/`)
}
/** 顶栏二级平铺项间距(与 `.portal-flat-nav__bar` gap 一致) */
export const PORTAL_FLAT_NAV_ITEM_GAP = 2
/**
* 按可用宽度计算可平铺的二级数量;其余应收入「更多」。
* @param itemWidths 各二级项实测宽度(含 caret)
* @param moreWidth 「更多」按钮宽度
* @param availableWidth 中间导航槽可用宽度
*/
export function calcPortalFlatNavVisibleCount(
itemWidths: number[],
moreWidth: number,
availableWidth: number,
gap: number = PORTAL_FLAT_NAV_ITEM_GAP
): number {
if (!itemWidths.length || availableWidth <= 0) {
return 0
}
const total = itemWidths.reduce((sum, width, index) => sum + width + (index ? gap : 0), 0)
if (total <= availableWidth) {
return itemWidths.length
}
let used = 0
let count = 0
for (let i = 0; i < itemWidths.length; i++) {
const next = used + (count ? gap : 0) + itemWidths[i]
const needMore = i < itemWidths.length - 1
const limit = needMore ? availableWidth - moreWidth - gap : availableWidth
if (next <= limit + 0.5) {
used = next
count++
} else {
break
}
}
return Math.max(0, count)
}
/** 二级是否存在真实子级(需展开下拉,不可直接跳转) */
export function l2HasExpandableChildren(l2: AppRouteRecord): boolean {
return filterSidebarRoutes(l2.children || []).length > 0
}
/**
* 当前一级模块下可展示的二级列表。
* 仅保留至少有一个可导航目标(真实三级或虚拟自指)的二级。
*/
export function listPortalFlatNavL2Items(l1Module: AppRouteRecord | undefined): AppRouteRecord[] {
if (!l1Module) {
return []
}
const l2List = filterSidebarRoutes(l1Module.children || [])
return l2List.filter((l2) => resolveMegaPanelL3Links(l2).length > 0)
}
/** 二级(含其下三级)是否匹配当前路由 */
export function isPortalL2Active(l2: AppRouteRecord, routePath: string): boolean {
return resolveMegaPanelL3Links(l2).some((link) => isMegaPanelLinkActive(link, routePath))
}