Browse Source

feat: /api/resources/list 后端组树返回 — TreeUtils.buildTree + sort

- ResourceNode 新增 children 字段
- listAll() 改用 TreeUtils.buildTree 组树,按 sort 排序
- 前端无需自行组树,直接渲染 data
- 测试适配树结构,前端文档同步更新
master
luoweijian 1 month ago
parent
commit
ae038a1398
  1. 4
      crm-auth/src/main/java/com/crm/auth/domain/dto/ResourceNode.java
  2. 6
      crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java
  3. 29
      crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java
  4. 47
      docs/frontend-integration-menu-permission.md

4
crm-auth/src/main/java/com/crm/auth/domain/dto/ResourceNode.java

@ -8,6 +8,7 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.util.List;
/** /**
* 权限资源树节点 DTOADR-0011 * 权限资源树节点 DTOADR-0011
@ -55,6 +56,9 @@ public class ResourceNode implements Serializable {
@Schema(description = "图标 URL,catalog/menu 可选") @Schema(description = "图标 URL,catalog/menu 可选")
private String icon; private String icon;
@Schema(description = "子节点列表(仅 listAll 返回时填充)")
private List<ResourceNode> children;
/** SysMenu → ResourceNode(id 映射 + menuName→name + path→route) */ /** SysMenu → ResourceNode(id 映射 + menuName→name + path→route) */
public static ResourceNode fromEntity(SysMenu entity) { public static ResourceNode fromEntity(SysMenu entity) {
ResourceNode dto = BeanCopyUtils.copy(entity, ResourceNode::new); ResourceNode dto = BeanCopyUtils.copy(entity, ResourceNode::new);

6
crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java

@ -11,6 +11,7 @@ import com.crm.auth.mapper.SysRoleMenuMapper;
import com.crm.auth.service.IResourceService; import com.crm.auth.service.IResourceService;
import com.crm.auth.service.ISysMenuService; import com.crm.auth.service.ISysMenuService;
import com.crm.base.domain.exception.BusinessErrorException; import com.crm.base.domain.exception.BusinessErrorException;
import com.crm.base.utils.TreeUtils;
import com.crm.file.api.FileApi; import com.crm.file.api.FileApi;
import com.crm.file.domain.dto.FileInfoDTO; import com.crm.file.domain.dto.FileInfoDTO;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -19,6 +20,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -40,9 +42,11 @@ public class ResourceServiceImpl implements IResourceService {
@Override @Override
public List<ResourceNode> listAll() { public List<ResourceNode> listAll() {
return sysMenuService.list().stream() List<ResourceNode> nodes = sysMenuService.list().stream()
.map(ResourceNode::fromEntity) .map(ResourceNode::fromEntity)
.sorted(Comparator.comparingInt(n -> n.getSort() != null ? n.getSort() : 0))
.collect(Collectors.toList()); .collect(Collectors.toList());
return TreeUtils.buildTree(nodes, ResourceNode::getId, ResourceNode::getParentId, ResourceNode::setChildren);
} }
@Override @Override

29
crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java

@ -68,19 +68,30 @@ class ResourceServiceImplTest {
// ==================== 查询 ==================== // ==================== 查询 ====================
@Test @Test
@DisplayName("全量资源树查询 -> 返回扁平 ResourceNode 数组,含 catalog/menu/button") @DisplayName("全量资源树查询 -> 返回树形结构,catalog→menu→button 三级嵌套")
void listAll_returnsFlatListWithAllTypes() { void listAll_returnsTreeWithAllTypes() {
when(sysMenuService.list()).thenReturn(List.of(catalog, menu, button)); when(sysMenuService.list()).thenReturn(List.of(catalog, menu, button));
List<ResourceNode> result = resourceService.listAll(); List<ResourceNode> result = resourceService.listAll();
assertThat(result).hasSize(3); // 根节点只有 catalog
assertThat(result.get(0).getType()).isEqualTo(MenuType.CATALOG); assertThat(result).hasSize(1);
assertThat(result.get(0).getName()).isEqualTo("系统管理"); ResourceNode root = result.get(0);
assertThat(result.get(1).getType()).isEqualTo(MenuType.MENU); assertThat(root.getType()).isEqualTo(MenuType.CATALOG);
assertThat(result.get(1).getRoute()).isEqualTo("/system/user"); assertThat(root.getName()).isEqualTo("系统管理");
assertThat(result.get(2).getType()).isEqualTo(MenuType.BUTTON);
assertThat(result.get(2).getPerms()).isEqualTo("crm:user:list"); // catalog 下挂 menu
assertThat(root.getChildren()).hasSize(1);
ResourceNode childMenu = root.getChildren().get(0);
assertThat(childMenu.getType()).isEqualTo(MenuType.MENU);
assertThat(childMenu.getRoute()).isEqualTo("/system/user");
// menu 下挂 button
assertThat(childMenu.getChildren()).hasSize(1);
ResourceNode childButton = childMenu.getChildren().get(0);
assertThat(childButton.getType()).isEqualTo(MenuType.BUTTON);
assertThat(childButton.getPerms()).isEqualTo("crm:user:list");
assertThat(childButton.getChildren()).isEmpty();
} }
@Test @Test

47
docs/frontend-integration-menu-permission.md

@ -48,7 +48,7 @@ interface ResourceNode {
// —— catalog/menu 专用 —— // —— catalog/menu 专用 ——
icon?: string; // 图标预览 URL icon?: string; // 图标预览 URL
} children?: ResourceNode[]; // 子节点列表(listAll 返回时填充,其他接口无此字段)
``` ```
> ⚠️ **ID 一律按字符串收发**。后端返回的 id 是字符串(雪花 ID 超出 JS Number 安全范围),前端回传原样传字符串即可,不要转 Number。 > ⚠️ **ID 一律按字符串收发**。后端返回的 id 是字符串(雪花 ID 超出 JS Number 安全范围),前端回传原样传字符串即可,不要转 Number。
@ -89,7 +89,7 @@ GET /api/resources/list
Authorization: Bearer {token} Authorization: Bearer {token}
``` ```
**响应示例**: **响应示例**(后端已组树,直接渲染即可)
```json ```json
{ {
@ -103,9 +103,8 @@ Authorization: Bearer {token}
"type": "CATALOG", "type": "CATALOG",
"name": "系统管理", "name": "系统管理",
"sort": 1, "sort": 1,
"description": null, "icon": "https://cdn.example.com/icons/system.png",
"icon": "https://cdn.example.com/icons/system.png" "children": [
},
{ {
"id": "10", "id": "10",
"parentId": "1", "parentId": "1",
@ -113,8 +112,7 @@ Authorization: Bearer {token}
"name": "用户管理", "name": "用户管理",
"sort": 1, "sort": 1,
"route": "/system/user", "route": "/system/user",
"icon": null "children": [
},
{ {
"id": "100", "id": "100",
"parentId": "10", "parentId": "10",
@ -125,37 +123,24 @@ Authorization: Bearer {token}
"denyBehavior": "hide", "denyBehavior": "hide",
"apiUrl": "/api/system/users/page", "apiUrl": "/api/system/users/page",
"status": "enabled", "status": "enabled",
"description": "查询用户列表" "description": "查询用户列表",
"children": []
}
]
}
]
} }
] ]
} }
``` ```
**前端处理**:后端返回扁平数组,前端通过 `parentId` 自行组树。 **前端处理**:后端已通过 `TreeUtils.buildTree` 组好树,`data` 直接就是树形结构,无需前端再按 `parentId` 组树。直接渲染即可:
```typescript ```typescript
// 组树示例 // 直接渲染后端返回的树形数据
function buildTree(nodes: ResourceNode[]): TreeNode[] { const tree = response.data; // 已是树形结构
const map = new Map<string, TreeNode>();
const roots: TreeNode[] = [];
nodes.forEach(node => { <Tree treeData={tree} />
map.set(node.id!, { ...node, children: [] });
});
nodes.forEach(node => {
const treeNode = map.get(node.id!)!;
const parentId = node.parentId || "0";
if (parentId === "0") {
roots.push(treeNode);
} else {
const parent = map.get(parentId);
parent?.children.push(treeNode);
}
});
return roots.sort((a, b) => a.sort - b.sort);
}
``` ```
--- ---
@ -538,7 +523,7 @@ curl -X POST "{baseUrl}/api/resources/delete" \
### 6.3 前端开发建议 ### 6.3 前端开发建议
1. **树形组件**:推荐使用 `parentId` 在前端组树,后端返回扁平数组性能更好 1. **树形数据**:后端已组好树并按 sort 排序,前端直接渲染 `data` 即可,无需自行组树
2. **表单提交**:使用 `URLSearchParams``qs` 库构造表单字段,不要用 `JSON.stringify` 整体打包 2. **表单提交**:使用 `URLSearchParams``qs` 库构造表单字段,不要用 `JSON.stringify` 整体打包
3. **ID 处理**:所有 id 字段按字符串处理,不要 `parseInt` / `Number()` 3. **ID 处理**:所有 id 字段按字符串处理,不要 `parseInt` / `Number()`
4. **表单校验**:根据节点类型动态显示必填字段 4. **表单校验**:根据节点类型动态显示必填字段

Loading…
Cancel
Save