Chet.Admin 模块详解⑤:部门树形管理与组织架构

发布时间:2026/7/21 8:18:03
Chet.Admin 模块详解⑤:部门树形管理与组织架构 Chet.Admin 模块详解⑤部门树形管理与组织架构 《Chet.Admin 全栈实战》系列第 11 篇前言上一篇讲了菜单树这一篇继续讲另一个树形结构部门。很多人会把「部门」和「菜单」用同一套方案糊过去但两者的语义其实差别很大维度菜单部门用途决定能访问哪些页面决定能看哪些数据类型目录/菜单/按钮三态单一实体编码无强制编码业务编码HR、TECH路由直接驱动路由生成不进路由表权限控制按钮显隐控制数据范围Chet.Admin把部门作为数据权限的载体用户的部门归属 数据权限策略 → 决定能查到哪些数据。一、部门实体设计部门实体位于Chet.Admin.Domain/Department/DepartmentEntity.cspublicclassDepartmentEntity:BaseEntity{publicstringName{get;set;}string.Empty;// 部门名称publicstringCode{get;set;}string.Empty;// 部门编码publicstring?Leader{get;set;}// 负责人publicstring?Phone{get;set;}// 联系电话publicstring?Email{get;set;}// 邮箱publicintParentId{get;set;}// 父部门ID0顶级publicintSort{get;set;}publicboolIsEnabled{get;set;}true;publicListDepartmentEntityChildren{get;set;}[];}字段设计思路Code业务编码如TECH、HR、FIN用于跨系统对接Leader/Phone/Email部门联系信息方便业务流程审批ParentId父子关系0 表示顶级部门Children导航属性EF Core 自动装配与菜单的区别没有类型字段部门不需要分类没有Path/Component/Icon部门不进路由多了Code/Leader/Phone/Email业务属性二、后端树形 CRUD 编码唯一校验2.1 控制器的 7 个接口DepartmentsController.cs的接口列表[HttpGet]// 所有部门扁平[HttpGet(tree)]// 部门树全量[HttpGet(paged)]// 分页查询[HttpGet({id})]// 详情[HttpPost]// 创建[HttpPut({id})]// 更新[HttpDelete({id})]// 删除注意这里没有菜单那种my-tree接口。部门权限的过滤是在业务查询层做的比如查用户列表时根据数据权限策略自动 join 部门表。2.2 编码唯一校验CreateDepartmentAsync里有重要的前置校验publicasyncTaskDepartmentDtoCreateDepartmentAsync(DepartmentCreateDtodto){_logger.LogInformation(Creating department: {Code},dto.Code);varexistingawait_departmentRepository.GetByCodeAsync(dto.Code);if(existing!null)thrownewBadRequestException($Department code {dto.Code} already exists);vardept_mapper.MapDepartmentEntity(dto);await_departmentRepository.AddAsync(dept);await_departmentRepository.SaveChangesAsync();return_mapper.MapDepartmentDto(dept);}为什么不直接靠数据库唯一索引数据库唯一索引报错时是个DbUpdateException信息不友好前端拿到的是违反 UNIQUE 约束这种数据库方言。Chet.Admin 选择在业务层主动校验抛BadRequestException统一返回给前端{code:400,message:Department code TECH already exists}数据库的唯一索引作为兜底防止并发请求绕过业务校验。2.3 关键字搜索多字段 OR 匹配GetPagedDepartmentsAsync支持按编码/名称/负责人模糊搜索if(!string.IsNullOrWhiteSpace(request.Keyword)){vardbContext(AppDbContext)_unitOfWork.DbContext;varkeywordrequest.Keyword.Trim();varquerydbContext.Departments.AsNoTracking().Where(dd.Code.Contains(keyword)||d.Name.Contains(keyword)||(d.Leader!nulld.Leader.Contains(keyword)));vartotalCountawaitquery.CountAsync();varitemsawaitquery.Skip(request.Skip).Take(request.PageSize).ToListAsync();// ...}注意Leader字段的判空d.Leader!nulld.Leader.Contains(keyword)因为Leader可空直接Contains会 NRE。EF Core 翻译成 SQL 时其实是安全的但显式判空让代码意图更清晰也能在 LINQ to Objects 调试时不翻车。2.4 递归建树跟菜单一模一样的模式privatestaticIEnumerableDepartmentTreeDtoBuildDepartmentTree(ListDepartmentTreeDtoallDepts,intparentId){returnallDepts.Where(dd.ParentIdparentId).OrderBy(dd.Sort).Select(d{d.ChildrenBuildDepartmentTree(allDepts,d.Id).ToList();returnd;}).ToList();}为什么不抽公共方法树节点类型不同MenuTreeDtovsDepartmentTreeDto抽象成本 重复成本这两个方法都很短独立维护反而更清晰不过度抽象 工程上的克制。三、前端部门管理页面3.1 树形表格 自动建树views/system/department/index.vue跟菜单页结构类似const[Grid,gridApi]useVbenVxeGrid({gridOptions:{columns,proxyConfig:{ajax:{query:async(){constlistawaitgetAllDeptsApi();return{items:list||[],total:list?.length||0};},},},rowConfig:{keyField:id},treeConfig:{parentField:parentId,rowField:id,transform:true,// 扁平 → 树自动处理expandAll:true,indent:20,},pagerConfig:{enabled:false},// 部门数据量小不分页},});亮点后端返回扁平数组前端靠transform: true自动建树expandAll: true默认全部展开方便查看整体组织架构关闭分页器避免父子节点被分页拆散3.2 父级选择器基于树形数据递归建树跟菜单不一样的地方部门父级选择器基于已经建好的树再递归而不是基于扁平数据。functionbuildTreeSelect(items:any[],excludeId?:number):any[]{returnitems.filter((item:any)item.id!excludeId).map((item:any)({label:item.name,value:item.id,children:item.children?buildTreeSelect(item.children,excludeId):undefined,}));}为什么部门可以这么做因为后端/departments/tree接口已经返回了带children的树结构。直接复用这棵树即可不需要把扁平数据再建一次。对比菜单的写法菜单是基于扁平数据建树// 菜单基于扁平数组functionbuildMenuTreeSelect(flatMenus:any[],excludeId?:number):any[]{constfilteredflatMenus.filter((m:any)m.id!excludeId);constbuild(parentId:number):any[]{...};returnbuild(0);}两者都是正确的但部门写法更简洁因为后端已经建好树了。excludeId是关键编辑某部门时自己不能当自己的父级也不应该把后代当父级会形成环。最简单稳妥的做法是把自己从候选列表里过滤掉。3.3 editingId 模式与菜单一致consteditingIdref(0);const[Modal,modalApi]useVbenModal({onConfirm:async(){constvaluesawaitformApi.getValues();if(editingId.value){awaitupdateDeptApi(editingId.value,values);message.success(更新成功);}else{awaitcreateDeptApi(values);message.success(创建成功);}modalApi.close();gridApi.query();},asynconOpenChange(isOpen){if(isOpen){formApi.resetForm();constdatamodalApi.getDataRecordstring,any();editingId.valuedata?.id||0;// 关键每次打开都重新拉取部门树排除当前编辑节点constdeptTree:any[]awaitgetDeptTreeApi()||[];constexcludeIddata?.id;formApi.updateSchema([{fieldName:parentId,componentProps:{treeData:buildTreeSelect(deptTree,excludeId)},}]);if(data)formApi.setValues(data);}},});和菜单的差异菜单的父级选择器在try-catch里加载容错部门直接 await因为父级选择器是必填的加载失败要直接抛错两者设计取向不同菜单的父级可以不选顶级部门也允许留空但部门数据量小加载失败的概率可以忽略。3.4 行操作新增子部门functiononCreate(parentId0){modalApi.setData({parentId}).open();}// 表格行模板VbenTableAction:actions[{text:新增,auth:system:dept:create,onClick:()onCreate(row.id)},{text:编辑,auth:system:dept:update,onClick:()onEdit(row)},]:dropdown-actions[{text:删除,auth:system:dept:delete,danger:true,popConfirm:{title:确认删除,confirm:()onDelete(row)}}]/每个部门都能直接「新增」子部门自动把当前行 ID 作为parentId传给 Modal。这是树形管理模块的标配体验。3.5 表单 SchemaconstformSchema:VbenFormSchema[][{component:Input,fieldName:name,label:部门名称,rules:required},{component:Input,fieldName:code,label:部门编码,rules:required,help:如 TECH, HR, FIN},{component:TreeSelect,fieldName:parentId,label:上级部门,componentProps:{treeData:[],placeholder:留空为顶级部门,allowClear:true,showSearch:true,treeNodeFilterProp:label,treeLine:true,treeDefaultExpandAll:true,dropdownStyle:{maxHeight:400px},style:{width:100%},},},{component:Input,fieldName:leader,label:负责人},{component:Input,fieldName:phone,label:联系电话},{component:Input,fieldName:email,label:邮箱},{component:InputNumber,fieldName:sort,label:排序,defaultValue:0,componentProps:{style:{width:100%}}},{component:Switch,fieldName:isEnabled,label:启用,defaultValue:true},];注意treeLine: true这个属性在 TreeSelect 里显示树形连接线视觉上更清晰地看出父子关系配合treeDefaultExpandAll打开下拉就能看到完整组织架构跟菜单表单的对比没有dependencies.if所有字段都常驻显示没有动态显隐没有IconPicker部门不需要图标TreeSelect比Select更适合父级本身是树形数据3.6 微交互树节点 hover 样式部门页面在style里加了一些细节:deep(.vxe-tree--btn-wrapper) { .vxe-tree-icon { color: hsl(var(--muted-foreground)); transition: all 0.2s ease; :hover { color: hsl(var(--primary)); } } } :deep(.vxe-tree-cell) { .vxe-tree-wrapper { align-items: center; } } /* 树形节点 hover 效果 */ :deep(.vxe-body--row) { transition: background-color 0.2s ease; }细节展开/折叠图标 hover 时变主题色行背景过渡 0.2s鼠标划过有呼吸感这些细节单看没什么累积起来就是产品的精致度。四、API 层扁平 树形双接口api/system/department.ts提供两种接口// 扁平接口用于表格渲染前端 transform 自动建树exportasyncfunctiongetAllDeptsApi(){returnrequestClient.get(/departments);}// 树形接口用于父级选择器直接复用后端建好的树exportasyncfunctiongetDeptTreeApi(){returnrequestClient.get(/departments/tree);}// 分页接口用于按关键字搜索场景exportasyncfunctiongetDeptListApi(params:any){constresultawaitrequestClient.get(/departments/paged,{params});return{items:result?.items||[],total:result?.metadata?.totalCount||0};}exportasyncfunctioncreateDeptApi(data:any){...}exportasyncfunctionupdateDeptApi(id:number,data:any){...}exportasyncfunctiondeleteDeptApi(id:number){...}为什么同时要getAllDeptsApi和getDeptTreeApi表格用扁平数据靠 VxeTable 的transform建树渲染层和后端解耦TreeSelect 用树形数据Ant Design Vue 的TreeSelect直接接受treeData省一层转换两种数据格式各自服务不同组件让接口贴合使用方比强行统一格式更优雅。五、与用户/数据权限的联动部门表的真正价值不在 CRUD而在数据权限。5.1 用户表关联部门每个用户都有DepartmentId字段决定他属于哪个部门。一个用户只属于一个部门简化模型。5.2 数据权限策略Chet.Admin 的角色表里有数据权限策略All看全部数据Department只看本部门DepartmentAndSub看本部门及子部门Custom自定义部门集合执行查询时根据当前用户部门 角色策略动态拼接Where条件// 伪代码示意if(policyDataPermissionPolicy.Department){queryquery.Where(uu.DepartmentIdcurrentUserId);}elseif(policyDataPermissionPolicy.DepartmentAndSub){vardeptIdsGetDeptAndSubIds(user.DepartmentId);queryquery.Where(udeptIds.Contains(u.DepartmentId.Value));}这里有个隐含约束部门表必须有完整的层级关系删除部门时要检查是否还有用户挂在该部门下否则会出现孤儿用户数据权限失效Chet.Admin 目前简化了这层校验但生产环境里建议补上publicasyncTaskDeleteDepartmentAsync(intid){vardeptawait_departmentRepository.GetByIdAsync(id)??thrownewNotFoundException(nameof(DepartmentEntity),id);// 建议补充检查是否有子部门和关联用户varhasChildrenawait_dbContext.Departments.AnyAsync(dd.ParentIdid);if(hasChildren)thrownewBadRequestException(该部门下有子部门无法删除);varhasUsersawait_dbContext.Users.AnyAsync(uu.DepartmentIdid);if(hasUsers)thrownewBadRequestException(该部门下有用户无法删除);_departmentRepository.Delete(dept);await_departmentRepository.SaveChangesAsync();} 这是个留给使用者按需扩展的点不破坏现有 CRUD 体验。六、设计回顾✅字段精简只保留部门必备字段没有冗余✅Code 唯一校验业务层主动校验返回友好错误✅多字段搜索Code/Name/Leader 任一匹配✅扁平 树形双接口贴合不同使用方✅TreeSelect 复用后端树减少前端转换✅excludeId 防环编辑时排除当前节点✅editingId 模式和菜单等模块保持一致✅数据权限载体作为用户数据范围的判定依据✅微交互树节点 hover 状态、过渡动画七、和菜单模块的对比放一张完整对比表方便理解两个模块的和而不同维度菜单部门实体字段多Type/Path/Component/Icon/Permission…少Name/Code/Leader/Phone/Email类型分类三态目录/菜单/按钮单态路由生成✅ 是❌ 否唯一校验Path 隐式唯一Code 显式校验树形数据接口/menus/tree/departments/tree用户关联多对多角色-菜单一对一用户-部门权限用途控制 UI 显隐控制数据范围IconPicker✅ 使用❌ 不用表单动态显隐✅ dependencies.if❌ 全部常驻核心差异一句话菜单管能不能点部门管能看哪些数据。下篇预告下篇讲字典管理 useDict字典类型 字典项 CRUD、组合式函数 useDict 的设计、业务表单如何自动联动字典数据。「Chet.Admin 模块详解⑥字典管理 useDict 联动表单 」敬请期待 GitHubhttps://github.com/qiect/Chet.Admin Giteehttps://gitee.com/qiect/Chet.Admin⭐ 觉得不错的话点个 Star 支持一下吧#ChetAdmin#全栈开发#.NET10#Vue3#组织架构#RBAC