align(用户菜单授权): 创建/编辑用户菜单权限补齐前端客户端(app)菜单并按类型分区落库

用户管理菜单权限此前只含后台 admin(单类型):前端授权树/回显仅拉 menuType=admin,
后端 replaceDirectPermissions 也固定用 ADMIN 作用域写。现改为同时覆盖 后台(admin)+前端客户端(app):
- 前端 user-menu-auth 节点携带 type,api 并行拉取 admin/app 两类可授权树与已授权 id 合并去重,树内两类根并存(账号与权限/视频/前端工具/运营工具/后勤工具等)
- 后端 PermissionMenuService 新增 resolveMenuTypes(按 id 解析类型);AdminUserService create/update 把勾选 id 拆 admin/app 分区整树替换(internal 不纳入)
- 前端 1556 测试全绿 + build 通过;后端单测(AdminUserServiceTest/PermissionMenuServiceTest/Controller/PermissionMenuResolveTypeTest) EXIT 0
- 本地 E2E:创建用户写入 admin=7+app=3 → 按类型回读 admin[7]/app[3],删除正常
This commit is contained in:
2026-09-06 15:32:43 +08:00
parent 8034d916bc
commit 898fae4cb3
7 changed files with 184 additions and 19 deletions
@@ -3,8 +3,10 @@ import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
ADMIN_MENU_TYPE,
APP_MENU_TYPE,
buildMenuOptionTree,
buildUserColumnPermissionPayload,
normalizeMenuIds,
parseMenuOptionList,
parseUserGrantedColumnIds,
type MenuOptionNode,
@@ -13,18 +15,33 @@ import {
export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus'
export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users'
/** 拉取后台可授权菜单树(扁平列表组装嵌套、按 sort 排序)。 */
/** 拉取可授权菜单树(后台 admin + 前端客户端 app 两类合并,按 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))
const [adminRes, appRes] = await Promise.all([
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }),
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: APP_MENU_TYPE } }),
])
const nodes = [
...parseMenuOptionList(adminRes.data, ADMIN_MENU_TYPE),
...parseMenuOptionList(appRes.data, APP_MENU_TYPE),
]
return buildMenuOptionTree(nodes)
}
/** 拉取某用户当前直接授权菜单 id 列表。 */
/** 拉取某用户当前直接授权菜单 id 列表admin + app 合并去重)。 */
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)
const [adminRes, appRes] = await Promise.all([
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
params: { menuType: ADMIN_MENU_TYPE },
}),
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
params: { menuType: APP_MENU_TYPE },
}),
])
return normalizeMenuIds([
...parseUserGrantedColumnIds(adminRes.data),
...parseUserGrantedColumnIds(appRes.data),
])
}
/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */
@@ -3,6 +3,8 @@ import { unwrap } from '../../api/envelope.ts'
/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */
export const ADMIN_MENU_TYPE = 'admin'
/** 前端客户端菜单类型(桌面端功能模块,如视频/前端工具/运营工具/后勤工具)。 */
export const APP_MENU_TYPE = 'app'
/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */
export interface MenuOptionNode {
@@ -12,6 +14,8 @@ export interface MenuOptionNode {
name: string
sort: number
parentId: number | null
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
type: string
children?: MenuOptionNode[]
}
@@ -20,7 +24,7 @@ function numberOrNull(value: unknown): number | null {
}
/** 把单条 PermissionMenuItemVo(snake_case) 归一为授权节点;缺 id 视为无效。 */
export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null {
export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode | null {
if (!raw || typeof raw !== 'object') return null
const record = raw as Record<string, unknown>
const id = numberOrNull(record.id)
@@ -34,15 +38,16 @@ export function parsePermissionMenuItem(raw: unknown): MenuOptionNode | null {
name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单',
sort: sortRaw === null ? 0 : sortRaw,
parentId: parentId === null ? null : parentId,
type,
}
}
/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */
export function parseMenuOptionList(payload: unknown): MenuOptionNode[] {
export function parseMenuOptionList(payload: unknown, type = ''): MenuOptionNode[] {
const data = unwrap<unknown>(payload)
if (!Array.isArray(data)) return []
return data
.map((raw) => parsePermissionMenuItem(raw))
.map((raw) => parsePermissionMenuItem(raw, type))
.filter((node): node is MenuOptionNode => node !== null)
}
@@ -0,0 +1,38 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
ADMIN_MENU_TYPE,
APP_MENU_TYPE,
parseMenuOptionList,
parsePermissionMenuItem,
} from '../src/pages/account/user-menu-auth.ts'
// 验收反馈:用户创建/编辑的菜单权限配置要同时包含 后台(admin) 与 前端客户端(app) 两类菜单,
// 并按类型分区落库(internal 不纳入)。
test('align_user_menu_auth_both_types_tree_nodes', () => {
// 授权节点携带 type:admin/app 两类菜单都能进同一棵树。
assert.equal(APP_MENU_TYPE, 'app')
const admin = parsePermissionMenuItem({ id: 1, name: '账号权限', parent_id: null, sort_order: 1 }, ADMIN_MENU_TYPE)
assert.equal(admin?.type, ADMIN_MENU_TYPE)
const app = parseMenuOptionList(
[{ id: 2, name: '视频', parent_id: null, sort_order: 1, column_key: 'wb' }],
APP_MENU_TYPE,
)
assert.equal(app[0].type, APP_MENU_TYPE)
})
test('align_user_menu_auth_both_types_fetch_and_submit_source', () => {
const api = readSource('src/pages/account/user-menu-auth-api.ts')
assert.match(api, /APP_MENU_TYPE/, '拉取/回显含客户端类型')
assert.match(api, /Promise\.all/, '两类菜单并行拉取')
assert.match(api, /normalizeMenuIds/, '两类已授权 id 合并去重')
// 提交仍由后端把勾选 id 按类型分区写(user PUT),前端不再限定单类型。
const service = readSource('../backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java')
assert.match(service, /resolveMenuTypes/, '后端按菜单 id 解析类型')
assert.match(service, /MENU_TYPE_ADMIN/, 'admin 分区写')
assert.match(service, /MENU_TYPE_APP/, 'app 分区写')
const auth = readSource('src/pages/account/user-menu-auth.ts')
assert.match(auth, /type: string/, '授权节点携带 type')
})