Files
shanxia2.0/docs/superpowers/plans/2026-10-04-web-admin-group-leaf-tabs.md
T
2026-10-05 12:57:51 +08:00

36 KiB
Raw Blame History

web-admin 侧栏非叶子 + 右侧叶子组 Tab Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 侧栏只展示非叶子;点击最后一层非叶子(或混合节点)后,内容区用固定组 Tab 展示该层可见叶子;默认只挂载当前路由页,同组点过的叶子 keep-alive,换组卸载。

Architecture: 纯函数 leaf-group.ts 从菜单树解析当前组与侧栏非叶子树。侧栏把最后一层非叶子渲染成可点击项(叶子不出现)。ArtGroupLeafTabs 画固定 Tab 条并 router.push。ArtPageContent 用 <KeepAlive :key="groupId"> 缓存本组已访问叶子,隐藏页走非缓存分支。不打开、不改造 WorkTab。

Tech Stack: Vue 3、Vue Router、Pinia menuList、Element Plus 侧栏、Fresh Mint Token、Node node:test + tsx

规格: docs/superpowers/specs/2026-10-04-web-admin-group-leaf-tabs-design.md

对照 Demo: docs/demos/sidebar-group-leaf-tabs-demo.html

Commit 约定: 仅在用户明确要求时执行 git commit;计划中的 Commit 步骤是逻辑切点,执行时默认跳过。


双列一级例外(相对规格正文)

规格写「中间目录只展开、不改内容」。双列/门户的一级模块切换若完全不 push,会出现「侧栏已是报销、内容仍是 CRM」的错位。实现采用:

  • 树内部的中间节点:只展开/收起,不 push。
  • 一级模块自身是 last-non-leaf / mixed(如 CRM):进组;若当前 URL 已属于该组则保持。
  • 一级模块是 intermediate(如报销管理):选中该模块并 push 其下第一个 last-non-leaf(或 mixed)的第一个可见叶子(打开第一组 Tab)。

File structure

文件 职责
web-admin/src/utils/router/leaf-group.ts 新建:可见性、节点分类、非叶子侧栏树、解析当前组、隐藏页归属、进组是否保持 URL
web-admin/src/utils/router/leaf-group.test.ts 新建:上述纯函数单测
web-admin/src/utils/router/sidebar-menu-filter.ts 现有叶子过滤保留给「可导航叶子」判断;侧栏展示改走 toNonLeafSidebarTree
web-admin/src/utils/menu-badge-registry.ts 组节点角标:汇总后代叶子数字角标
web-admin/src/utils/menu-badge-registry.test.ts 新建:汇总角标
web-admin/src/utils/navigation/jump.ts 增加 enterLeafGroup:已在组内则不跳;否则进第一个叶子
web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue 非叶子树;最后一层当 MenuItem 进组;混合节点标题进组、箭头展开
web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue default-active 用组 path;一级点击走 enterLeafGroup / 第一组;展开 index 用非叶子树
web-admin/src/components/core/layouts/art-portal-sub-nav/widget/PortalSubmenu.vue 与侧栏同一套非叶子/进组
web-admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue 同上(LEFT 以外布局)
web-admin/src/components/core/layouts/art-group-leaf-tabs/index.vue 新建:固定组 Tab 条
web-admin/src/components/core/layouts/art-page-content/index.vue 挂 Tab 条;本组叶子走 KeepAlive(key=groupId)
docs/web-admin-ui-spec-fresh-mint.md §3 组 Tab ≠ WorkTab
openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md 新增 Requirement

隐藏页不强制给每个路由补 meta.activePath:用「最长叶子 path 前缀」归属;仅当单测发现歧义再补 activePath。


Task 1: leaf-group 纯函数 + 失败测试

Files:

  • Create: web-admin/src/utils/router/leaf-group.test.ts

  • Create: web-admin/src/utils/router/leaf-group.ts(下一步才写实现)

  • Step 1: 写失败测试

