当前位置: 首页 > news >正文

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

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

《Chet.Admin 全栈实战》系列第 11 篇


前言

上一篇讲了菜单树,这一篇继续讲另一个树形结构:部门

很多人会把「部门」和「菜单」用同一套方案糊过去,但两者的语义其实差别很大:

维度菜单部门
用途决定能访问哪些页面决定能看哪些数据
类型目录/菜单/按钮三态单一实体
编码无强制编码业务编码(HR、TECH)
路由直接驱动路由生成不进路由表
权限控制按钮显隐控制数据范围

Chet.Admin把部门作为数据权限的载体:用户的部门归属 + 数据权限策略 → 决定能查到哪些数据。


一、部门实体设计

部门实体位于Chet.Admin.Domain/Department/DepartmentEntity.cs

publicclassDepartmentEntity: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;}// 父部门ID(0=顶级)publicintSort{get;set;}publicboolIsEnabled{get;set;}=true;publicList<DepartmentEntity>Children{get;set;}=[];}

字段设计思路

  • Code业务编码(如TECHHRFIN),用于跨系统对接
  • 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里有重要的前置校验:

publicasyncTask<DepartmentDto>CreateDepartmentAsync(DepartmentCreateDtodto){_logger.LogInformation("Creating department: {Code}",dto.Code);varexisting=await_departmentRepository.GetByCodeAsync(dto.Code);if(existing!=null)thrownewBadRequestException($"Department code '{dto.Code}' already exists");vardept=_mapper.Map<DepartmentEntity>(dto);await_departmentRepository.AddAsync(dept);await_departmentRepository.SaveChangesAsync();return_mapper.Map<DepartmentDto>(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;varkeyword=request.Keyword.Trim();varquery=dbContext.Departments.AsNoTracking().Where(d=>d.Code.Contains(keyword)||d.Name.Contains(keyword)||(d.Leader!=null&&d.Leader.Contains(keyword)));vartotalCount=awaitquery.CountAsync();varitems=awaitquery.Skip(request.Skip).Take(request.PageSize).ToListAsync();// ...}

注意Leader字段的判空

d.Leader!=null&&d.Leader.Contains(keyword)

因为Leader可空,直接Contains会 NRE。EF Core 翻译成 SQL 时其实是安全的,但显式判空让代码意图更清晰,也能在 LINQ to Objects 调试时不翻车。

2.4 递归建树

跟菜单一模一样的模式:

privatestaticIEnumerable<DepartmentTreeDto>BuildDepartmentTree(List<DepartmentTreeDto>allDepts,intparentId){returnallDepts.Where(d=>d.ParentId==parentId).OrderBy(d=>d.Sort).Select(d=>{d.Children=BuildDepartmentTree(allDepts,d.Id).ToList();returnd;}).ToList();}

为什么不抽公共方法

  • 树节点类型不同(MenuTreeDtovsDepartmentTreeDto
  • 抽象成本 > 重复成本
  • 这两个方法都很短,独立维护反而更清晰

不过度抽象= 工程上的克制。


三、前端:部门管理页面

3.1 树形表格 + 自动建树

views/system/department/index.vue跟菜单页结构类似:

const[Grid,gridApi]=useVbenVxeGrid({gridOptions:{columns,proxyConfig:{ajax:{query:async()=>{constlist=awaitgetAllDeptsApi();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[]{constfiltered=flatMenus.filter((m:any)=>m.id!==excludeId);constbuild=(parentId:number):any[]=>{...};returnbuild(0);}

两者都是正确的,但部门写法更简洁,因为后端已经建好树了。

excludeId是关键:编辑某部门时,自己不能当自己的父级,也不应该把后代当父级(会形成环)。最简单稳妥的做法是把自己从候选列表里过滤掉

3.3 editingId 模式:与菜单一致

consteditingId=ref(0);const[Modal,modalApi]=useVbenModal({onConfirm:async()=>{constvalues=awaitformApi.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();constdata=modalApi.getData<Record<string,any>>();editingId.value=data?.id||0;// 关键:每次打开都重新拉取部门树,排除当前编辑节点constdeptTree:any[]=awaitgetDeptTreeApi()||[];constexcludeId=data?.id;formApi.updateSchema([{fieldName:'parentId',componentProps:{treeData:buildTreeSelect(deptTree,excludeId)},}]);if(data)formApi.setValues(data);}},});

和菜单的差异

  • 菜单的父级选择器在try-catch里加载(容错)
  • 部门直接 await,因为父级选择器是必填的,加载失败要直接抛错

两者设计取向不同:菜单的父级可以不选(顶级),部门也允许留空,但部门数据量小,加载失败的概率可以忽略。

3.4 行操作:新增子部门

functiononCreate(parentId=0){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 表单 Schema

constformSchema: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:部门不需要图标
  • TreeSelectSelect更适合:父级本身是树形数据

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){constresult=awaitrequestClient.get('/departments/paged',{params});return{items:result?.items||[],total:result?.metadata?.totalCount||0};}exportasyncfunctioncreateDeptApi(data:any){...}exportasyncfunctionupdateDeptApi(id:number,data:any){...}exportasyncfunctiondeleteDeptApi(id:number){...}

为什么同时要getAllDeptsApigetDeptTreeApi

  • 表格用扁平数据:靠 VxeTable 的transform建树,渲染层和后端解耦
  • TreeSelect 用树形数据:Ant Design Vue 的TreeSelect直接接受treeData省一层转换

两种数据格式各自服务不同组件,让接口贴合使用方,比强行统一格式更优雅。


五、与用户/数据权限的联动

部门表的真正价值不在 CRUD,而在数据权限

5.1 用户表关联部门

每个用户都有DepartmentId字段,决定他属于哪个部门。一个用户只属于一个部门(简化模型)。

5.2 数据权限策略

Chet.Admin 的角色表里有数据权限策略:

  • All看全部数据
  • Department只看本部门
  • DepartmentAndSub看本部门及子部门
  • Custom自定义部门集合

执行查询时,根据当前用户部门 + 角色策略,动态拼接Where条件:

// 伪代码示意if(policy==DataPermissionPolicy.Department){query=query.Where(u=>u.DepartmentId==currentUserId);}elseif(policy==DataPermissionPolicy.DepartmentAndSub){vardeptIds=GetDeptAndSubIds(user.DepartmentId);query=query.Where(u=>deptIds.Contains(u.DepartmentId.Value));}

这里有个隐含约束

  • 部门表必须有完整的层级关系
  • 删除部门时要检查是否还有用户挂在该部门下
  • 否则会出现"孤儿用户",数据权限失效

Chet.Admin 目前简化了这层校验,但生产环境里建议补上:

publicasyncTaskDeleteDepartmentAsync(intid){vardept=await_departmentRepository.GetByIdAsync(id)??thrownewNotFoundException(nameof(DepartmentEntity),id);// 建议补充:检查是否有子部门和关联用户varhasChildren=await_dbContext.Departments.AnyAsync(d=>d.ParentId==id);if(hasChildren)thrownewBadRequestException("该部门下有子部门,无法删除");varhasUsers=await_dbContext.Users.AnyAsync(u=>u.DepartmentId==id);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 联动表单 📖」敬请期待 👀


🔗 GitHub:https://github.com/qiect/Chet.Admin

🔗 Gitee:https://gitee.com/qiect/Chet.Admin

⭐ 觉得不错的话,点个 Star 支持一下吧!

#ChetAdmin#全栈开发#.NET10#Vue3#组织架构#RBAC

http://www.cnnetsun.cn/news/3555149.html

相关文章:

  • LINUX——第一部分 Linux是什么与如何学习【第1章】
  • LangChain框架:构建大语言模型应用的开源利器
  • Django学生图书管理系统:从零到部署的完整项目实战
  • 基于STM32单片机直流电机控制加减速正反转控制系统设计DIY-T017
  • Java面试与实战能力割裂:如何识别影帝型候选人与培养实干工程师
  • 基于STM32单片机生理监控人体肺活量测试仪系统设计DIY-T101
  • 基于STM32单片机温控风扇温度采集PWM调速无线视频监控/WiFi/蓝牙APP设计DIY-T083
  • XUnity Auto Translator:5分钟掌握Unity游戏自动翻译的终极指南
  • SAP GUI 登录界面信息设置详解
  • 华为云 Skills 库 —— 让 AI 自然语言操控云基础设施
  • 【苍穹外卖 Day12| Excel 入门】
  • C++多态实战:用组装电脑案例理解虚函数与接口设计
  • Agent-Reach 完全指南:用 CLI 一键给 AI Agent 接入 15 个互联网平台
  • Unity新输入系统实战:从事件驱动到跨平台输入架构设计
  • 多模态AI模型:技术突破与产业应用全景
  • 文件上传下载“卡脖子”还爆内存?Spring Boot 云存储传输优化全链路指南
  • TI处理器PLL时钟树配置实战:uPP与McASP外设时钟精准设计
  • 解决Python 3.12在Conda环境中libstdc++版本不兼容的完整指南
  • 二叉树数据结构详解:从基础概念到工程应用
  • Spring Boot 3.3与JDK 17升级实战:5大陷阱与解决方案
  • Java面试高效突击:核心八股+场景实战+AI辅助的秋招攻略
  • 工控上位机开发:S7-1200 PLC与博途软件通信实战
  • Visual C++与Lua混合编程实战:构建可扩展计算器应用
  • 面向零样本威胁分类的语义元对齐
  • HarmonyOS7 拖拽方向限制:PanDirection 让组件只能水平或垂直拖动
  • Python ctypes实战:轻松调用C动态库提升性能50倍
  • Java代码审计:常见安全漏洞与防护实践
  • WSL保活机制与实战解决方案
  • 服务业消费复苏趋势与创新策略分析
  • SolidWorks建模到3D打印:解决外壳装配误差的DFM实战指南