From 0fcee021899dc5e1402868ace4ab746fac02d8c8 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:06:58 +0800 Subject: [PATCH] =?UTF-8?q?task-49(=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=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=8E=88=E6=9D=83=E6=95=B0=E6=8D=AE=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 user-menu-auth.ts 纯解析:parseMenuOptionList 归一 permission-menus 扁平项、 buildMenuOptionTree 按 parent_id 组装并按 (sort,key) 排序、parseUserGrantedColumnIds/ normalizeMenuIds 解包用户已授权 id 去重升序。user-menu-auth-api.ts 走 GET /api/admin/permission-menus(menuType=admin) 与 /permission-users/{id}/columns 加载授权树与已勾选。 8 用例全过,355 单测 + build 绿。 --- .../src/pages/account/user-menu-auth-api.ts | 37 +++++++ .../src/pages/account/user-menu-auth.ts | 97 +++++++++++++++++++ admin-frontend-vue/tests/task-49.test.ts | 87 +++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 admin-frontend-vue/src/pages/account/user-menu-auth-api.ts create mode 100644 admin-frontend-vue/src/pages/account/user-menu-auth.ts create mode 100644 admin-frontend-vue/tests/task-49.test.ts diff --git a/admin-frontend-vue/src/pages/account/user-menu-auth-api.ts b/admin-frontend-vue/src/pages/account/user-menu-auth-api.ts new file mode 100644 index 00000000..a846a9ed --- /dev/null +++ b/admin-frontend-vue/src/pages/account/user-menu-auth-api.ts @@ -0,0 +1,37 @@ +/** 用户菜单授权数据加载适配(任务 49):拉取可授权菜单树 + 用户已授权 id。 */ +import { http } from '@/api/http' +import { + ADMIN_MENU_TYPE, + buildMenuOptionTree, + parseMenuOptionList, + parseUserGrantedColumnIds, + type MenuOptionNode, +} from './user-menu-auth' + +export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus' +export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users' + +/** 拉取后台可授权菜单树(扁平列表组装嵌套、按 sort 排序)。 */ +export async function fetchGrantableMenus(): Promise { + const { data } = await http.get(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }) + return buildMenuOptionTree(parseMenuOptionList(data)) +} + +/** 拉取某用户当前直接授权菜单 id 列表。 */ +export async function fetchUserGrantedColumnIds(userId: number): Promise { + const { data } = await http.get(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, { + params: { menuType: ADMIN_MENU_TYPE }, + }) + return parseUserGrantedColumnIds(data) +} + +/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */ +export interface UserMenuAuthData { + tree: MenuOptionNode[] + checkedIds: number[] +} + +export async function loadUserMenuAuth(userId: number): Promise { + const [tree, checkedIds] = await Promise.all([fetchGrantableMenus(), fetchUserGrantedColumnIds(userId)]) + return { tree, checkedIds } +} diff --git a/admin-frontend-vue/src/pages/account/user-menu-auth.ts b/admin-frontend-vue/src/pages/account/user-menu-auth.ts new file mode 100644 index 00000000..b2b71ce7 --- /dev/null +++ b/admin-frontend-vue/src/pages/account/user-menu-auth.ts @@ -0,0 +1,97 @@ +/** 用户菜单授权数据解析模型(任务 49):permission-menus 扁平项 → 嵌套树 + 用户已授权 id。 */ +import { unwrap } from '../../api/envelope.ts' + +/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */ +export const ADMIN_MENU_TYPE = 'admin' + +/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */ +export interface MenuOptionNode { + id: number + /** column_key,稳定权限标识;缺省用 id 字符串兜底。 */ + key: string + name: string + sort: number + parentId: number | null + children?: MenuOptionNode[] +} + +function numberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null +} + +/** 把单条 PermissionMenuItemVo(snake_case) 归一为授权节点;缺 id 视为无效。 */ +export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null { + if (!raw || typeof raw !== 'object') return null + const record = raw as Record + const id = numberOrNull(record.id) + if (id === null) return null + const parentId = numberOrNull(record.parent_id) + const sortRaw = numberOrNull(record.sort_order) + const columnKey = typeof record.column_key === 'string' ? record.column_key.trim() : '' + return { + id, + key: columnKey || String(id), + name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单', + sort: sortRaw === null ? 0 : sortRaw, + parentId: parentId === null ? null : parentId, + } +} + +/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */ +export function parseMenuOptionList(payload: unknown): MenuOptionNode[] { + const data = unwrap(payload) + if (!Array.isArray(data)) return [] + return data + .map((raw) => parsePermissionMenuItem(raw)) + .filter((node): node is MenuOptionNode => node !== null) +} + +/** 按 parent_id 组装嵌套树;同级按 (sort,key) 升序,父缺失的节点按根处理。 */ +export function buildMenuOptionTree(items: MenuOptionNode[]): MenuOptionNode[] { + const nodes = new Map() + for (const item of items) { + nodes.set(item.id, { ...item, children: [] }) + } + const roots: MenuOptionNode[] = [] + 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: MenuOptionNode, b: MenuOptionNode) => + a.sort - b.sort || a.key.localeCompare(b.key) + const sortRecursive = (nodes: MenuOptionNode[]) => { + 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 +} + +/** 解包用户已授权 columnIds(data.columnIds)→ 去重升序正整数。 */ +export function parseUserGrantedColumnIds(payload: unknown): number[] { + const data = unwrap(payload) + const record = data && typeof data === 'object' ? (data as Record) : null + if (!record || !Array.isArray(record.columnIds)) return [] + return normalizeMenuIds(record.columnIds) +} + +/** 任意输入 id 集合归一为去重升序正整数(镜像后端 normalizeColumnIds)。 */ +export function normalizeMenuIds(raw: unknown): number[] { + if (!Array.isArray(raw)) return [] + const ids = new Set() + for (const value of raw) { + const id = numberOrNull(value) + if (id !== null && id >= 1) ids.add(id) + } + return [...ids].sort((a, b) => a - b) +} diff --git a/admin-frontend-vue/tests/task-49.test.ts b/admin-frontend-vue/tests/task-49.test.ts new file mode 100644 index 00000000..c8a4e8db --- /dev/null +++ b/admin-frontend-vue/tests/task-49.test.ts @@ -0,0 +1,87 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + ADMIN_MENU_TYPE, + buildMenuOptionTree, + normalizeMenuIds, + parseMenuOptionList, + parsePermissionMenuItem, + parseUserGrantedColumnIds, +} from '../src/pages/account/user-menu-auth.ts' + +test('test_task_049_user_menu_auth_load_normal_primary_path', () => { + // 正常主路径:扁平 permission-menus 归一后按 parent_id 组装出嵌套、按 sort 排序的树。 + const items = parseMenuOptionList({ + success: true, + data: [ + { id: 1, name: '账号权限', column_key: 'account', parent_id: null, sort_order: 1, menu_type: 'admin' }, + { id: 2, name: '用户管理', column_key: 'account_users', parent_id: 1, sort_order: 1, menu_type: 'admin' }, + { id: 3, name: '角色组', column_key: 'account_groups', parent_id: 1, sort_order: 2, menu_type: 'admin' }, + ], + }) + assert.equal(items.length, 3) + const tree = buildMenuOptionTree(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].key, 'account_users') + assert.deepEqual(parseUserGrantedColumnIds({ success: true, data: { columnIds: [3, 2] } }), [2, 3]) +}) + +test('test_task_049_user_menu_auth_load_normal_variant_input', () => { + // 正常变体:缺 column_key 用 id 兜底;父节点缺失的子节点按根处理。 + const orphan = parsePermissionMenuItem({ id: 9, name: '孤儿', parent_id: 99, sort_order: 0 }) + assert.equal(orphan?.parentId, 99) + assert.equal(buildMenuOptionTree([orphan as never])[0].id, 9) + const node = parsePermissionMenuItem({ id: 5, name: 'x', sort_order: 3 }) + assert.equal(node?.key, '5') + assert.equal(node?.name, 'x') +}) + +test('test_task_049_user_menu_auth_load_normal_repeated_operation_is_idempotent', () => { + // 正常重复:组装不改输入、结果稳定。 + const items = parseMenuOptionList([{ id: 1, name: 'A', parent_id: null, sort_order: 0 }]) + assert.deepEqual(buildMenuOptionTree(items), buildMenuOptionTree(items)) + assert.equal(items[0].id, 1) +}) + +test('test_task_049_user_menu_auth_load_boundary_empty_input', () => { + // 边界空值:空负载/空数组/缺 granted 字段均回默认,不崩溃。 + assert.deepEqual(parseMenuOptionList({}), []) + assert.deepEqual(buildMenuOptionTree([]), []) + assert.deepEqual(parseUserGrantedColumnIds({ success: true, data: {} }), []) + assert.deepEqual(normalizeMenuIds(undefined), []) +}) + +test('test_task_049_user_menu_auth_load_boundary_single_item', () => { + // 边界单元素:单根节点成树。 + const items = parseMenuOptionList([{ id: 7, name: 'Only', column_key: 'only', parent_id: null, sort_order: 0 }]) + const tree = buildMenuOptionTree(items) + assert.equal(tree.length, 1) + assert.equal(tree[0].key, 'only') +}) + +test('test_task_049_user_menu_auth_load_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:缺 id 的行被过滤;非法/重复 granted 去重并升序。 + assert.equal(parsePermissionMenuItem({ name: 'no-id' }), null) + assert.deepEqual(normalizeMenuIds([2, 1, 2, -1, 3.7, 'x' as never]), [1, 2, 3]) +}) + +test('test_task_049_user_menu_auth_load_invalid_input_rejected', () => { + // 异常输入:success=false 抛后端 message;granted 无 columnIds 字段回默认。 + assert.throws(() => parseMenuOptionList({ success: false, message: '无权限查看菜单' }), /无权限查看菜单/) + assert.deepEqual(parseUserGrantedColumnIds({ success: true, data: { nothing: 1 } }), []) +}) + +test('test_task_049_user_menu_auth_load_dependency_failure_returns_actionable_message', () => { + // 依赖失败/加载走 adapter:GET permission-menus(menuType=admin)+permission-users/{id}/columns,页面不内联。 + const mod = readSource('src/pages/account/user-menu-auth.ts') + assert.equal(/axios|http\./.test(mod), false, '解析模型保持纯逻辑') + const api = readSource('src/pages/account/user-menu-auth-api.ts') + assert.match(api, /permission-menus/) + assert.match(api, /permission-users\/\$?\{?userId|permission-users/) + assert.match(api, new RegExp(`menuType.*${ADMIN_MENU_TYPE}|${ADMIN_MENU_TYPE}`)) + assert.match(api, /parseMenuOptionList/) + assert.match(api, /parseUserGrantedColumnIds/) +})