From 26edb9e6edc99485b1eae6712c8cb568e5d0d628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 12:17:17 +0800 Subject: [PATCH] =?UTF-8?q?task-6(=E5=A3=B3=E5=B1=82/=E8=B7=AF=E7=94=B1):?= =?UTF-8?q?=20=E5=AE=9A=E4=B9=89=E5=90=8E=E7=AB=AF=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=A0=91=E5=88=B0=E4=BE=A7=E8=BE=B9=E6=A0=8F=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E8=A7=84=E5=88=99=E5=B9=B6=E8=90=BD=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 src/layout/menu-mapper.ts:toSidebarEntries 处理分组/顶级路由叶/畸形节点, groupKeysForActive 按当前路由展开激活分组;AdminLayout 菜单区改用映射结果, 删除旧 visibleChildren/canSee 死逻辑;8 用例覆盖空树/单叶/缺 route 过滤等。 --- admin-frontend-vue/src/layout/AdminLayout.vue | 42 +++----- admin-frontend-vue/src/layout/menu-mapper.ts | 46 +++++++++ admin-frontend-vue/tests/task-3.test.ts | 2 +- admin-frontend-vue/tests/task-6.test.ts | 99 +++++++++++++++++++ 4 files changed, 158 insertions(+), 31 deletions(-) create mode 100644 admin-frontend-vue/src/layout/menu-mapper.ts create mode 100644 admin-frontend-vue/tests/task-6.test.ts diff --git a/admin-frontend-vue/src/layout/AdminLayout.vue b/admin-frontend-vue/src/layout/AdminLayout.vue index 803b5271..734f1f17 100644 --- a/admin-frontend-vue/src/layout/AdminLayout.vue +++ b/admin-frontend-vue/src/layout/AdminLayout.vue @@ -5,7 +5,7 @@ import { ElMessage } from 'element-plus' import { useAdminSessionStore } from '@/stores/admin-session' import { joinAdminPath } from '@/config/app' import { pageTitleOf, topbarUserOf } from '@/layout/topbar-model' -import type { AdminMenuNode } from '@/types/admin' +import { groupKeysForActive, toSidebarEntries } from '@/layout/menu-mapper' const route = useRoute() const router = useRouter() @@ -13,27 +13,12 @@ const session = useAdminSessionStore() const collapsed = ref(false) const activePath = computed(() => route.path) -const groups = computed(() => session.menuTree.filter((node) => node.children?.length)) +const menuEntries = computed(() => toSidebarEntries(session.menuTree)) +const openedKeys = computed(() => groupKeysForActive(menuEntries.value, route.path)) const pageTitle = computed(() => pageTitleOf(route.meta.title as string | undefined)) const userVm = computed(() => topbarUserOf(session.user)) const logoUrl = joinAdminPath('assets', 'logo.jpg') -function menuPath(node: AdminMenuNode): string { - return node.route || '/account/users' -} - -function visibleChildren(node: AdminMenuNode) { - return (node.children || []).filter((child) => child.route) -} - -function canSee(node: AdminMenuNode) { - return session.isSuperAdmin.valueOf() || hasMenu(node, String(route.meta.menuKey || '')) || node.route === activePath.value -} - -function hasMenu(node: AdminMenuNode, key: string): boolean { - return node.key === key || (node.children || []).some((child) => hasMenu(child, key)) -} - async function signOut() { try { await session.signOut() @@ -55,23 +40,22 @@ async function signOut() { - - - - diff --git a/admin-frontend-vue/src/layout/menu-mapper.ts b/admin-frontend-vue/src/layout/menu-mapper.ts new file mode 100644 index 00000000..e7a32d2a --- /dev/null +++ b/admin-frontend-vue/src/layout/menu-mapper.ts @@ -0,0 +1,46 @@ +import type { AdminMenuNode } from '../types/admin' + +export interface SidebarItem { + key: string + name: string + route: string +} + +/** 侧边栏渲染项:分组(含子页面)或单页面项(含顶级路由叶节点)。 */ +export type SidebarEntry = + | { kind: 'group'; key: string; name: string; children: SidebarItem[] } + | { kind: 'item'; key: string; name: string; route: string } + +/** + * 后端菜单树 -> 侧边栏节点映射规则(任务 6): + * - 有可路由子节点的节点渲染为分组,只保留带 route 的子项; + * - 无子节点但有 route 的节点渲染为单页面项(不丢顶级叶菜单); + * - 既无子节点也无 route 的节点跳过(点不进去的纯占位不展示)。 + */ +export function toSidebarEntries(nodes: AdminMenuNode[] | null | undefined): SidebarEntry[] { + const entries: SidebarEntry[] = [] + for (const node of nodes || []) { + const name = node.name || '' + const key = node.key || '' + const children: SidebarItem[] = (node.children || []) + .filter((child): child is AdminMenuNode & { route: string } => Boolean(child.route)) + .map((child) => ({ key: child.key || '', name: child.name || '', route: child.route })) + if (children.length > 0) { + entries.push({ kind: 'group', key, name, children }) + } else if (node.route) { + entries.push({ kind: 'item', key, name, route: node.route }) + } + } + return entries +} + +/** 计算需默认展开的分组 key:分组下含当前激活路由子项时返回该分组 key。 */ +export function groupKeysForActive(entries: SidebarEntry[], activeRoute: string): string[] { + const keys: string[] = [] + for (const entry of entries) { + if (entry.kind === 'group' && entry.children.some((child) => child.route === activeRoute)) { + keys.push(entry.key) + } + } + return keys +} diff --git a/admin-frontend-vue/tests/task-3.test.ts b/admin-frontend-vue/tests/task-3.test.ts index ba6f6e51..0eeb6ad4 100644 --- a/admin-frontend-vue/tests/task-3.test.ts +++ b/admin-frontend-vue/tests/task-3.test.ts @@ -32,7 +32,7 @@ test('test_task_003_layout_contract_boundary_empty_input', () => { // 边界空值:菜单为空时应展示无菜单状态,且页面容器本身仍保留。 const layout = readSource(LAYOUT) assert.ok(layout.includes('暂无可用菜单'), '无菜单需有空状态文案') - assert.ok(layout.includes('!groups.length'), '空菜单由 groups 长度驱动') + assert.ok(layout.includes('menuEntries.length'), '空菜单由映射结果长度驱动') assert.equal(occurrences(layout, ' & { key: string; name: string }): AdminMenuNode { + return { key: partial.key, name: partial.name, route: partial.route, children: partial.children, sort: partial.sort } +} + +const TREE: AdminMenuNode[] = [ + node({ key: 'account', name: '账号权限', children: [ + node({ key: 'admin_users', name: '用户管理', route: '/account/users' }), + node({ key: 'admin_columns', name: '菜单管理', route: '/account/menus' }), + ] }), +] + +test('test_task_006_menu_sidebar_mapping_normal_primary_path', () => { + // 正常主路径:带可路由子节点的后端菜单渲染为分组,顺序保留。 + const entries = toSidebarEntries(TREE) + assert.equal(entries.length, 1) + assert.equal(entries[0].kind, 'group') + if (entries[0].kind === 'group') { + assert.equal(entries[0].name, '账号权限') + assert.deepEqual(entries[0].children.map((c) => c.route), ['/account/users', '/account/menus']) + } +}) + +test('test_task_006_menu_sidebar_mapping_normal_variant_input', () => { + // 正常变体:另一组菜单(含顶级路由叶 + 分组混合)正确混合输出。 + const mix: AdminMenuNode[] = [ + node({ key: 'g', name: '分组', children: [node({ key: 'c', name: '子', route: '/x/c' })] }), + node({ key: 'leaf', name: '顶级页', route: '/top' }), + ] + const entries = toSidebarEntries(mix) + assert.equal(entries.length, 2) + assert.equal(entries[0].kind, 'group') + assert.equal(entries[1].kind, 'item') + if (entries[1].kind === 'item') assert.equal(entries[1].route, '/top') +}) + +test('test_task_006_menu_sidebar_mapping_normal_repeated_operation_is_idempotent', () => { + // 正常重复:映射结果稳定,不修改输入源树。 + const snapshot = JSON.stringify(TREE) + const first = JSON.stringify(toSidebarEntries(TREE)) + const second = JSON.stringify(toSidebarEntries(TREE)) + assert.equal(first, second) + assert.equal(JSON.stringify(TREE), snapshot, '映射不得污染源菜单树') +}) + +test('test_task_006_menu_sidebar_mapping_boundary_empty_input', () => { + // 边界空值:空菜单树得到空侧边栏列表,不崩溃。 + assert.deepEqual(toSidebarEntries([]), []) + assert.deepEqual(toSidebarEntries(undefined as unknown as AdminMenuNode[]), []) +}) + +test('test_task_006_menu_sidebar_mapping_boundary_single_item', () => { + // 边界单元素:单个顶级路由页不因没有子节点而被丢弃。 + const entries = toSidebarEntries([node({ key: 's', name: '单页', route: '/solo' })]) + assert.equal(entries.length, 1) + assert.equal(entries[0].kind, 'item') + if (entries[0].kind === 'item') assert.equal(entries[0].route, '/solo') +}) + +test('test_task_006_menu_sidebar_mapping_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:子项缺 route 被过滤;分组无任何可路由子项时不渲染占位分组。 + const noRoutableChildren: AdminMenuNode[] = [ + node({ key: 'g', name: '空分组', children: [node({ key: 'c', name: '无路由' })] }), + ] + assert.deepEqual(toSidebarEntries(noRoutableChildren), []) + const withMissing = toSidebarEntries([node({ key: 'g', name: '有', children: [ + node({ key: 'ok', name: '可点', route: '/a' }), + node({ key: 'no', name: '不可点' }), + ] })]) + if (withMissing[0].kind === 'group') { + assert.deepEqual(withMissing[0].children.map((c) => c.key), ['ok']) + } else { + assert.fail('应输出分组') + } +}) + +test('test_task_006_menu_sidebar_mapping_invalid_input_rejected', () => { + // 异常输入:route/children 缺省的畸形节点被跳过而非抛异常。 + const malformed = [ + { key: 'x', name: '畸形' } as unknown as AdminMenuNode, + { key: 'y', name: '无key子项', children: [{ name: '裸' }] } as unknown as AdminMenuNode, + ] + assert.deepEqual(toSidebarEntries(malformed), [], '畸形节点不应产生侧边栏项') +}) + +test('test_task_006_menu_sidebar_mapping_dependency_failure_returns_actionable_message', () => { + // 依赖失败:壳层消费映射模块;激活分组展开按当前路由命中。 + const layout = readSource('src/layout/AdminLayout.vue') + assert.match(layout, /toSidebarEntries/) + assert.match(layout, /groupKeysForActive/) + const active = toSidebarEntries(TREE) + assert.deepEqual(groupKeysForActive(active, '/account/menus'), ['account']) + assert.deepEqual(groupKeysForActive(active, '/account/nope'), [], '未命中路由不展开任何分组') +})