创建 web-admin/src/utils/router/leaf-group.test.ts:

import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import type { AppRouteRecord } from '@/types/router'
import {
  classifyMenuNode,
  enterGroupTargetPath,
  firstLeafPath,
  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 crm = item('/crm', 'CRM管理', {
  component: undefined,
  children: [
    item('/crm/product', '产品管理'),
    item('/crm/quote', '报价单管理'),
    quoteForm,
    item('/crm/quote-approval', '报价单审批')
  ]
})

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), '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.equal(crmSide?.children?.length || 0, 0)
    const mixedSide = side.find((n) => n.path === '/sys')
    assert.deepEqual(
      mixedSide?.children?.map((c) => c.path),
      ['/sys/role-group']
    )
  })
})

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('treats CRM module as the group and omits hidden leaves', () => {
    const g = resolveLeafGroup(tree, '/crm/quote')
    assert.equal(g?.groupPath, '/crm')
    assert.deepEqual(
      g?.leaves.map((l) => l.path),
      ['/crm/product', '/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')
    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/product')
  })

  it('firstLeafPath follows menu order', () => {
    const g = resolveLeafGroup(tree, '/bx/advance/apply')!
    assert.equal(firstLeafPath(g), '/bx/advance/apply')
  })
})
  • Step 2: 跑测试确认失败
cd web-admin && node --import tsx --test src/utils/router/leaf-group.test.ts

Expected: FAIL,Cannot find module './leaf-group' 或导出不存在。

  • Step 3: 写最小实现

创建 web-admin/src/utils/router/leaf-group.ts:

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 hasFolderChild(item: AppRouteRecord): boolean {
  return getVisibleChildren(item).some((child) => classifyMenuNode(child) !== 'leaf')
}

function hasLeafChild(item: AppRouteRecord): boolean {
  return getVisibleChildren(item).some((child) => classifyMenuNode(child) === 'leaf')
}

export function classifyMenuNode(item: AppRouteRecord): MenuNodeKind {
  if (isHiddenMenuItem(item)) return 'empty'
  const visible = getVisibleChildren(item)
  if (!visible.length) {
    return isSidebarNavigableRoute(item) ? 'leaf' : 'empty'
  }
  const folder = hasFolderChild(item)
  const leaf = hasLeafChild(item)
  if (folder && leaf) return 'mixed'
  if (folder) return 'intermediate'
  if (leaf) return 'last-non-leaf'
  return 'empty'
}

export function toNonLeafSidebarTree(items: AppRouteRecord[]): AppRouteRecord[] {
  const out: AppRouteRecord[] = []
  for (const item of items || []) {
    if (isHiddenMenuItem(item)) continue
    const kind = classifyMenuNode(item)
    if (kind === 'leaf' || kind === 'empty') continue
    const folderChildren = toNonLeafSidebarTree(getVisibleChildren(item))
    if (kind === 'last-non-leaf') {
      out.push({ ...item, children: undefined })
      continue
    }
    out.push({ ...item, children: folderChildren.length ? folderChildren : undefined })
  }
  return out
}

function normalizePath(path?: string): string {
  if (!path) return ''
  const trimmed = path.trim()
  if (!trimmed) return ''
  return trimmed.length > 1 ? trimmed.replace(/\/+$/, '') : trimmed
}

function pathMatches(itemPath: string, currentPath: string): boolean {
  const item = normalizePath(itemPath)
  const current = normalizePath(currentPath)
  if (!item || !current) return false
  return current === item || current.startsWith(`${item}/`)
}

function walk(items: AppRouteRecord[], visit: (node: AppRouteRecord) => void): void {
  for (const item of items || []) {
    visit(item)
    if (item.children?.length) walk(item.children, visit)
  }
}

function collectGroupNodes(tree: AppRouteRecord[]): AppRouteRecord[] {
  const nodes: AppRouteRecord[] = []
  walk(tree, (node) => {
    const kind = classifyMenuNode(node)
    if (kind === 'last-non-leaf' || kind === 'mixed') nodes.push(node)
  })
  return nodes
}

