task-49(账号/权限页面): 实现用户菜单授权数据加载

新增 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 绿。
This commit is contained in:
2026-09-05 15:06:58 +08:00
parent fba8e35118
commit 0fcee02189
3 changed files with 221 additions and 0 deletions
@@ -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<MenuOptionNode[]> {
const { data } = await http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } })
return buildMenuOptionTree(parseMenuOptionList(data))
}
/** 拉取某用户当前直接授权菜单 id 列表。 */
export async function fetchUserGrantedColumnIds(userId: number): Promise<number[]> {
const { data } = await http.get<unknown>(`${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<UserMenuAuthData> {
const [tree, checkedIds] = await Promise.all([fetchGrantableMenus(), fetchUserGrantedColumnIds(userId)])
return { tree, checkedIds }
}
@@ -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<string, unknown>
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<unknown>(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<number, MenuOptionNode>()
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
}
/** 解包用户已授权 columnIdsdata.columnIds)→ 去重升序正整数。 */
export function parseUserGrantedColumnIds(payload: unknown): number[] {
const data = unwrap<unknown>(payload)
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : 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<number>()
for (const value of raw) {
const id = numberOrNull(value)
if (id !== null && id >= 1) ids.add(id)
}
return [...ids].sort((a, b) => a - b)
}
+87
View File
@@ -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 抛后端 messagegranted 无 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', () => {
// 依赖失败/加载走 adapterGET 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/)
})