task-54(账号/权限页面): 实现后台菜单列表加载
menu-manage-model.ts 增 buildMenuManageTree 按 parentId 组装展示树(同级按 sort+name); menu-manage-api.ts GET /api/admin/permission-menus(menuType=admin) 扁平拉取并建树; MenusPage 去内联 http,改走 adapter 展示默认展开树形菜单。8 用例全过,395 单测 + build 绿。
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { loadMenuManageTree } from './menu-manage-api'
|
||||
import type { MenuManageNode } from './menu-manage-model'
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<MenuManageNode[]>([])
|
||||
|
||||
async function loadMenus() {
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await loadMenuManageTree()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '菜单列表加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMenus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h2>菜单管理</h2>
|
||||
<p>维护后台菜单树和页面路由。权限粒度仅到菜单/页面。</p>
|
||||
</div>
|
||||
<el-button type="primary">新增菜单</el-button>
|
||||
</div>
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="rows" row-key="id" default-expand-all stripe>
|
||||
<el-table-column prop="name" label="菜单名称" min-width="220" />
|
||||
<el-table-column prop="columnKey" label="权限标识" min-width="240" />
|
||||
<el-table-column prop="routePath" label="路由" min-width="260" />
|
||||
<el-table-column prop="sortOrder" label="排序" width="100" />
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default>
|
||||
<el-button link type="primary">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 菜单管理列表加载适配(任务 54):GET /api/admin/permission-menus(admin) → 展示树。 */
|
||||
import { http } from '@/api/http'
|
||||
import {
|
||||
ADMIN_MENU_TYPE,
|
||||
buildMenuManageTree,
|
||||
parseMenuManageList,
|
||||
type MenuManageNode,
|
||||
} from './menu-manage-model'
|
||||
|
||||
export const MENUS_ENDPOINT = '/api/admin/permission-menus'
|
||||
|
||||
/** 拉取扁平可编辑菜单节点。 */
|
||||
export async function fetchMenuManageNodes(): Promise<MenuManageNode[]> {
|
||||
const { data } = await http.get<unknown>(MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } })
|
||||
return parseMenuManageList(data)
|
||||
}
|
||||
|
||||
/** 拉取并按 parentId 组装成展示树。 */
|
||||
export async function loadMenuManageTree(): Promise<MenuManageNode[]> {
|
||||
return buildMenuManageTree(await fetchMenuManageNodes())
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export interface MenuManageNode {
|
||||
menuType: string
|
||||
routePath: string
|
||||
sortOrder: number
|
||||
children?: MenuManageNode[]
|
||||
}
|
||||
|
||||
export interface MenuManageForm {
|
||||
@@ -86,6 +87,37 @@ export function parseMenuManageList(payload: unknown): MenuManageNode[] {
|
||||
.filter((node): node is MenuManageNode => node !== null)
|
||||
}
|
||||
|
||||
/** 按 parentId 组装展示树;同级按 (sortOrder,name) 升序,父缺失按根处理。 */
|
||||
export function buildMenuManageTree(items: MenuManageNode[]): MenuManageNode[] {
|
||||
const nodes = new Map<number, MenuManageNode>()
|
||||
for (const item of items) {
|
||||
nodes.set(item.id, { ...item, children: [] })
|
||||
}
|
||||
const roots: MenuManageNode[] = []
|
||||
for (const item of items) {
|
||||
const node = nodes.get(item.id)
|
||||
if (!node) continue
|
||||
const parent = item.parentId === null ? undefined : nodes.get(item.parentId)
|
||||
if (parent) {
|
||||
parent.children!.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
const order = (a: MenuManageNode, b: MenuManageNode) =>
|
||||
a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)
|
||||
const sortRecursive = (nodes: MenuManageNode[]) => {
|
||||
nodes.sort(order)
|
||||
for (const node of nodes) node.children?.length && sortRecursive(node.children)
|
||||
}
|
||||
sortRecursive(roots)
|
||||
for (const item of items) {
|
||||
const node = nodes.get(item.id)
|
||||
if (node && !node.children?.length) delete node.children
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
/** 新建/编辑表单空值工厂:默认菜单类型为 admin、排序 0、根父节点。 */
|
||||
export function emptyMenuManageForm(): MenuManageForm {
|
||||
return { name: '', columnKey: '', parentId: null, menuType: ADMIN_MENU_TYPE, routePath: '', sortOrder: 0 }
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readSource } from './helpers.ts'
|
||||
import { ADMIN_MENU_TYPE, buildMenuManageTree, parseMenuManageList } from '../src/pages/account/menu-manage-model.ts'
|
||||
|
||||
test('test_task_054_menu_manage_list_normal_primary_path', () => {
|
||||
// 正常主路径:扁平列表按 parentId 组装树、同级按 sort 排序。
|
||||
const items = parseMenuManageList([
|
||||
{ id: 1, name: '账号权限', column_key: 'account', parent_id: null, route_path: 'group-account', sort_order: 2 },
|
||||
{ id: 2, name: '用户管理', column_key: 'account_users', parent_id: 1, route_path: '/account/users', sort_order: 1 },
|
||||
{ id: 3, name: '菜单管理', column_key: 'admin_columns', parent_id: 1, route_path: '/account/menus', sort_order: 2 },
|
||||
])
|
||||
const tree = buildMenuManageTree(items)
|
||||
assert.equal(tree.length, 1)
|
||||
assert.equal(tree[0].id, 1)
|
||||
assert.equal(tree[0].children?.length, 2)
|
||||
assert.equal(tree[0].children?.[0].columnKey, 'account_users')
|
||||
})
|
||||
|
||||
test('test_task_054_menu_manage_list_normal_variant_input', () => {
|
||||
// 正常变体:多个根 + 缺父的子节点按根处理,不丢节点。
|
||||
const items = parseMenuManageList([
|
||||
{ id: 1, name: 'A', column_key: 'a', parent_id: null },
|
||||
{ id: 2, name: 'B', column_key: 'b', parent_id: 999 },
|
||||
{ id: 3, name: 'C', column_key: 'c', parent_id: null },
|
||||
])
|
||||
const tree = buildMenuManageTree(items)
|
||||
assert.equal(tree.length, 3)
|
||||
const ids = tree.map((n) => n.id).sort((x, y) => x - y)
|
||||
assert.deepEqual(ids, [1, 2, 3])
|
||||
})
|
||||
|
||||
test('test_task_054_menu_manage_list_normal_repeated_operation_is_idempotent', () => {
|
||||
// 正常重复:建树不改输入、结果稳定。
|
||||
const items = parseMenuManageList([{ id: 1, name: 'A', column_key: 'a', parent_id: null, sort_order: 0 }])
|
||||
assert.deepEqual(buildMenuManageTree(items), buildMenuManageTree(items))
|
||||
assert.equal(items[0].sortOrder, 0)
|
||||
})
|
||||
|
||||
test('test_task_054_menu_manage_list_boundary_empty_input', () => {
|
||||
// 边界空值:空负载/空数组 → 空树。
|
||||
assert.deepEqual(parseMenuManageList({}), [])
|
||||
assert.deepEqual(buildMenuManageTree([]), [])
|
||||
})
|
||||
|
||||
test('test_task_054_menu_manage_list_boundary_single_item', () => {
|
||||
// 边界单元素:单根成树。
|
||||
const tree = buildMenuManageTree(parseMenuManageList([{ id: 9, name: 'Only', column_key: 'only', parent_id: null }]))
|
||||
assert.equal(tree.length, 1)
|
||||
assert.equal(tree[0].id, 9)
|
||||
})
|
||||
|
||||
test('test_task_054_menu_manage_list_boundary_limit_or_missing_field', () => {
|
||||
// 边界上限/缺字段:缺 sort 用 0;子按 (sort,name) 稳定。
|
||||
const items = parseMenuManageList([
|
||||
{ id: 1, name: '根', column_key: 'root', parent_id: null },
|
||||
{ id: 2, name: 'b', column_key: 'b', parent_id: 1, sort_order: 2 },
|
||||
{ id: 3, name: 'a', column_key: 'a', parent_id: 1, sort_order: 1 },
|
||||
])
|
||||
const children = buildMenuManageTree(items)[0].children || []
|
||||
assert.deepEqual(children.map((c) => c.id), [3, 2])
|
||||
})
|
||||
|
||||
test('test_task_054_menu_manage_list_invalid_input_rejected', () => {
|
||||
// 异常输入:缺 id 行被解析过滤,不入树。
|
||||
const tree = buildMenuManageTree(parseMenuManageList([{ id: 1, name: 'ok', column_key: 'ok' }, { name: 'bad' }]))
|
||||
assert.equal(tree.length, 1)
|
||||
assert.equal(tree[0].name, 'ok')
|
||||
})
|
||||
|
||||
test('test_task_054_menu_manage_list_dependency_failure_returns_actionable_message', () => {
|
||||
// 依赖失败/加载走 adapter:页面不再内联 http;api 走 GET permission-menus(menuType=admin)+解析建树。
|
||||
const model = readSource('src/pages/account/menu-manage-model.ts')
|
||||
assert.equal(/axios|http\./.test(model), false)
|
||||
const page = readSource('src/pages/account/MenusPage.vue')
|
||||
assert.match(page, /menu-manage-api|fetchMenuManageTree/)
|
||||
assert.equal(/http\.get|unwrap\(/.test(page), false, '页面不再内联请求')
|
||||
const api = readSource('src/pages/account/menu-manage-api.ts')
|
||||
assert.match(api, /permission-menus/)
|
||||
assert.match(api, /parseMenuManageList/)
|
||||
assert.match(api, /buildMenuManageTree/)
|
||||
assert.match(api, /menuType:\s*ADMIN_MENU_TYPE|ADMIN_MENU_TYPE/)
|
||||
})
|
||||
Reference in New Issue
Block a user