From ffb16bea9aa253e3443f107d00e34b22695cc47e 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 15:16:25 +0800 Subject: [PATCH] =?UTF-8?q?task-54(=E8=B4=A6=E5=8F=B7/=E6=9D=83=E9=99=90?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2):=20=E5=AE=9E=E7=8E=B0=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E5=88=97=E8=A1=A8=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 绿。 --- .../src/pages/account/MenusPage.vue | 47 +++++++++++ .../src/pages/account/menu-manage-api.ts | 21 +++++ .../src/pages/account/menu-manage-model.ts | 32 +++++++ admin-frontend-vue/tests/task-54.test.ts | 83 +++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 admin-frontend-vue/src/pages/account/MenusPage.vue create mode 100644 admin-frontend-vue/src/pages/account/menu-manage-api.ts create mode 100644 admin-frontend-vue/tests/task-54.test.ts diff --git a/admin-frontend-vue/src/pages/account/MenusPage.vue b/admin-frontend-vue/src/pages/account/MenusPage.vue new file mode 100644 index 00000000..58f079cd --- /dev/null +++ b/admin-frontend-vue/src/pages/account/MenusPage.vue @@ -0,0 +1,47 @@ + + + + + + + 菜单管理 + 维护后台菜单树和页面路由。权限粒度仅到菜单/页面。 + + 新增菜单 + + + + + + + + + + 编辑 + + + + + + diff --git a/admin-frontend-vue/src/pages/account/menu-manage-api.ts b/admin-frontend-vue/src/pages/account/menu-manage-api.ts new file mode 100644 index 00000000..01ff38ad --- /dev/null +++ b/admin-frontend-vue/src/pages/account/menu-manage-api.ts @@ -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 { + const { data } = await http.get(MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }) + return parseMenuManageList(data) +} + +/** 拉取并按 parentId 组装成展示树。 */ +export async function loadMenuManageTree(): Promise { + return buildMenuManageTree(await fetchMenuManageNodes()) +} diff --git a/admin-frontend-vue/src/pages/account/menu-manage-model.ts b/admin-frontend-vue/src/pages/account/menu-manage-model.ts index 6887d677..680aa03b 100644 --- a/admin-frontend-vue/src/pages/account/menu-manage-model.ts +++ b/admin-frontend-vue/src/pages/account/menu-manage-model.ts @@ -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() + 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 } diff --git a/admin-frontend-vue/tests/task-54.test.ts b/admin-frontend-vue/tests/task-54.test.ts new file mode 100644 index 00000000..0d7ed6a1 --- /dev/null +++ b/admin-frontend-vue/tests/task-54.test.ts @@ -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/) +})
维护后台菜单树和页面路由。权限粒度仅到菜单/页面。