This commit is contained in:
wuzilong committed 2026-10-05 12:57:51 +08:00
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
+20
View File
@@ -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) : ''
}
+36
View File
@@ -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'
)
})
})
+273
View File
@@ -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
}
+9
View File
@@ -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'
+37 -14
View File
@@ -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]