task-6(壳层/路由): 定义后端菜单树到侧边栏节点映射规则并落地
新增 src/layout/menu-mapper.ts:toSidebarEntries 处理分组/顶级路由叶/畸形节点, groupKeysForActive 按当前路由展开激活分组;AdminLayout 菜单区改用映射结果, 删除旧 visibleChildren/canSee 死逻辑;8 用例覆盖空树/单叶/缺 route 过滤等。
This commit is contained in:
@@ -5,7 +5,7 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||||
import { joinAdminPath } from '@/config/app'
|
import { joinAdminPath } from '@/config/app'
|
||||||
import { pageTitleOf, topbarUserOf } from '@/layout/topbar-model'
|
import { pageTitleOf, topbarUserOf } from '@/layout/topbar-model'
|
||||||
import type { AdminMenuNode } from '@/types/admin'
|
import { groupKeysForActive, toSidebarEntries } from '@/layout/menu-mapper'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -13,27 +13,12 @@ const session = useAdminSessionStore()
|
|||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
|
|
||||||
const activePath = computed(() => route.path)
|
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 pageTitle = computed(() => pageTitleOf(route.meta.title as string | undefined))
|
||||||
const userVm = computed(() => topbarUserOf(session.user))
|
const userVm = computed(() => topbarUserOf(session.user))
|
||||||
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
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() {
|
async function signOut() {
|
||||||
try {
|
try {
|
||||||
await session.signOut()
|
await session.signOut()
|
||||||
@@ -55,23 +40,22 @@ async function signOut() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-scrollbar class="admin-menu-scroll">
|
<el-scrollbar class="admin-menu-scroll">
|
||||||
<el-menu :default-active="activePath" :collapse="collapsed" router class="admin-menu">
|
<el-menu :default-active="activePath" :default-openeds="openedKeys" :collapse="collapsed" router class="admin-menu">
|
||||||
<template v-for="group in groups" :key="group.key">
|
<template v-for="entry in menuEntries" :key="entry.key">
|
||||||
<el-sub-menu :index="group.key">
|
<el-sub-menu v-if="entry.kind === 'group'" :index="entry.key">
|
||||||
<template #title>
|
<template #title>
|
||||||
<span class="menu-group-title">{{ group.name }}</span>
|
<span class="menu-group-title">{{ entry.name }}</span>
|
||||||
</template>
|
</template>
|
||||||
<el-menu-item
|
<el-menu-item v-for="item in entry.children" :key="item.key" :index="item.route">
|
||||||
v-for="item in visibleChildren(group)"
|
|
||||||
:key="item.key"
|
|
||||||
:index="menuPath(item)"
|
|
||||||
>
|
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
|
<el-menu-item v-else :key="entry.key" :index="entry.route">
|
||||||
|
{{ entry.name }}
|
||||||
|
</el-menu-item>
|
||||||
</template>
|
</template>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
<el-empty v-if="!groups.length && !session.loading" description="暂无可用菜单" :image-size="72" />
|
<el-empty v-if="!menuEntries.length && !session.loading" description="暂无可用菜单" :image-size="72" />
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
|
|
||||||
<button class="sidebar-collapse" type="button" @click="collapsed = !collapsed">
|
<button class="sidebar-collapse" type="button" @click="collapsed = !collapsed">
|
||||||
@@ -100,5 +84,3 @@ async function signOut() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ test('test_task_003_layout_contract_boundary_empty_input', () => {
|
|||||||
// 边界空值:菜单为空时应展示无菜单状态,且页面容器本身仍保留。
|
// 边界空值:菜单为空时应展示无菜单状态,且页面容器本身仍保留。
|
||||||
const layout = readSource(LAYOUT)
|
const layout = readSource(LAYOUT)
|
||||||
assert.ok(layout.includes('暂无可用菜单'), '无菜单需有空状态文案')
|
assert.ok(layout.includes('暂无可用菜单'), '无菜单需有空状态文案')
|
||||||
assert.ok(layout.includes('!groups.length'), '空菜单由 groups 长度驱动')
|
assert.ok(layout.includes('menuEntries.length'), '空菜单由映射结果长度驱动')
|
||||||
assert.equal(occurrences(layout, '<RouterView'), 1)
|
assert.equal(occurrences(layout, '<RouterView'), 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import { groupKeysForActive, toSidebarEntries } from '../src/layout/menu-mapper.ts'
|
||||||
|
import type { AdminMenuNode } from '../src/types/admin.ts'
|
||||||
|
|
||||||
|
function node(partial: Partial<AdminMenuNode> & { 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'), [], '未命中路由不展开任何分组')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user