export function visibleGroupLeaves(groupNode: AppRouteRecord): AppRouteRecord[] {
  return getVisibleChildren(groupNode).filter((child) => classifyMenuNode(child) === 'leaf')
}

export function leafGroupFromNode(groupNode: AppRouteRecord): LeafGroup | null {
  const kind = classifyMenuNode(groupNode)
  if (kind !== 'last-non-leaf' && kind !== 'mixed') return null
  const leaves = visibleGroupLeaves(groupNode)
  if (!leaves.length) return null
  const groupPath = normalizePath(String(groupNode.path || ''))
  return {
    groupId: String(groupNode.id || groupPath || groupNode.meta?.title || ''),
    groupPath,
    title: String(groupNode.meta?.title || ''),
    leaves
  }
}

function longestMatchingLeafPath(group: LeafGroup, currentPath: string): string | null {
  const current = normalizePath(currentPath)
  let best: string | null = null
  for (const leaf of group.leaves) {
    const leafPath = normalizePath(String(leaf.path || ''))
    if (!leafPath) continue
    if (current === leafPath || current.startsWith(`${leafPath}/`)) {
      if (!best || leafPath.length > best.length) best = leafPath
    }
  }
  return best
}

export function resolveLeafGroup(
  tree: AppRouteRecord[],
  currentPath: string,
  activePath?: string
): LeafGroup | null {
  const candidates = collectGroupNodes(tree)
    .map((node) => leafGroupFromNode(node))
    .filter((g): g is LeafGroup => !!g)

  const hint = normalizePath(activePath || '')
  const current = normalizePath(currentPath)

  let best: LeafGroup | null = null
  let bestScore = -1
  for (const group of candidates) {
    if (group.groupPath && (current === group.groupPath || current.startsWith(`${group.groupPath}/`))) {
      const leafHit = longestMatchingLeafPath(group, hint || current)
      const score = (leafHit ? leafHit.length : 0) * 10 + group.groupPath.length
      if (score > bestScore) {
        best = group
        bestScore = score
      }
    }
  }
  return best
}

export function resolveHighlightedLeafPath(
  group: LeafGroup,
  currentPath: string,
  activePath?: string
): string | null {
  const hint = normalizePath(activePath || '')
  if (hint) {
    const exact = group.leaves.find((leaf) => normalizePath(String(leaf.path || '')) === hint)
    if (exact?.path) return normalizePath(String(exact.path))
  }
  return longestMatchingLeafPath(group, hint || currentPath)
}

export function routeBelongsToGroup(
  group: LeafGroup,
  currentPath: string,
  activePath?: string
): boolean {
  return !!resolveHighlightedLeafPath(group, currentPath, activePath)
}

export function firstLeafPath(group: LeafGroup): string | undefined {
  const path = group.leaves[0]?.path
  return path ? normalizePath(String(path)) : undefined
}

export function enterGroupTargetPath(
  group: LeafGroup,
  currentPath: string,
  activePath?: string
): string | null {
  if (routeBelongsToGroup(group, currentPath, activePath)) return null
  return firstLeafPath(group) || null
}

/** 一级 intermediate:取先序第一个可进组节点 */
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
}

export function findMenuNodeByPath(
  items: AppRouteRecord[],
  path: string
): AppRouteRecord | undefined {
  const want = String(path || '')
  for (const node of items || []) {
    if (String(node.path || '') === want) return node
    const hit = findMenuNodeByPath(node.children || [], path)
    if (hit) return hit
  }
  return undefined
}

若 tsx 无法解析 @/,测试改为相对 import 类型:把 import type { AppRouteRecord } from '@/types/router' 换成从 '../../types/router'。以 list-toolbar-overflow-items.test.ts 能跑通的解析为准;本文件实现继续用 @/。

  • Step 4: 再跑测试
