366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
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))
|
||
}
|