# 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` 用 `` 缓存本组已访问叶子,隐藏页走非缓存分支。不打开、不改造 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`: ```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 & { meta?: Partial } = {} ): 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: 跑测试确认失败** ```bash 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`: ```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: 再跑测试** ```bash 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: 写失败测试** ```ts 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: 跑测试确认失败** ```bash 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` 附近): ```ts 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: 跑测试确认通过** ```bash 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` 同文件增加: ```ts 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` 改为: ```ts 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。 ```ts const hasChildren = (item: AppRouteRecord): boolean => { return !!(item.children && item.children.length > 0) } ``` - [ ] **Step 2: 点击进组** `goPage` 改为: ```ts const goPage = (item: AppRouteRecord): void => { closeMenu() enterLeafGroup(item) } ``` 混合节点:展示树仍带 folder children,走 `ElSubMenu`。在 `#title` 的菜单名上 `@click.stop.prevent="goPage(item)"`,不要绑在整个 title 上以免吞掉箭头展开。 ```vue {{ formatMenuTitle(item.meta.title) }} ``` 仅当 `classifyMenuNode` 需要原始 children 时:`toNonLeafSidebarTree` 对 mixed **保留 folder children**,但 **丢掉了直接叶子**,`enterLeafGroup(item)` 会拿不到叶子。 **必须:** `toNonLeafSidebarTree` 的 mixed/last-non-leaf 节点带上非枚举字段会丢失。改为在展示节点上保留原始引用: 在 `leaf-group.ts` 的 `toNonLeafSidebarTree` 中: ```ts out.push({ ...item, children: folderChildren.length ? folderChildren : undefined }) ``` `...item` 仍包含原始 `children` 随后被覆盖。`enterLeafGroup` 用 path 从 `useMenuStore().menuList` 找回完整节点更稳。 更新 `enterLeafGroup`: ```ts 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` 已导出): ```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** ```ts 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: 一级点击** ```ts 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: 组件** ```vue ``` 无关闭按钮、无图标。隐藏页时 `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` 之前插入: ```vue ``` (组件随工程 auto-import;若未扫到 layouts,则在 script 里显式 import。) - [ ] **Step 2: 拆 KeepAlive** 可见组叶子(当前 path 等于组内某 leaf.path)走 KeepAlive;其它(隐藏页、无组、`isFullPage`)走非缓存分支。`KeepAlive` 加 `:key="leafGroupId"`,换组销毁缓存。 ```ts 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 内: ```vue ``` 隐藏页 `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)不展示图标」之后新增: ```md ### 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: 单测** ```bash 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 后按仓库约定更新图谱** ```bash ./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,否则失去同组缓存。