36 KiB
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),登录后:
- 点报销 → 基础配置:右侧应出现三个 Tab(下一 Task 前内容区可能仍只有一页,但 URL 应为费用项目)。
- 点备用金:URL 变为备用金申请。
- 点 CRM:URL 变为产品管理(或该用户第一个有权限叶子)。
- 侧栏看不到「费用项目管理」等叶子名。
- 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: 浏览器验收加载
- 基础配置:只应看到费用项目列表请求;点工作流备用金才出现该页请求;再切回费用项目不应重复首屏 list(Network)。
- 再点备用金:费用项目/工作流请求不再因缓存显示;只加载申请页。
- CRM 进报价表单:Tab 仍在,「报价单管理」高亮;刷新仍在表单 URL。
- 从形成点「合同管理」:直接切走,无确认框。
- 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,否则失去同组缓存。