18 KiB
菜单/权限点管理 — 前端对接文档
遵循《业务中台产品接口说明书》全局契约
接口基础路径:/api/resources
权限要求:管理员角色(ROLE_ADMIN)
1. 接口总览
| 方法 | 路径 | 用途 |
|---|---|---|
GET |
/api/resources/list |
获取全量资源树(扁平数组) |
POST |
/api/resources/saveOrUpdate |
新增或编辑节点 |
POST |
/api/resources/delete |
删除节点 |
POST |
/api/resources/icon/upload |
上传图标 |
认证方式:所有接口需在请求头携带 Authorization: Bearer {token},且 token 对应用户必须具有 ROLE_ADMIN 角色。
请求格式:
GET请求:参数放 URL queryPOST请求:Content-Type: application/x-www-form-urlencoded,参数为表单字段- 文件上传:
Content-Type: multipart/form-data
⚠️ 注意:POST 请求不使用 JSON body,复杂字段(对象/数组)各自
JSON.stringify后作为普通表单字段传递。
2. 数据模型:ResourceNodeDTO
interface ResourceNodeDTO {
id: string | null; // 节点 ID(字符串),新增时不传,编辑时必传
parentId: string | null; // 父节点 ID(字符串),根节点传 null 或 "0"
type: 'CATALOG' | 'MENU' | 'BUTTON'; // 节点类型
name: string; // 显示名称(必填)
sort: number; // 排序号,值越小越靠前(必填)
description?: string; // 描述/备注
// —— menu 类型专用 ——
route?: string; // 前端路由路径,menu 必填
// —— button 类型专用 ——
perms?: string; // 权限码,格式 crm:<module>:<action>
denyBehavior?: string; // 无权限时表现:'hide' | 'disable'
apiUrl?: string; // 按钮对应的后端接口路径
status?: string; // 权限点状态:'enabled' | 'disabled'
// —— catalog/menu 专用 ——
icon?: string; // 图标预览 URL
children?: ResourceNodeDTO[]; // 子节点列表(listAll 返回时填充,其他接口无此字段)
⚠️ ID 一律按字符串收发。后端返回的 id 是字符串(雪花 ID 超出 JS Number 安全范围),前端回传原样传字符串即可,不要转 Number。
节点类型说明
| 类型 | 枚举值 | 说明 | 可挂子节点 |
|---|---|---|---|
| 菜单分组 | CATALOG |
收纳容器,不承载页面 | 只能挂 MENU |
| 菜单页面 | MENU |
可导航的页面 | 只能挂 BUTTON |
| 按钮/权限点 | BUTTON |
叶子节点,承载权限码 | 不可挂子节点 |
层级约束
根节点(parentId=null/"0")
├── CATALOG(菜单分组)
│ └── MENU(菜单页面)
│ └── BUTTON(按钮/权限点)
└── MENU(菜单页面,一级菜单)
└── BUTTON(按钮/权限点)
规则:
- 根节点只能挂
CATALOG或MENU CATALOG下只能挂MENU(不能嵌套分组)MENU下只能挂BUTTONBUTTON是叶子节点,不可再挂子节点
3. 接口详情
3.1 获取全量资源树
GET /api/resources/list
Authorization: Bearer {token}
响应示例(后端已组树,直接渲染即可):
{
"code": 0,
"success": true,
"message": "success",
"data": [
{
"id": "1",
"parentId": "0",
"type": "CATALOG",
"name": "系统管理",
"sort": 1,
"icon": "https://cdn.example.com/icons/system.png",
"children": [
{
"id": "10",
"parentId": "1",
"type": "MENU",
"name": "用户管理",
"sort": 1,
"route": "/system/user",
"children": [
{
"id": "100",
"parentId": "10",
"type": "BUTTON",
"name": "查询用户",
"sort": 1,
"perms": "crm:user:list",
"denyBehavior": "hide",
"apiUrl": "/api/system/users/page",
"status": "enabled",
"description": "查询用户列表",
"children": []
}
]
}
]
}
]
}
前端处理:后端已通过 TreeUtils.buildTree 组好树,data 直接就是树形结构,无需前端再按 parentId 组树。直接渲染即可:
// 直接渲染后端返回的树形数据
const tree = response.data; // 已是树形结构
<Tree treeData={tree} />
3.2 新增/编辑节点
POST /api/resources/saveOrUpdate
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer {token}
表单参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | string | 编辑时必传 | 不传为新增,传为编辑 |
| parentId | string | 是 | 父节点 ID,根节点传 "0" |
| type | string | 是 | CATALOG / MENU / BUTTON |
| name | string | 是 | 显示名称 |
| sort | number | 是 | 排序号 |
| description | string | 否 | 描述/备注 |
| route | string | menu 必填 | 前端路由路径 |
| perms | string | button 必填 | 权限码 |
| denyBehavior | string | button 必填 | hide / disable |
| apiUrl | string | button 必填 | 后端接口路径 |
| status | string | button 必填 | enabled / disabled |
| icon | string | catalog/menu 可选 | 图标预览 URL |
请求示例(新增菜单分组):
POST /api/resources/saveOrUpdate
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer eyJhbGciOi...
parentId=0&type=CATALOG&name=业务管理&sort=10&description=CRM核心业务模块
请求示例(新增按钮/权限点):
POST /api/resources/saveOrUpdate
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer eyJhbGciOi...
parentId=1002&type=BUTTON&name=删除客户&sort=3&perms=crm:customer:delete&denyBehavior=disable&apiUrl=/api/crm/customer/delete&status=enabled&description=删除客户及其关联数据
请求示例(编辑节点 — 改名):
POST /api/resources/saveOrUpdate
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer eyJhbGciOi...
id=10&parentId=1&type=MENU&name=客户管理改&sort=1&route=/customer/list
响应:返回保存后的完整节点。
{
"code": 0,
"success": true,
"message": "success",
"data": {
"id": "11",
"parentId": "1",
"type": "MENU",
"name": "客户管理",
"sort": 2,
"route": "/customer/list"
}
}
字段必填规则(按类型):
| 字段 | CATALOG | MENU | BUTTON |
|---|---|---|---|
| name | ✅ 必填 | ✅ 必填 | ✅ 必填 |
| sort | ✅ 必填 | ✅ 必填 | ✅ 必填 |
| route | ❌ | ✅ 必填 | ❌ |
| perms | ❌ | ❌ | ✅ 必填 |
| denyBehavior | ❌ | ❌ | ✅ 必填 |
| apiUrl | ❌ | ❌ | ✅ 必填 |
| status | ❌ | ❌ | ✅ 必填 |
| icon | 可选 | 可选 | ❌ |
| description | 可选 | 可选 | 可选 |
编辑限制:
type字段创建后不可变更(编辑时传不同的 type 会被拒绝)- 编辑时
route可不传,后端保留原有值(除非传新值覆盖)
3.3 删除节点
POST /api/resources/delete
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer {token}
表单参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | string | 是 | 要删除的节点 ID |
请求示例:
POST /api/resources/delete
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer eyJhbGciOi...
id=100
校验规则:
- 节点必须存在
- 节点下不能有子节点(有子节点会返回错误,需先删除子节点)
- 删除时自动清理各角色对该节点的授权引用
成功响应:
{
"code": 0,
"success": true,
"message": "success"
}
失败响应示例(有子节点):
{
"code": 61009,
"success": false,
"message": "节点下有 3 个子节点,请先删除子节点"
}
3.4 上传图标
POST /api/resources/icon/upload
Content-Type: multipart/form-data
Authorization: Bearer {token}
参数:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| file | File | ✅ | 图片文件 |
限制:
- 格式:
png/jpg/jpeg/gif/svg/webp - 大小:≤ 500KB
请求示例:
POST /api/resources/icon/upload
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
Authorization: Bearer eyJhbGciOi...
------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="icon.png"
Content-Type: image/png
(二进制内容)
------WebKitFormBoundary--
响应:
{
"code": 0,
"success": true,
"message": "success",
"data": {
"fileId": "1234567890",
"originalName": "icon.png",
"size": 12345,
"contentType": "image/png",
"bizDomain": "resources/icon"
}
}
前端处理流程:
async function uploadIcon(file: File): Promise<string> {
// 1. 上传图标,获取 fileId
const formData = new FormData();
formData.append('file', file);
const uploadRes = await fetch('/api/resources/icon/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData, // multipart/form-data 由浏览器自动设置
});
const { data: fileInfo } = await uploadRes.json();
// 2. 通过 fileId 获取预览 URL
const previewRes = await fetch(`/api/file/preview-url?fileId=${fileInfo.fileId}`, {
headers: { Authorization: `Bearer ${token}` },
});
const { data: previewUrl } = await previewRes.json();
// 3. 返回预览 URL,存入节点的 icon 字段
return previewUrl;
}
// 使用示例
const iconUrl = await uploadIcon(selectedFile);
// 然后在保存节点时将 iconUrl 作为 icon 表单字段传入
await saveNode({ ...node, icon: iconUrl });
4. 停用与启用权限点
停用/启用权限点没有单独的接口,通过编辑节点(POST /api/resources/saveOrUpdate)更新 status 字段实现。
操作方式
POST /api/resources/saveOrUpdate
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer eyJhbGciOi...
id=1003&parentId=1002&type=BUTTON&name=删除客户&sort=3&perms=crm:customer:delete&denyBehavior=disable&apiUrl=/api/crm/customer/delete&status=disabled
| 操作 | status 值 | 说明 |
|---|---|---|
| 停用 | disabled |
权限点全局失效,所有用户均无法使用该权限 |
| 启用 | enabled |
恢复权限点生效,原角色授权自动恢复 |
业务语义
- 停用:即使角色已勾选授予该权限点,权限解析引擎也不将其
perms纳入用户的权限码并集——相当于全局断路,无需逐角色取消授权 - 启用:重新启用后,原授权自动恢复生效,无需管理员重新到角色授权中勾选
- 数据影响:停用/启用只修改
status字段,不涉及角色授权关系(sys_role_menu)的变动
前端处理建议
// 权限点状态切换
async function togglePermissionStatus(nodeId: string, enabled: boolean) {
const node = await getNode(nodeId);
const params = new URLSearchParams();
params.append('id', node.id);
params.append('parentId', node.parentId);
params.append('type', node.type);
params.append('name', node.name);
params.append('sort', node.sort);
params.append('perms', node.perms);
params.append('denyBehavior', node.denyBehavior);
params.append('apiUrl', node.apiUrl);
params.append('status', enabled ? 'enabled' : 'disabled');
await fetch('/api/resources/saveOrUpdate', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params,
});
}
// 在 UI 上显示停用状态
<Switch
checked={node.status === 'enabled'}
onChange={(checked) => togglePermissionStatus(node.id, checked)}
/>
5. 错误处理
所有错误响应格式统一:
{
"code": 61009,
"success": false,
"message": "具体错误信息"
}
判定规则:判成功只看 success === true;判失败后的分支处理(跳登录、回填表单等)再看 code。
HTTP 状态码:业务失败 HTTP 状态码仍是 200,通过 success/code 判断。仅以下场景 HTTP 状态码非 200:
| HTTP | 场景 | 响应体 code |
|---|---|---|
| 401 | 未登录 / 登录过期 | 40103 |
| 403 | 已登录但无权限 | 40301 |
| 404 | 接口路径不存在 | 40401 |
| 500 | 系统内部异常 | 50001 |
即使 HTTP 状态码非 200,响应体仍然是统一信封结构。
资源管理接口常见错误:
| 消息 | 原因 |
|---|---|
节点名称不能为空 |
name 字段为空 |
节点类型不能为空 |
type 字段为空 |
menu 类型必须提供路由(route) |
MENU 类型缺少 route |
button 类型必须提供权限码(perms) |
BUTTON 类型缺少 perms |
button 类型必须提供无权限行为(denyBehavior) |
BUTTON 类型缺少 denyBehavior |
button 类型必须提供接口 URL(apiUrl) |
BUTTON 类型缺少 apiUrl |
button 类型必须提供状态(status) |
BUTTON 类型缺少 status |
根节点不允许添加 button 类型 |
违反层级约束 |
catalog 节点下只能添加 menu 类型 |
违反层级约束 |
menu 节点下只能添加 button 类型 |
违反层级约束 |
button 是叶子节点,不可添加子节点 |
违反层级约束 |
节点类型不可变更 |
编辑时修改了 type |
节点下有 N 个子节点,请先删除子节点 |
删除非叶子节点 |
不支持的图标格式 |
上传非法格式 |
图标大小不能超过 500KB |
文件过大 |
6. 联调指南
6.1 前置条件
- 确保后端服务已启动(Base URL 由后端另行下发)
- 使用管理员账号通过钉钉扫码登录获取 token
- 请求头统一添加
Authorization: Bearer {token} - token 由后端滑动续期,前端无需定时刷新;收到 401(code=40103)时清除 token 跳登录页
6.2 快速验证
# 1. 获取全量资源树
curl -X GET "{baseUrl}/api/resources/list" \
-H "Authorization: Bearer YOUR_TOKEN"
# 2. 新增一个菜单分组
curl -X POST "{baseUrl}/api/resources/saveOrUpdate" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "parentId=0&type=CATALOG&name=测试分组&sort=99"
# 3. 在分组下新增菜单
curl -X POST "{baseUrl}/api/resources/saveOrUpdate" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "parentId={上一步返回的id}&type=MENU&name=测试菜单&sort=1&route=/test/menu"
# 4. 在菜单下新增按钮
curl -X POST "{baseUrl}/api/resources/saveOrUpdate" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "parentId={菜单id}&type=BUTTON&name=测试按钮&sort=1&perms=crm:test:action&denyBehavior=hide&apiUrl=/api/test/action&status=enabled"
# 5. 删除节点
curl -X POST "{baseUrl}/api/resources/delete" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "id={节点id}"
6.3 前端开发建议
- 树形数据:后端已组好树并按 sort 排序,前端直接渲染
data即可,无需自行组树 - 表单提交:使用
URLSearchParams或qs库构造表单字段,不要用JSON.stringify整体打包 - ID 处理:所有 id 字段按字符串处理,不要
parseInt/Number() - 表单校验:根据节点类型动态显示必填字段
- CATALOG:name, sort
- MENU:name, sort, route
- BUTTON:name, sort, perms, denyBehavior, apiUrl, status
- 类型锁定:编辑时 type 字段应置灰不可修改
- 图标上传:先上传获取 URL,再保存节点
- 权限码格式:建议提供下拉选择或自动补全,格式为
crm:<module>:<action>- module:
lead,opportunity,customer,project - action:
add,edit,delete,export,transfer等
- module:
6.4 调试技巧
- 每个响应都带响应头
X-Trace-Id,报障时附上此 ID - 使用浏览器开发者工具 Network 面板查看请求/响应
- 后端日志会打印保存的节点信息,便于排查
- 403 错误检查 token 是否过期、用户是否有 ADMIN 角色
- 61009 错误查看 message 字段获取具体原因
7. 权限码并集说明
用户登录后,后端会自动计算其权限码并集(所有角色授权的启用状态按钮的 perms),注入到 Spring Security 上下文中。
前端使用:
- 登录后从
GET /api/auth/me获取用户信息及authorities列表 - 根据
authorities控制按钮显示/隐藏
// 示例:检查是否有某权限
const hasPermission = (perm: string) => {
return userAuthorities.includes(perm);
};
// 使用
{hasPermission('crm:customer:delete') && <DeleteButton />}
停用权限点:
- 当按钮的
status设为disabled时,即使角色已授权,该权限码也不会出现在用户权限并集中 - 相当于全局断路,无需逐角色取消授权
- 重新启用后原授权自动恢复
8. 与角色授权的集成
资源树维护完成后,在角色管理 → 授权页面使用:
- 调用
GET /api/resources/list获取全量资源树 - 渲染为勾选树(checkbox tree)
- 勾选节点即授予该角色对应权限
- 勾选菜单节点时,前端自动联动勾选其全部祖先节点(确保菜单可见)
// 联动勾选祖先
function collectAncestorIds(nodeId: string, tree: TreeNode[]): string[] {
const ancestors: string[] = [];
const findPath = (nodes: TreeNode[], target: string, path: string[]): boolean => {
for (const node of nodes) {
if (node.id === target) {
ancestors.push(...path);
return true;
}
if (findPath(node.children, target, [...path, node.id!])) {
return true;
}
}
return false;
};
findPath(tree, nodeId, []);
return ancestors;
}