提交
This commit is contained in:
1 parent
59fafb92d5
commit
6abfb6e251
1512 files changed
+1565614
-275592
No files matched your search
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { Edit, Delete, Filter, Download } from '@element-plus/icons-vue'
|
||||
import {
|
||||
overflowEditItem,
|
||||
overflowDeleteItem,
|
||||
overflowClearFiltersItem,
|
||||
overflowExportPlaceholder
|
||||
} from './list-toolbar-overflow-items'
|
||||
|
||||
describe('overflowEditItem', () => {
|
||||
it('uses key edit and wires onClick/disabled', () => {
|
||||
let clicked = false
|
||||
const item = overflowEditItem({
|
||||
disabled: true,
|
||||
onClick: () => {
|
||||
clicked = true
|
||||
}
|
||||
})
|
||||
assert.equal(item.key, 'edit')
|
||||
assert.equal(item.label, '编辑')
|
||||
assert.equal(item.disabled, true)
|
||||
assert.equal(item.icon, Edit)
|
||||
item.onClick?.()
|
||||
assert.equal(clicked, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('overflowDeleteItem', () => {
|
||||
it('uses danger type and key delete', () => {
|
||||
const item = overflowDeleteItem({ onClick: () => {} })
|
||||
assert.equal(item.key, 'delete')
|
||||
assert.equal(item.type, 'danger')
|
||||
assert.equal(item.icon, Delete)
|
||||
})
|
||||
})
|
||||
|
||||
describe('overflowClearFiltersItem', () => {
|
||||
it('hides when no active filters', () => {
|
||||
const hidden = overflowClearFiltersItem(false, () => {})
|
||||
assert.equal(hidden.key, 'clearFilters')
|
||||
assert.equal(hidden.hidden, true)
|
||||
assert.equal(hidden.icon, Filter)
|
||||
const shown = overflowClearFiltersItem(true, () => {})
|
||||
assert.equal(shown.hidden, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('overflowExportPlaceholder', () => {
|
||||
it('returns export key for slot rendering', () => {
|
||||
const item = overflowExportPlaceholder()
|
||||
assert.equal(item.key, 'export')
|
||||
assert.equal(item.label, '导出')
|
||||
assert.equal(item.icon, Download)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Component } from 'vue'
|
||||
import { Delete, Download, Edit, Filter } from '@element-plus/icons-vue'
|
||||
import type { ListToolbarOverflowItem } from '@/components/business/list-toolbar-overflow/types'
|
||||
|
||||
type ClickOpts = {
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
label?: string
|
||||
icon?: Component
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function overflowEditItem(opts: ClickOpts): ListToolbarOverflowItem {
|
||||
return {
|
||||
key: 'edit',
|
||||
label: opts.label ?? '编辑',
|
||||
icon: opts.icon ?? Edit,
|
||||
disabled: opts.disabled,
|
||||
loading: opts.loading,
|
||||
onClick: opts.onClick
|
||||
}
|
||||
}
|
||||
|
||||
export function overflowDeleteItem(opts: ClickOpts): ListToolbarOverflowItem {
|
||||
return {
|
||||
key: 'delete',
|
||||
label: opts.label ?? '删除',
|
||||
icon: opts.icon ?? Delete,
|
||||
type: 'danger',
|
||||
disabled: opts.disabled,
|
||||
loading: opts.loading,
|
||||
onClick: opts.onClick
|
||||
}
|
||||
}
|
||||
|
||||
export function overflowClearFiltersItem(
|
||||
hasActive: boolean,
|
||||
onClear: () => void
|
||||
): ListToolbarOverflowItem {
|
||||
return {
|
||||
key: 'clearFilters',
|
||||
label: '清空列过滤',
|
||||
icon: Filter,
|
||||
hidden: !hasActive,
|
||||
onClick: onClear
|
||||
}
|
||||
}
|
||||
|
||||
export function overflowExportPlaceholder(
|
||||
label = '导出'
|
||||
): ListToolbarOverflowItem {
|
||||
return {
|
||||
key: 'export',
|
||||
label,
|
||||
icon: Download
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/** 列表工具栏「更多」收纳:项间距(与 `.list-toolbar-row` gap 一致) */
|
||||
export const LIST_TOOLBAR_OVERFLOW_GAP = 8
|
||||
|
||||
/**
|
||||
* 按可用宽度计算可平铺的工具栏按钮数量;其余应收入「更多」。
|
||||
* 算法与门户顶栏二级平铺一致:从左保留,空间不足时预留「更多」宽度。
|
||||
*/
|
||||
export function calcListToolbarOverflowVisibleCount(
|
||||
itemWidths: number[],
|
||||
moreWidth: number,
|
||||
availableWidth: number,
|
||||
gap: number = LIST_TOOLBAR_OVERFLOW_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)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { sumLeafBadgeCounts } from './menu-badge-sum'
|
||||
|
||||
describe('sumLeafBadgeCounts', () => {
|
||||
it('sums descendant leaf menuCode counts', () => {
|
||||
const node = {
|
||||
path: '/bx/advance',
|
||||
component: undefined,
|
||||
meta: { title: '备用金' },
|
||||
children: [
|
||||
{ path: '/bx/advance/apply', component: '/x', meta: { title: '申请', menuCode: 'bx:advanceApply:list' } },
|
||||
{ path: '/bx/advance/approve', component: '/x', meta: { title: '审批', menuCode: 'bx:advanceApprove:list' } },
|
||||
{
|
||||
path: '/bx/advance/issue',
|
||||
component: '/x',
|
||||
meta: { title: '发放', isHide: true, menuCode: 'bx:advanceIssue:list' }
|
||||
}
|
||||
]
|
||||
} as any
|
||||
const text = sumLeafBadgeCounts(node, (code) => {
|
||||
if (code === 'bx:advanceApprove:list') return 3
|
||||
if (code === 'bx:advanceIssue:list') return 9
|
||||
return 0
|
||||
})
|
||||
assert.equal(text, '3')
|
||||
})
|
||||
|
||||
it('returns empty when sum is 0', () => {
|
||||
const node = {
|
||||
path: '/bx/config',
|
||||
component: undefined,
|
||||
meta: { title: '基础配置' },
|
||||
children: [{ path: '/bx/config/a', component: '/x', meta: { title: 'A', menuCode: 'x' } }]
|
||||
} as any
|
||||
assert.equal(sumLeafBadgeCounts(node, () => 0), '')
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
import { useMenuBadgeStore } from '@/store/modules/menu-badge'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { sumLeafBadgeCounts } from '@/utils/menu-badge-sum'
|
||||
|
||||
export { sumLeafBadgeCounts }
|
||||
|
||||
function unwrapData<T = any>(res: unknown): T {
|
||||
return ((res as any)?.data ?? res) as T
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { classifyMenuNode, getVisibleChildren } from '@/utils/router/leaf-group'
|
||||
|
||||
export function sumLeafBadgeCounts(
|
||||
item: AppRouteRecord,
|
||||
getCount: (menuCode: string) => number
|
||||
): string {
|
||||
let total = 0
|
||||
const visit = (node: AppRouteRecord) => {
|
||||
if (node.meta?.isHide) return
|
||||
if (classifyMenuNode(node) === 'leaf') {
|
||||
const code = String(node.meta?.menuCode || '').trim()
|
||||
if (code) total += Number(getCount(code) || 0)
|
||||
return
|
||||
}
|
||||
for (const child of getVisibleChildren(node)) visit(child)
|
||||
}
|
||||
visit(item)
|
||||
return total > 0 ? String(total) : ''
|
||||
}
|
||||
@@ -18,6 +18,14 @@ import { AppRouteRecord } from '@/types/router'
|
||||
import { router } from '@/router'
|
||||
import { isNavigableMenuItem } from './route'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import {
|
||||
classifyMenuNode,
|
||||
enterGroupTargetPath,
|
||||
firstEnterableGroupNode,
|
||||
findMenuNodeByPath,
|
||||
leafGroupFromNode
|
||||
} from '@/utils/router/leaf-group'
|
||||
|
||||
// 打开外部链接
|
||||
export const openExternalLink = (link: string) => {
|
||||
@@ -71,3 +79,31 @@ export const handleMenuJump = (item: AppRouteRecord, jumpToFirst: boolean = fals
|
||||
// 跳转到子菜单路径
|
||||
router.push(firstChild.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入叶子分组:解析菜单节点对应的末级/混合分组,并跳转到组内目标页。
|
||||
* 已在组内时不跳转(由 enterGroupTargetPath 判定)。
|
||||
*/
|
||||
export const enterLeafGroup = (item: AppRouteRecord) => {
|
||||
const { link, isIframe } = item.meta
|
||||
if (link && !isIframe) {
|
||||
return openExternalLink(link)
|
||||
}
|
||||
const full =
|
||||
findMenuNodeByPath(useMenuStore().menuList, String(item.path || '')) || item
|
||||
const kind = classifyMenuNode(full)
|
||||
const groupNode =
|
||||
kind === 'last-non-leaf' || kind === 'mixed' ? full : firstEnterableGroupNode(full)
|
||||
const group = groupNode ? leafGroupFromNode(groupNode) : null
|
||||
if (!group) {
|
||||
return handleMenuJump(full, false)
|
||||
}
|
||||
const current = router.currentRoute.value
|
||||
const target = enterGroupTargetPath(
|
||||
group,
|
||||
String(current.path || ''),
|
||||
current.meta?.activePath as string | undefined
|
||||
)
|
||||
if (!target) return
|
||||
return router.push(target)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import type { AppRouteRecord } from '../../types/router'
|
||||
import {
|
||||
classifyMenuNode,
|
||||
enterGroupTargetPath,
|
||||
firstLeafPath,
|
||||
layoutOutletCacheKey,
|
||||
resolveHighlightedLeafPath,
|
||||
resolveLeafGroup,
|
||||
routeBelongsToGroup,
|
||||
toNonLeafSidebarTree
|
||||
} from './leaf-group'
|
||||
|
||||
function item(
|
||||
path: string,
|
||||
title: string,
|
||||
extra: Partial<AppRouteRecord> & { meta?: Partial<AppRouteRecord['meta']> } = {}
|
||||
): AppRouteRecord {
|
||||
const { meta, children, component, name, ...rest } = extra
|
||||
return {
|
||||
path,
|
||||
name: name || path,
|
||||
component: component === undefined ? '/x' : component,
|
||||
children,
|
||||
meta: { title, ...(meta || {}) },
|
||||
...rest
|
||||
}
|
||||
}
|
||||
|
||||
const bxConfig = item('/bx/config', '基础配置', {
|
||||
component: undefined,
|
||||
children: [
|
||||
item('/bx/config/expense-item', '费用项目管理'),
|
||||
item('/bx/config/workflow-advance', '工作流配置(备用金)'),
|
||||
item('/bx/config/workflow-reimburse', '工作流配置(报销单)')
|
||||
]
|
||||
})
|
||||
|
||||
const bxAdvance = item('/bx/advance', '备用金', {
|
||||
component: undefined,
|
||||
children: [
|
||||
item('/bx/advance/apply', '备用金申请'),
|
||||
item('/bx/advance/approve', '备用金审批'),
|
||||
item('/bx/advance/issue', '备用金发放')
|
||||
]
|
||||
})
|
||||
|
||||
const bx = item('/bx', '报销管理', {
|
||||
component: undefined,
|
||||
children: [bxConfig, bxAdvance]
|
||||
})
|
||||
|
||||
const quoteForm = item('/crm/quote/form', '报价单表单', {
|
||||
meta: { title: '报价单表单', isHide: true }
|
||||
})
|
||||
|
||||
const crmProductGroup = item('/crm/product-group', '产品管理', {
|
||||
component: undefined,
|
||||
children: [item('/crm/product', '产品管理')]
|
||||
})
|
||||
|
||||
const crmQuoteGroup = item('/crm/quote-group', '报价单管理', {
|
||||
component: undefined,
|
||||
children: [
|
||||
item('/crm/quote', '报价单管理'),
|
||||
quoteForm,
|
||||
item('/crm/quote-approval', '报价单审批')
|
||||
]
|
||||
})
|
||||
|
||||
const crm = item('/crm', 'CRM管理', {
|
||||
component: undefined,
|
||||
children: [crmProductGroup, crmQuoteGroup]
|
||||
})
|
||||
|
||||
const mixed = item('/sys', '系统', {
|
||||
component: undefined,
|
||||
children: [
|
||||
item('/sys/user', '用户管理'),
|
||||
item('/sys/role-group', '角色分组', {
|
||||
component: undefined,
|
||||
children: [item('/sys/role-group/role', '角色')]
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const tree = [bx, crm, mixed]
|
||||
|
||||
describe('classifyMenuNode', () => {
|
||||
it('marks folders of only leaves as last-non-leaf', () => {
|
||||
assert.equal(classifyMenuNode(bxConfig), 'last-non-leaf')
|
||||
assert.equal(classifyMenuNode(bx), 'intermediate')
|
||||
assert.equal(classifyMenuNode(crm), 'intermediate')
|
||||
assert.equal(classifyMenuNode(crmQuoteGroup), 'last-non-leaf')
|
||||
assert.equal(classifyMenuNode(mixed), 'mixed')
|
||||
assert.equal(classifyMenuNode(item('/crm/product', '产品管理')), 'leaf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toNonLeafSidebarTree', () => {
|
||||
it('drops leaves and keeps last-non-leaf as empty-children nodes', () => {
|
||||
const side = toNonLeafSidebarTree(tree)
|
||||
const bxSide = side.find((n) => n.path === '/bx')
|
||||
assert.ok(bxSide?.children)
|
||||
assert.deepEqual(
|
||||
bxSide.children.map((c) => c.path),
|
||||
['/bx/config', '/bx/advance']
|
||||
)
|
||||
assert.equal(bxSide.children[0].children?.length || 0, 0)
|
||||
const crmSide = side.find((n) => n.path === '/crm')
|
||||
assert.deepEqual(
|
||||
crmSide?.children?.map((c) => c.path),
|
||||
['/crm/product-group', '/crm/quote-group']
|
||||
)
|
||||
assert.equal(crmSide?.children?.[0].children?.length || 0, 0)
|
||||
const mixedSide = side.find((n) => n.path === '/sys')
|
||||
assert.deepEqual(
|
||||
mixedSide?.children?.map((c) => c.path),
|
||||
['/sys/role-group']
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps top-level navigable leaves such as 首页', () => {
|
||||
const home = item('/home', '首页')
|
||||
const side = toNonLeafSidebarTree([home, bx])
|
||||
assert.deepEqual(
|
||||
side.map((n) => n.path),
|
||||
['/home', '/bx']
|
||||
)
|
||||
})
|
||||
|
||||
it('still drops nested leaves', () => {
|
||||
const side = toNonLeafSidebarTree(tree)
|
||||
const bxSide = side.find((n) => n.path === '/bx')
|
||||
assert.ok(!bxSide?.children?.some((c) => c.path === '/bx/config/expense-item'))
|
||||
})
|
||||
|
||||
it('keeps group nodes if applied twice (sidebar must not drop stripped folders)', () => {
|
||||
const once = toNonLeafSidebarTree(tree)
|
||||
const twice = toNonLeafSidebarTree(once)
|
||||
assert.deepEqual(
|
||||
twice.map((n) => n.path),
|
||||
once.map((n) => n.path)
|
||||
)
|
||||
const bxOnce = once.find((n) => n.path === '/bx')
|
||||
const bxTwice = twice.find((n) => n.path === '/bx')
|
||||
assert.deepEqual(
|
||||
bxTwice?.children?.map((c) => c.path),
|
||||
bxOnce?.children?.map((c) => c.path)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLeafGroup', () => {
|
||||
it('opens config leaves for a config page', () => {
|
||||
const g = resolveLeafGroup(tree, '/bx/config/expense-item')
|
||||
assert.equal(g?.groupPath, '/bx/config')
|
||||
assert.deepEqual(
|
||||
g?.leaves.map((l) => l.path),
|
||||
[
|
||||
'/bx/config/expense-item',
|
||||
'/bx/config/workflow-advance',
|
||||
'/bx/config/workflow-reimburse'
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('opens quote group leaves for a CRM quote page and omits hidden leaves', () => {
|
||||
const g = resolveLeafGroup(tree, '/crm/quote')
|
||||
assert.equal(g?.groupPath, '/crm/quote-group')
|
||||
assert.deepEqual(
|
||||
g?.leaves.map((l) => l.path),
|
||||
['/crm/quote', '/crm/quote-approval']
|
||||
)
|
||||
})
|
||||
|
||||
it('maps hidden form to quote leaf via longest prefix', () => {
|
||||
const g = resolveLeafGroup(tree, '/crm/quote/form')
|
||||
assert.equal(g?.groupPath, '/crm/quote-group')
|
||||
assert.equal(resolveHighlightedLeafPath(g!, '/crm/quote/form'), '/crm/quote')
|
||||
})
|
||||
|
||||
it('uses mixed node direct leaves only', () => {
|
||||
const g = resolveLeafGroup(tree, '/sys/user')
|
||||
assert.equal(g?.groupPath, '/sys')
|
||||
assert.deepEqual(
|
||||
g?.leaves.map((l) => l.path),
|
||||
['/sys/user']
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null on home-like paths with no group', () => {
|
||||
assert.equal(resolveLeafGroup(tree, '/home'), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('routeBelongsToGroup / enterGroupTargetPath', () => {
|
||||
it('stays when already in group including hidden pages', () => {
|
||||
const g = resolveLeafGroup(tree, '/crm/quote')!
|
||||
assert.equal(routeBelongsToGroup(g, '/crm/quote/form'), true)
|
||||
assert.equal(enterGroupTargetPath(g, '/crm/quote/form'), null)
|
||||
assert.equal(enterGroupTargetPath(g, '/bx/advance/apply'), '/crm/quote')
|
||||
})
|
||||
|
||||
it('firstLeafPath follows menu order', () => {
|
||||
const g = resolveLeafGroup(tree, '/bx/advance/apply')!
|
||||
assert.equal(firstLeafPath(g), '/bx/advance/apply')
|
||||
})
|
||||
})
|
||||
|
||||
describe('layoutOutletCacheKey', () => {
|
||||
it('keeps RouteParent stable across quote list and approval tabs', () => {
|
||||
const groupId = '/crm/quote-group'
|
||||
assert.equal(
|
||||
layoutOutletCacheKey({
|
||||
nestedMatchedCount: 3,
|
||||
leafGroupId: groupId,
|
||||
routePath: '/crm/quote-approval'
|
||||
}),
|
||||
groupId
|
||||
)
|
||||
assert.equal(
|
||||
layoutOutletCacheKey({
|
||||
nestedMatchedCount: 3,
|
||||
leafGroupId: groupId,
|
||||
routePath: '/crm/quote'
|
||||
}),
|
||||
groupId
|
||||
)
|
||||
})
|
||||
|
||||
it('uses route path when layout renders the leaf directly', () => {
|
||||
assert.equal(
|
||||
layoutOutletCacheKey({
|
||||
nestedMatchedCount: 2,
|
||||
leafGroupId: '/bx/advance',
|
||||
routePath: '/bx/advance/apply'
|
||||
}),
|
||||
'/bx/advance/apply'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the same RouteParent for hidden pages in the group', () => {
|
||||
assert.equal(
|
||||
layoutOutletCacheKey({
|
||||
nestedMatchedCount: 3,
|
||||
leafGroupId: '/crm/quote-group',
|
||||
routePath: '/crm/quote/form'
|
||||
}),
|
||||
'/crm/quote-group'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { isSidebarNavigableRoute } from '@/utils/router/sidebar-menu-filter'
|
||||
|
||||
export type MenuNodeKind =
|
||||
| 'leaf'
|
||||
| 'last-non-leaf'
|
||||
| 'mixed'
|
||||
| 'intermediate'
|
||||
| 'empty'
|
||||
|
||||
export interface LeafGroup {
|
||||
groupId: string
|
||||
groupPath: string
|
||||
title: string
|
||||
leaves: AppRouteRecord[]
|
||||
}
|
||||
|
||||
export function isHiddenMenuItem(item: AppRouteRecord): boolean {
|
||||
return !!item.meta?.isHide
|
||||
}
|
||||
|
||||
export function getVisibleChildren(item: AppRouteRecord): AppRouteRecord[] {
|
||||
return (item.children || []).filter((child) => !isHiddenMenuItem(child))
|
||||
}
|
||||
|
||||
function isFolderChild(child: AppRouteRecord): boolean {
|
||||
if (child.meta?.sidebarLeafGroup) return true
|
||||
return getVisibleChildren(child).length > 0
|
||||
}
|
||||
|
||||
export function classifyMenuNode(item: AppRouteRecord): MenuNodeKind {
|
||||
if (item.meta?.sidebarLeafGroup) return 'last-non-leaf'
|
||||
|
||||
const visible = getVisibleChildren(item)
|
||||
if (visible.length === 0) {
|
||||
return isSidebarNavigableRoute(item) ? 'leaf' : 'empty'
|
||||
}
|
||||
|
||||
const hasFolder = visible.some(isFolderChild)
|
||||
const hasLeaf = visible.some(
|
||||
(child) => !isFolderChild(child) && isSidebarNavigableRoute(child)
|
||||
)
|
||||
|
||||
if (hasFolder && hasLeaf) return 'mixed'
|
||||
if (hasFolder) return 'intermediate'
|
||||
if (hasLeaf) return 'last-non-leaf'
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
export function toNonLeafSidebarTree(
|
||||
items: AppRouteRecord[],
|
||||
level = 0
|
||||
): AppRouteRecord[] {
|
||||
const result: AppRouteRecord[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const kind = classifyMenuNode(item)
|
||||
if (isHiddenMenuItem(item) || kind === 'empty') continue
|
||||
if (kind === 'leaf') {
|
||||
if (level === 0 && isSidebarNavigableRoute(item)) {
|
||||
result.push(item)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (kind === 'last-non-leaf') {
|
||||
result.push({
|
||||
...item,
|
||||
children: undefined,
|
||||
meta: { ...item.meta, sidebarLeafGroup: true }
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (kind === 'mixed') {
|
||||
const folderChildren = getVisibleChildren(item).filter(isFolderChild)
|
||||
result.push({
|
||||
...item,
|
||||
meta: { ...item.meta, sidebarLeafGroup: true },
|
||||
children: toNonLeafSidebarTree(folderChildren, level + 1)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
children: toNonLeafSidebarTree(getVisibleChildren(item), level + 1)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function visibleGroupLeaves(node: AppRouteRecord): AppRouteRecord[] {
|
||||
const kind = classifyMenuNode(node)
|
||||
if (kind === 'last-non-leaf') {
|
||||
return getVisibleChildren(node).filter(isSidebarNavigableRoute)
|
||||
}
|
||||
if (kind === 'mixed') {
|
||||
return getVisibleChildren(node).filter(
|
||||
(child) => !isFolderChild(child) && isSidebarNavigableRoute(child)
|
||||
)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function leafGroupFromNode(node: AppRouteRecord): LeafGroup | null {
|
||||
const kind = classifyMenuNode(node)
|
||||
if (kind !== 'last-non-leaf' && kind !== 'mixed') return null
|
||||
|
||||
const groupPath = node.path || ''
|
||||
return {
|
||||
groupId: groupPath,
|
||||
groupPath,
|
||||
title: node.meta.title,
|
||||
leaves: visibleGroupLeaves(node)
|
||||
}
|
||||
}
|
||||
|
||||
export function findMenuNodeByPath(
|
||||
items: AppRouteRecord[],
|
||||
path: string
|
||||
): AppRouteRecord | undefined {
|
||||
return findNodeWithAncestors(items, path)?.node
|
||||
}
|
||||
|
||||
function findNodeWithAncestors(
|
||||
items: AppRouteRecord[],
|
||||
targetPath: string,
|
||||
ancestors: AppRouteRecord[] = []
|
||||
): { node: AppRouteRecord; ancestors: AppRouteRecord[] } | undefined {
|
||||
for (const item of items) {
|
||||
if (item.path === targetPath) {
|
||||
return { node: item, ancestors }
|
||||
}
|
||||
if (item.children?.length) {
|
||||
const found = findNodeWithAncestors(item.children, targetPath, [...ancestors, item])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pathMatchesPrefix(currentPath: string, prefix: string): boolean {
|
||||
return currentPath === prefix || currentPath.startsWith(`${prefix}/`)
|
||||
}
|
||||
|
||||
function groupFromAncestors(located: {
|
||||
node: AppRouteRecord
|
||||
ancestors: AppRouteRecord[]
|
||||
}): LeafGroup | null {
|
||||
const chain = [located.node, ...[...located.ancestors].reverse()]
|
||||
for (const node of chain) {
|
||||
const group = leafGroupFromNode(node)
|
||||
if (group) return group
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveLeafGroup(
|
||||
tree: AppRouteRecord[],
|
||||
currentPath: string,
|
||||
activePath?: string
|
||||
): LeafGroup | null {
|
||||
const matchPath = activePath || currentPath
|
||||
const located = findNodeWithAncestors(tree, matchPath)
|
||||
if (located) {
|
||||
return groupFromAncestors(located)
|
||||
}
|
||||
|
||||
const candidates: LeafGroup[] = []
|
||||
|
||||
function walk(items: AppRouteRecord[]) {
|
||||
for (const item of items) {
|
||||
const kind = classifyMenuNode(item)
|
||||
const groupPath = item.path || ''
|
||||
|
||||
if (
|
||||
(kind === 'last-non-leaf' || kind === 'mixed') &&
|
||||
pathMatchesPrefix(matchPath, groupPath)
|
||||
) {
|
||||
const group = leafGroupFromNode(item)
|
||||
if (group) candidates.push(group)
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
walk(item.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(tree)
|
||||
if (candidates.length === 0) return null
|
||||
|
||||
candidates.sort((a, b) => b.groupPath.length - a.groupPath.length)
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
export function resolveHighlightedLeafPath(
|
||||
group: LeafGroup,
|
||||
currentPath: string,
|
||||
activePath?: string
|
||||
): string {
|
||||
const matchPath = activePath || currentPath
|
||||
const exact = group.leaves.find((leaf) => leaf.path === matchPath)
|
||||
if (exact?.path) return exact.path
|
||||
|
||||
let best = ''
|
||||
let bestLen = 0
|
||||
for (const leaf of group.leaves) {
|
||||
const leafPath = leaf.path || ''
|
||||
if (pathMatchesPrefix(matchPath, leafPath) && leafPath.length > bestLen) {
|
||||
best = leafPath
|
||||
bestLen = leafPath.length
|
||||
}
|
||||
}
|
||||
|
||||
return best || matchPath
|
||||
}
|
||||
|
||||
export function routeBelongsToGroup(
|
||||
group: LeafGroup,
|
||||
path: string,
|
||||
activePath?: string
|
||||
): boolean {
|
||||
const matchPath = activePath || path
|
||||
if (group.leaves.some((leaf) => leaf.path === matchPath)) return true
|
||||
|
||||
const highlighted = resolveHighlightedLeafPath(group, path, activePath)
|
||||
return group.leaves.some((leaf) => leaf.path === highlighted)
|
||||
}
|
||||
|
||||
export function firstLeafPath(group: LeafGroup): string {
|
||||
return group.leaves[0]?.path || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 布局层 KeepAlive 的子节点 key。
|
||||
* 只要当前 URL 属于某业务组且路由深度为「布局 → 目录 → 叶子/隐藏页」,
|
||||
* 布局层只缓存一份目录 RouteParent(key=groupId)。
|
||||
* 组内 Tab 与隐藏页都走该 RouteParent 的内部 RouterView,禁止再按叶子 path
|
||||
* 复制嵌套出口(否则会 insertBefore / 功能互相串)。
|
||||
*/
|
||||
export function layoutOutletCacheKey(options: {
|
||||
nestedMatchedCount: number
|
||||
leafGroupId: string
|
||||
routePath: string
|
||||
}): string {
|
||||
if (options.leafGroupId && options.nestedMatchedCount >= 3) {
|
||||
return options.leafGroupId
|
||||
}
|
||||
return options.routePath
|
||||
}
|
||||
|
||||
export function enterGroupTargetPath(
|
||||
group: LeafGroup,
|
||||
currentPath: string,
|
||||
activePath?: string
|
||||
): string | null {
|
||||
if (routeBelongsToGroup(group, currentPath, activePath)) return null
|
||||
return firstLeafPath(group) || null
|
||||
}
|
||||
|
||||
export function firstEnterableGroupNode(item: AppRouteRecord): AppRouteRecord | null {
|
||||
const kind = classifyMenuNode(item)
|
||||
if (kind === 'last-non-leaf' || kind === 'mixed') return item
|
||||
if (kind !== 'intermediate') return null
|
||||
for (const child of getVisibleChildren(item)) {
|
||||
const found = firstEnterableGroupNode(child)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const DEFAULT_MENU_ITEM_ICON = 'ri:file-list-2-line'
|
||||
* 键与 router/modules 中 name 一致,便于前后端菜单对齐。
|
||||
*/
|
||||
export const SIDEBAR_MENU_ICON_MAP: Record<string, string> = {
|
||||
PortalHome: 'ri:home-line',
|
||||
// ── 报销管理 ──
|
||||
BxManagement: 'ri:wallet-3-line',
|
||||
BxConfig: 'ri:settings-4-line',
|
||||
@@ -73,9 +74,15 @@ export const SIDEBAR_MENU_ICON_MAP: Record<string, string> = {
|
||||
// ── 运输 ──
|
||||
TransportPlan: 'ri:truck-line',
|
||||
PlPlanModule: 'ri:route-line',
|
||||
PlArchiveModule: 'ri:archive-line',
|
||||
PlCertUpload: 'ri:shield-check-line',
|
||||
PlFreightModule: 'ri:money-cny-box-line',
|
||||
PlVehicleModule: 'ri:car-line',
|
||||
PlVehicleRechargeGroup: 'ri:gas-station-line',
|
||||
PlVehicleFuelGroup: 'ri:drop-line',
|
||||
PlVehicleMaintGroup: 'ri:tools-line',
|
||||
PlVehicleFuelRegister: 'ri:gas-station-line',
|
||||
PlVehicleFuelSummary: 'ri:bar-chart-2-line',
|
||||
PlDocumentReminderModule: 'ri:alarm-warning-line',
|
||||
PlBaseInfo: 'ri:database-2-line',
|
||||
PlCarrier: 'ri:building-2-line',
|
||||
@@ -150,6 +157,8 @@ export const SIDEBAR_MENU_ICON_BY_CODE: Record<string, string> = {
|
||||
'pl:vehicle-recharge-workflow:list': 'ri:flow-chart',
|
||||
'pl:vehicle-maintenance-apply:list': 'ri:tools-line',
|
||||
'pl:vehicle-maintenance-process:list': 'ri:settings-3-line',
|
||||
'pl:vehicle-fuel-register:list': 'ri:gas-station-line',
|
||||
'pl:vehicle-fuel-summary:list': 'ri:bar-chart-2-line',
|
||||
'pl:vehicle-urea-apply:list': 'ri:drop-line',
|
||||
'pl:vehicle-urea-disburse:list': 'ri:drop-line',
|
||||
'pl:document:list': 'ri:alarm-warning-line'
|
||||
|
||||
@@ -15,15 +15,17 @@ export interface PortalMegaPanelColumns {
|
||||
right: PortalMegaPanelRow[]
|
||||
}
|
||||
|
||||
/** 门户顶栏一级模块展示顺序(与路由注册顺序无关) */
|
||||
/** 左侧菜单 / 门户一级模块展示顺序 */
|
||||
export const PORTAL_TOP_MODULE_ORDER = [
|
||||
'/home',
|
||||
'/crm',
|
||||
'/bx',
|
||||
'/db',
|
||||
'/pl',
|
||||
'/vehicle-mgmt',
|
||||
'/db',
|
||||
'/bx',
|
||||
'/duty',
|
||||
'/workflow',
|
||||
'/system'
|
||||
'/system',
|
||||
'/workflow'
|
||||
] as const
|
||||
|
||||
export type PortalTopModulePath = (typeof PORTAL_TOP_MODULE_ORDER)[number]
|
||||
@@ -48,7 +50,11 @@ export function isDashboardMenuItem(item: AppRouteRecord): boolean {
|
||||
*/
|
||||
export function filterPortalTopModules(menuList: AppRouteRecord[]): AppRouteRecord[] {
|
||||
const visible = (menuList || []).filter(
|
||||
(item) => !item.meta?.isHide && !isDashboardMenuItem(item)
|
||||
(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])
|
||||
@@ -141,10 +147,29 @@ function findIframeSubMenus(currentPath: string, menuList: AppRouteRecord[]): Ap
|
||||
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
|
||||
routePath: string,
|
||||
preferredTopPath?: string
|
||||
): AppRouteRecord[] {
|
||||
const allMenus = menuList || []
|
||||
const activePath = String(routePath)
|
||||
@@ -153,16 +178,14 @@ export function resolvePortalSubMenus(
|
||||
return findIframeSubMenus(activePath, allMenus)
|
||||
}
|
||||
|
||||
const currentTopPath = resolveTopModulePath(activePath)
|
||||
if (!currentTopPath) {
|
||||
return []
|
||||
}
|
||||
|
||||
const currentMenu = allMenus.find((menu) => menu.path === currentTopPath)
|
||||
const preferred = preferredTopPath
|
||||
? allMenus.find((menu) => menu.path === preferredTopPath && !menu.meta?.isHide)
|
||||
: undefined
|
||||
const currentMenu = preferred || resolveActiveTopMenu(allMenus, activePath)
|
||||
return currentMenu?.children ?? []
|
||||
}
|
||||
|
||||
/** 门户默认首页:优先 CRM 管理模块首个可访问页 */
|
||||
/** 登录与根路径默认落点:侧栏「首页」之后的第一个业务模块首个可访问页(优先 CRM) */
|
||||
export function getPortalDefaultHomePath(menuList: AppRouteRecord[]): string {
|
||||
const tops = filterPortalTopModules(menuList)
|
||||
const defaultModule = tops.find((item) => item.path === PORTAL_DEFAULT_MODULE_PATH) || tops[0]
|
||||
|
||||
Reference in new issue
Block a user