cd web-admin && node --import tsx --test src/utils/router/leaf-group.test.ts

Expected: PASS(全部 it 通过)。

  • Step 5: Commit 切点(默认跳过)

feat: add leaf-group helpers for sidebar non-leaf and content tabs


Task 2: 组节点角标汇总

Files:

  • Create: web-admin/src/utils/menu-badge-registry.test.ts

  • Modify: web-admin/src/utils/menu-badge-registry.ts

  • Step 1: 写失败测试

import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import { sumLeafBadgeCounts } from './menu-badge-registry'

describe('sumLeafBadgeCounts', () => {
  it('sums descendant leaf menuCode counts', () => {
    const node = {
      path: '/bx/advance',
      meta: { title: '备用金' },
      children: [
        { path: '/bx/advance/apply', meta: { title: '申请', menuCode: 'bx:advanceApply:list' } },
        { path: '/bx/advance/approve', meta: { title: '审批', menuCode: 'bx:advanceApprove:list' } },
        {
          path: '/bx/advance/issue',
          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',
      meta: { title: '基础配置' },
      children: [{ path: '/bx/config/a', meta: { title: 'A', menuCode: 'x' } }]
    } as any
    assert.equal(sumLeafBadgeCounts(node, () => 0), '')
  })
})
  • Step 2: 跑测试确认失败
cd web-admin && node --import tsx --test src/utils/menu-badge-registry.test.ts

Expected: FAIL,sumLeafBadgeCounts 未导出。

  • Step 3: 实现

在 web-admin/src/utils/menu-badge-registry.ts 增加(可放在 resolveMenuBadgeCode 附近):

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) : ''
}

注意:classifyMenuNode 依赖 isSidebarNavigableRoute,测试节点需带 component 才会被当成 leaf。给测试数据每个叶子加上 component: '/x'。

  • Step 4: 跑测试确认通过
cd web-admin && node --import tsx --test src/utils/menu-badge-registry.test.ts src/utils/router/leaf-group.test.ts

Expected: PASS。

  • Step 5: Commit 切点(默认跳过)

feat: roll up sidebar badges onto non-leaf group nodes


Task 3: enterLeafGroup 导航

Files:

  • Modify: web-admin/src/utils/navigation/jump.ts

  • Step 1: 增加进组函数

在 handleMenuJump 同文件增加:

import {
  classifyMenuNode,
  enterGroupTargetPath,
  firstEnterableGroupNode,
  leafGroupFromNode
} from '@/utils/router/leaf-group'

export const enterLeafGroup = (item: AppRouteRecord) => {
  const { link, isIframe } = item.meta
  if (link && !isIframe) {
    return openExternalLink(link)
  }

  const kind = classifyMenuNode(item)
  const groupNode =
    kind === 'last-non-leaf' || kind === 'mixed' ? item : firstEnterableGroupNode(item)
  const group = groupNode ? leafGroupFromNode(groupNode) : null
  if (!group) return

  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)
}

树内部中间节点不要调用本函数(只展开)。一级 intermediate 调用本函数时走 firstEnterableGroupNode。

  • Step 2: 手工核对(无独立测试:依赖 router 单例)

确认 handleMenuJump 原逻辑未改。

  • Step 3: Commit 切点(默认跳过)

feat: add enterLeafGroup navigation helper


Task 4: SidebarSubmenu 只渲染非叶子并进组

Files:

  • Modify: web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue

  • Step 1: 过滤改为非叶子树

将 filteredMenuItems 改为:

import { classifyMenuNode, toNonLeafSidebarTree } from '@/utils/router/leaf-group'
import { enterLeafGroup } from '@/utils/navigation/jump'

const filteredMenuItems = computed(() => toNonLeafSidebarTree(props.list || []))

hasChildren:classifyMenuNode 在原始 list 上判断。toNonLeafSidebarTree 会丢掉原始 children,因此必须在 map 前用展示树节点:last-non-leaf 的 children 已空,hasChildren 应为 false。

