diff --git a/crm-auth/src/main/java/com/crm/auth/domain/dto/ResourceNode.java b/crm-auth/src/main/java/com/crm/auth/domain/dto/ResourceNode.java index d57f847..d3c0597 100644 --- a/crm-auth/src/main/java/com/crm/auth/domain/dto/ResourceNode.java +++ b/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.Serializable; +import java.util.List; /** * 权限资源树节点 DTO(ADR-0011) @@ -55,6 +56,9 @@ public class ResourceNode implements Serializable { @Schema(description = "图标 URL,catalog/menu 可选") private String icon; + @Schema(description = "子节点列表(仅 listAll 返回时填充)") + private List children; + /** SysMenu → ResourceNode(id 映射 + menuName→name + path→route) */ public static ResourceNode fromEntity(SysMenu entity) { ResourceNode dto = BeanCopyUtils.copy(entity, ResourceNode::new); diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java index f39410c..ca58099 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java +++ b/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.ISysMenuService; import com.crm.base.domain.exception.BusinessErrorException; +import com.crm.base.utils.TreeUtils; import com.crm.file.api.FileApi; import com.crm.file.domain.dto.FileInfoDTO; import lombok.RequiredArgsConstructor; @@ -19,6 +20,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.Comparator; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -40,9 +42,11 @@ public class ResourceServiceImpl implements IResourceService { @Override public List listAll() { - return sysMenuService.list().stream() + List nodes = sysMenuService.list().stream() .map(ResourceNode::fromEntity) + .sorted(Comparator.comparingInt(n -> n.getSort() != null ? n.getSort() : 0)) .collect(Collectors.toList()); + return TreeUtils.buildTree(nodes, ResourceNode::getId, ResourceNode::getParentId, ResourceNode::setChildren); } @Override diff --git a/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java b/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java index 15e4990..1fdfb97 100644 --- a/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java +++ b/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java @@ -68,19 +68,30 @@ class ResourceServiceImplTest { // ==================== 查询 ==================== @Test - @DisplayName("全量资源树查询 -> 返回扁平 ResourceNode 数组,含 catalog/menu/button") - void listAll_returnsFlatListWithAllTypes() { + @DisplayName("全量资源树查询 -> 返回树形结构,catalog→menu→button 三级嵌套") + void listAll_returnsTreeWithAllTypes() { when(sysMenuService.list()).thenReturn(List.of(catalog, menu, button)); List result = resourceService.listAll(); - assertThat(result).hasSize(3); - assertThat(result.get(0).getType()).isEqualTo(MenuType.CATALOG); - assertThat(result.get(0).getName()).isEqualTo("系统管理"); - assertThat(result.get(1).getType()).isEqualTo(MenuType.MENU); - assertThat(result.get(1).getRoute()).isEqualTo("/system/user"); - assertThat(result.get(2).getType()).isEqualTo(MenuType.BUTTON); - assertThat(result.get(2).getPerms()).isEqualTo("crm:user:list"); + // 根节点只有 catalog + assertThat(result).hasSize(1); + ResourceNode root = result.get(0); + assertThat(root.getType()).isEqualTo(MenuType.CATALOG); + assertThat(root.getName()).isEqualTo("系统管理"); + + // 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 diff --git a/docs/frontend-integration-menu-permission.md b/docs/frontend-integration-menu-permission.md index 8276081..fe8e232 100644 --- a/docs/frontend-integration-menu-permission.md +++ b/docs/frontend-integration-menu-permission.md @@ -48,7 +48,7 @@ interface ResourceNode { // —— catalog/menu 专用 —— icon?: string; // 图标预览 URL -} + children?: ResourceNode[]; // 子节点列表(listAll 返回时填充,其他接口无此字段) ``` > ⚠️ **ID 一律按字符串收发**。后端返回的 id 是字符串(雪花 ID 超出 JS Number 安全范围),前端回传原样传字符串即可,不要转 Number。 @@ -89,7 +89,7 @@ GET /api/resources/list Authorization: Bearer {token} ``` -**响应示例**: +**响应示例**(后端已组树,直接渲染即可): ```json { @@ -103,59 +103,44 @@ Authorization: Bearer {token} "type": "CATALOG", "name": "系统管理", "sort": 1, - "description": null, - "icon": "https://cdn.example.com/icons/system.png" - }, - { - "id": "10", - "parentId": "1", - "type": "MENU", - "name": "用户管理", - "sort": 1, - "route": "/system/user", - "icon": null - }, - { - "id": "100", - "parentId": "10", - "type": "BUTTON", - "name": "查询用户", - "sort": 1, - "perms": "crm:user:list", - "denyBehavior": "hide", - "apiUrl": "/api/system/users/page", - "status": "enabled", - "description": "查询用户列表" + "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": [] + } + ] + } + ] } ] } ``` -**前端处理**:后端返回扁平数组,前端通过 `parentId` 自行组树。 +**前端处理**:后端已通过 `TreeUtils.buildTree` 组好树,`data` 直接就是树形结构,无需前端再按 `parentId` 组树。直接渲染即可: ```typescript -// 组树示例 -function buildTree(nodes: ResourceNode[]): TreeNode[] { - const map = new Map(); - const roots: TreeNode[] = []; - - nodes.forEach(node => { - 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); -} +// 直接渲染后端返回的树形数据 +const tree = response.data; // 已是树形结构 + + ``` --- @@ -538,7 +523,7 @@ curl -X POST "{baseUrl}/api/resources/delete" \ ### 6.3 前端开发建议 -1. **树形组件**:推荐使用 `parentId` 在前端组树,后端返回扁平数组性能更好 +1. **树形数据**:后端已组好树并按 sort 排序,前端直接渲染 `data` 即可,无需自行组树 2. **表单提交**:使用 `URLSearchParams` 或 `qs` 库构造表单字段,不要用 `JSON.stringify` 整体打包 3. **ID 处理**:所有 id 字段按字符串处理,不要 `parseInt` / `Number()` 4. **表单校验**:根据节点类型动态显示必填字段