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:
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解包用户已授权 columnIds(data.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)
|
||||||
|
}
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user