const hasChildren = (item: AppRouteRecord): boolean => {
  return !!(item.children && item.children.length > 0)
}
  • Step 2: 点击进组

goPage 改为:

const goPage = (item: AppRouteRecord): void => {
  closeMenu()
  enterLeafGroup(item)
}

混合节点:展示树仍带 folder children,走 ElSubMenu。在 #title 的菜单名上 @click.stop.prevent="goPage(item)",不要绑在整个 title 上以免吞掉箭头展开。

<span class="menu-name" @click.stop="goPage(item)">
  {{ formatMenuTitle(item.meta.title) }}
</span>

仅当 classifyMenuNode 需要原始 children 时:toNonLeafSidebarTree 对 mixed 保留 folder children,但 丢掉了直接叶子,enterLeafGroup(item) 会拿不到叶子。

必须: toNonLeafSidebarTree 的 mixed/last-non-leaf 节点带上非枚举字段会丢失。改为在展示节点上保留原始引用:

在 leaf-group.ts 的 toNonLeafSidebarTree 中:

out.push({ ...item, children: folderChildren.length ? folderChildren : undefined })

...item 仍包含原始 children 随后被覆盖。enterLeafGroup 用 path 从 useMenuStore().menuList 找回完整节点更稳。

更新 enterLeafGroup:

import { useMenuStore } from '@/store/modules/menu'

function findMenuNodeByPath(items: AppRouteRecord[], path: string): AppRouteRecord | undefined {
  const want = String(path || '')
  for (const node of items || []) {
    if (String(node.path || '') === want) return node
    const hit = findMenuNodeByPath(node.children || [], path)
    if (hit) return hit
  }
  return undefined
}

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
  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)
}
  • Step 3: 角标

resolveMenuTextBadge 在叶子被隐藏后,对 last-non-leaf / mixed 用 store 的 getCount 汇总(menu-badge.ts 已导出):

import { sumLeafBadgeCounts } from '@/utils/menu-badge-registry'

const full =
  findMenuNodeByPath(useMenuStore().menuList, String(item.path || '')) || item
const rolled = sumLeafBadgeCounts(full, (code) => menuBadgeStore.getCount(code))
if (rolled) return rolled

findMenuNodeByPath 与 enterLeafGroup 共用(可抽到 leaf-group.ts 导出,避免两处拷贝)。不要把叶子圆点画在组节点上。

  • Step 4: Commit 切点(默认跳过)

feat: render sidebar as non-leaf tree and enter leaf groups


Task 5: ArtSidebarMenu 高亮、展开、一级点击

Files:

  • Modify: web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue

  • Step 1: default-active 用组 path

import { resolveLeafGroup } from '@/utils/router/leaf-group'
import { enterLeafGroup } from '@/utils/navigation/jump'
import { toNonLeafSidebarTree } from '@/utils/router/leaf-group'

const leafGroup = computed(() =>
  resolveLeafGroup(
    useMenuStore().menuList,
    String(route.path || ''),
    route.meta.activePath as string | undefined
  )
)

const routerPath = computed(() => {
  if (leafGroup.value?.groupPath) return leafGroup.value.groupPath
  return String(route.meta.activePath || route.path)
})

ElMenu 的 :default-active="routerPath" 对组节点 path 才能高亮最后一层非叶子。

  • Step 2: menuList 展示非叶子

menuList 在 filterSidebarRoutes(children) 之后再 toNonLeafSidebarTree。LEFT 完整树同样 toNonLeafSidebarTree(allMenus)。

  • Step 3: 展开 index

collectOpenMenuIndexes 改为对 toNonLeafSidebarTree(items) 收集仍有 children 的节点(intermediate / mixed),不要对 last-non-leaf 调用 open。

  • Step 4: 一级点击
const onFirstLevelTabClick = (menu: AppRouteRecord): void => {
  selectedTopModulePath.value = String(menu.path || '')
  enterLeafGroup(menu)
}

CRM:进第一片可见叶子。报销:进「基础配置」第一片叶子。已在该组则不 push。

  • Step 5: 浏览器核对

pnpm dev(web-admin),登录后:

  1. 点报销 → 基础配置:右侧应出现三个 Tab(下一 Task 前内容区可能仍只有一页,但 URL 应为费用项目)。
  2. 点备用金:URL 变为备用金申请。
  3. 点 CRM:URL 变为产品管理(或该用户第一个有权限叶子)。
  4. 侧栏看不到「费用项目管理」等叶子名。
  • Step 6: Commit 切点(默认跳过)

feat: activate dual-menu first-level and tree via leaf groups


Task 6: Portal / Horizontal 子菜单对齐

Files:

  • Modify: web-admin/src/components/core/layouts/art-portal-sub-nav/widget/PortalSubmenu.vue

  • Modify: web-admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue

  • Step 1: PortalSubmenu

filteredChildren 改为 toNonLeafSidebarTree(item.children || [])。无 children 时若节点是 last-non-leaf(展示树被摊平后 parent 传入的是组节点),走 ElMenuItem + enterLeafGroup。混合节点:SubMenu + 标题名点击 enterLeafGroup。

  • Step 2: HorizontalSubmenu

同样:hasChildren 看展示树 children;叶子项改为组节点 enterLeafGroup;不要 handleMenuJump 进单个隐藏前的叶子。

  • Step 3: Commit 切点(默认跳过)

feat: align portal and horizontal menus with non-leaf groups


Task 7: ArtGroupLeafTabs 固定 Tab 条

Files:

  • Create: web-admin/src/components/core/layouts/art-group-leaf-tabs/index.vue

  • Step 1: 组件

<template>
  <div v-if="group && group.leaves.length" class="group-leaf-tabs" role="tablist">
    <button
      v-for="leaf in group.leaves"
      :key="leaf.path"
      type="button"
      class="group-leaf-tabs__item"
      :class="{ 'is-active': highlightedPath === normalize(leaf.path) }"
      role="tab"
      :aria-selected="highlightedPath === normalize(leaf.path)"
      @click="onSelect(leaf)"
    >
      {{ formatMenuTitle(leaf.meta.title) }}
    </button>
  </div>
</template>

<script setup lang="ts">
  import { computed } from 'vue'
  import { useRoute, useRouter } from 'vue-router'
  import { storeToRefs } from 'pinia'
  import { useMenuStore } from '@/store/modules/menu'
  import { formatMenuTitle } from '@/utils/router'
  import {
    resolveHighlightedLeafPath,
    resolveLeafGroup,
    type LeafGroup
  } from '@/utils/router/leaf-group'
  import type { AppRouteRecord } from '@/types/router'

  defineOptions({ name: 'ArtGroupLeafTabs' })

  const route = useRoute()
  const router = useRouter()
  const { menuList } = storeToRefs(useMenuStore())

  const normalize = (path?: string) => {
    const t = String(path || '').trim()
    return t.length > 1 ? t.replace(/\/+$/, '') : t
  }

  const group = computed<LeafGroup | null>(() =>
    resolveLeafGroup(
      menuList.value,
      String(route.path || ''),
      route.meta.activePath as string | undefined
    )
  )

  const highlightedPath = computed(() => {
    if (!group.value) return ''
    return (
      resolveHighlightedLeafPath(
        group.value,
        String(route.path || ''),
        route.meta.activePath as string | undefined
      ) || ''
    )
  })

  const onSelect = (leaf: AppRouteRecord) => {
    const path = normalize(leaf.path)
    if (!path || path === normalize(route.path)) return
    router.push(path)
  }
</script>

<style scoped lang="scss">
  .group-leaf-tabs {
    display: flex;
    align-items: stretch;
    gap: 0;
    min-height: 42px;
    padding: 0 8px;
    overflow-x: auto;
    background: var(--el-bg-color);
    border-bottom: 1px solid var(--el-border-color);
  }
  .group-leaf-tabs__item {
    appearance: none;
    border: 0;
    background: transparent;
    padding: 10px 14px 9px;
    font-size: 13px;
    color: var(--el-text-color-secondary);
    cursor: pointer;
    white-space: nowrap;
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;
  }
  .group-leaf-tabs__item:hover {
    color: var(--el-text-color-primary);
  }
  .group-leaf-tabs__item.is-active {
    color: var(--primary-dark, #059669);
    font-weight: 600;
    border-bottom-color: var(--primary, #10b981);
  }
</style>

无关闭按钮、无图标。隐藏页时 highlightedPath 仍是父叶子。

  • Step 2: Commit 切点(默认跳过)

feat: add fixed group leaf tab strip


Task 8: ArtPageContent 挂 Tab + 按组 keep-alive

Files:

  • Modify: web-admin/src/components/core/layouts/art-page-content/index.vue

  • Step 1: 模板

在 #app-content-header 之后、RouterView 之前插入:

<ArtGroupLeafTabs v-if="!isFullPage && showGroupLeafTabs" />

(组件随工程 auto-import;若未扫到 layouts,则在 script 里显式 import。)

  • Step 2: 拆 KeepAlive

可见组叶子(当前 path 等于组内某 leaf.path)走 KeepAlive;其它(隐藏页、无组、isFullPage)走非缓存分支。KeepAlive 加 :key="leafGroupId",换组销毁缓存。

import { resolveLeafGroup } from '@/utils/router/leaf-group'
import { useMenuStore } from '@/store/modules/menu'

const leafGroup = computed(() =>
  resolveLeafGroup(
    useMenuStore().menuList,
    String(route.path || ''),
    route.meta.activePath as string | undefined
  )
)

const showGroupLeafTabs = computed(() => !!leafGroup.value?.leaves.length)

const leafGroupId = computed(() => leafGroup.value?.groupId || '')

const isCurrentGroupLeaf = computed(() => {
  const path = String(route.path || '').replace(/\/+$/, '')
  return !!leafGroup.value?.leaves.some(
    (leaf) => String(leaf.path || '').replace(/\/+$/, '') === path
  )
})

const keepAliveMax = computed(() => {
  const groupSize = leafGroup.value?.leaves.length || 0
  if (groupSize > 0) return Math.max(groupSize, 1)
  return showWorkTab.value ? 5 : 1
})

RouterView 内:

<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
  <KeepAlive :key="leafGroupId" :max="keepAliveMax" :exclude="keepAliveExclude">
    <component
      class="art-page-view"
      :is="Component"
      :key="route.path"
      v-if="isCurrentGroupLeaf || route.meta.keepAlive"
    />
  </KeepAlive>
</Transition>
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
  <component
    class="art-page-view"
    :is="Component"
    :key="route.path"
    v-if="!(isCurrentGroupLeaf || route.meta.keepAlive)"
  />
</Transition>

隐藏页 isCurrentGroupLeaf === false 且默认 keepAlive: false → 不进组缓存。用户详情等 meta.keepAlive: true 仍进 KeepAlive。

换组时 leafGroupId 变化,KeepAlive 重建,上一组卸载。

  • Step 3: 浏览器验收加载
  1. 基础配置:只应看到费用项目列表请求;点工作流备用金才出现该页请求;再切回费用项目不应重复首屏 list(Network)。
  2. 再点备用金:费用项目/工作流请求不再因缓存显示;只加载申请页。
  3. CRM 进报价表单:Tab 仍在,「报价单管理」高亮;刷新仍在表单 URL。
  4. 从形成点「合同管理」:直接切走,无确认框。
  • Step 4: Commit 切点(默认跳过)

feat: lazy-mount group leaf pages and cache within the active group


Task 9: 规范文档

Files:

  • Modify: docs/web-admin-ui-spec-fresh-mint.md §3(约第 95 行)

  • Modify: openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md(WorkTab 条目之后插入)

  • Step 1: Fresh Mint

将「内容区自上而下:顶栏…默认不展示多页签 WorkTab」改为:

  • 顶栏(面包屑/工具,高约 56px)→ 组叶子 Tab 条(有业务组时)→ 页面内容。

  • 仍默认不展示 WorkTab(可关闭历史页签)。组 Tab 不是 WorkTab:不能单个关闭;换最后一层非叶子则整组替换。

  • 侧栏树只展示非叶子;叶子仅出现在组 Tab。

  • Step 2: OpenSpec Requirement

在「标签页(WorkTab)不展示图标」之后新增:

### Requirement: 侧栏非叶子与组叶子 Tab

`web-admin` 业务导航 SHALL 在侧栏仅展示非叶子节点。点击最后一层非叶子(可见子节点均为叶子)或混合节点(同时有目录与叶子)SHALL 在内容区展示该节点下全部「有权限且未隐藏」的叶子为固定 Tab(不可单个关闭)。默认仅挂载当前路由对应页面;切换到其它 Tab 后再挂载该页;同组已访问叶子 SHALL 保持缓存直至组切换。隐藏路由(`isHide`)SHALL NOT 出现在 Tab 条上,SHALL 通过 `meta.activePath` 或最长叶子路径前缀使父叶子保持选中。SHALL NOT 将组 Tab 实现为可关闭的 WorkTab。

#### Scenario: 报销基础配置

- **WHEN** 用户点击报销管理下的「基础配置」
- **THEN** 内容区 Tab 为费用项目管理、工作流配置(备用金)、工作流配置(报销单),并默认打开第一项(或当前 URL 已指向的项)

#### Scenario: 切换备用金整组替换

- **WHEN** 用户接着点击「备用金」
- **THEN** 上一组 Tab 全部消失,换成备用金申请、审批、发放,并卸载上一组已缓存页面

#### Scenario: 隐藏表单

- **WHEN** 用户从报价单管理进入报价单表单
- **THEN** Tab 条仍为 CRM 可见叶子,「报价单管理」保持选中,内容为表单页
  • Step 3: Commit 切点(默认跳过)

docs: specify sidebar non-leaf tree and group leaf tabs


Task 10: 全量单测 + 手工回归

  • Step 1: 单测
cd web-admin && node --import tsx --test src/utils/router/leaf-group.test.ts src/utils/menu-badge-registry.test.ts

Expected: PASS。

  • Step 2: 手工对照规格验收

规格「验收」五条全部点过。另:无权限叶子不出现;直达无权限 URL 仍 403;首页 /home 无组 Tab。

  • Step 3: 改了 TS/Vue 后按仓库约定更新图谱
./scripts/update-graph.sh frontend
  • Step 4: Commit 切点(默认跳过)

无新代码则不提交。


Spec coverage

规格项 Task
侧栏只显示非叶子 1, 4, 5, 6
最后一层非叶子开组 Tab 1, 3, 7
一级当最后一层(CRM) 1, 5
混合节点标题开 Tab、箭头展开 1, 4
固定 Tab、不能关、换组替换 7, 8
懒加载 + 同组 keep-alive + 换组卸载 8
隐藏页不进条、父叶子高亮、刷新保 URL 1, 7, 8
无未保存拦截 7(直接 push)
单叶子不显示 Tab 7(leaves.length > 1 才显示)
角标不丢 2, 4
组 Tab ≠ WorkTab 8, 9
双列一级 intermediate 进第一组 3, 5(计划开头例外)

执行注意

  • 不要改 showWorkTab 默认值,不要给 WorkTab 加关闭逻辑。
  • toNonLeafSidebarTree 展示节点可能丢掉叶子 children,进组一律用 menuList 全量节点。
  • KeepAlive :key="leafGroupId" 不要改成按 route.path,否则失去同组缓存。