简介:这份源码面向需要在 Windows 桌面端实现权限控制的中初级 C# 开发者,提供一套基于角色的权限菜单完整实现方案。它把权限与角色绑定,用户归属角色后自动继承对应菜单与操作权限,可解决企业级 WinForms 应用中菜单动态生成、访问控制与用户分组管理的实际问题。压缩包共 121 个文件、约 383KB,以 64 个 cs 源码文件为核心,配合 11 个 resx 资源、9 个 csproj 工程文件、3 个 sql 建表脚本及 png、ico 等界面素材,另含 sln 解决方案与 db 数据库文件,结构完整、可直接编译运行。已有 688 人学习下载。源码覆盖用户认证与会话管理、角色与权限服务、菜单动态生成逻辑及数据库交互等关键模块,并附带权限管理、权限组管理、客户端分组列表等窗体实现,读者可据此理解角色—权限—菜单的映射关系,快速搭建带权限控制的桌面应用基础模板,并在此基础上按需扩展角色、权限项与菜单结构。
1. 从一张“菜单越权”的工单说起
去年接手一个内部管理工具时,运维给我甩了张工单:某个只该看报表的账号,居然点进了用户管理页,还能改数据。排查下来不是接口没鉴权,而是菜单压根没按角色过滤——前端把整棵菜单树一股脑渲染出来,后端也没拦。这类问题在中小型桌面管理软件里特别常见,尤其是用 C# WinForm 快速搭起来的项目,界面能跑、功能能用,权限这块往往是最后补的,补得也最随意。
这份「C# WinForm 角色的权限菜单」源码,解决的正是这个痛点:它把「用户—角色—权限—菜单」这条链路做成了一个可复用的骨架,登录后按角色动态生成侧边菜单,没权限的节点直接不出现,配合按钮级权限控制,把「角色权限设计使用」这件事从口头约定落成了代码。适合两类人:一是正在用 WinForm 做管理后台、需要一套能直接抄的权限模型的开发者;二是接手了老项目、想给现有菜单加上角色过滤的维护者。下面我按「它怎么组织—怎么跑起来—怎么改」的顺序拆一遍。
2. 架构与目录:四张表撑起的权限模型
2.1 数据模型:用户、角色、权限、菜单怎么关联
这套源码的核心不是界面多花哨,而是数据表设计得干净。典型做法是四张主表加两张关联表,关系如下:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| Users | 登录账号 | UserId、UserName、PasswordHash、Status |
| Roles | 角色定义 | RoleId、RoleName、Remark |
| Permissions | 权限点(功能项) | PermissionId、Code、Name、Type |
| Menus | 菜单节点 | MenuId、ParentId、Name、FormName、Sort |
| UserRoles | 用户与角色多对多 | UserId、RoleId |
| RolePermissions | 角色与权限多对多 | RoleId、PermissionId |
这里有个容易混淆的点:菜单和权限是两套东西,但通过 MenuId 挂钩。菜单负责「界面上显示什么」,权限负责「代码里能不能执行」。常见做法是在 Menus 表里加一个 PermissionCode 字段,或者让 RolePermissions 直接引用 MenuId,这样角色勾选菜单时,等于同时授予了该菜单对应的功能权限。我一般倾向于后者——一张 RoleMenus 关联表,菜单即权限,简单直接,中小项目够用;如果要做按钮级细粒度控制,再单独维护 Permissions 表,用 Code 字符串在代码里判断。
源码里如果只有菜单级控制,那 RoleMenus 就够了;如果带按钮权限,就会多一层 Permissions。读代码时先看建表脚本或实体类,能立刻判断它做到哪一层。
2.2 目录结构:分层与职责边界
WinForm 项目常见的分层是 UI 层、业务层、数据访问层,权限模块通常横跨三层。一个典型的目录长这样:
RoleMenuDemo/ ├── Forms/ # 界面窗体 │ ├── LoginForm.cs # 登录 │ ├── MainForm.cs # 主框架,承载菜单 │ └── RoleManageForm.cs # 角色与菜单授权 ├── BLL/ # 业务逻辑 │ ├── UserService.cs │ ├── RoleService.cs │ └── MenuService.cs ├── DAL/ # 数据访问 │ ├── DbHelper.cs │ └── MenuRepository.cs ├── Models/ # 实体 │ ├── UserInfo.cs │ ├── RoleInfo.cs │ └── MenuInfo.cs └── Common/ └── Session.cs # 当前登录用户与权限缓存这个结构不复杂,但职责划得清:Forms 只管渲染和事件,BLL 管「这个用户能拿到哪些菜单」,DAL 管 SQL,Session 存登录态。改造时最怕的是把权限判断散落在各个窗体的 Load 事件里,那样加一个角色就得改十处。源码如果把这些判断收在 MenuService 和 Session 里,说明作者是有意做集中的,二次开发会省很多事。
2.3 菜单树的构建逻辑
菜单是树形结构,ParentId 自关联。加载时先取当前用户所有角色,再取角色关联的菜单,去重后按 ParentId 组装成树。这一步是整个权限菜单的「心脏」,写法好坏直接决定扩展性。常见做法是递归建树,或者用字典做一次遍历组装,后者性能更好,代码也更稳。下一章会把这部分代码逐段拆开讲。
3. 跑起来:从建库到登录看到自己的菜单
3.1 环境与依赖确认
这是 .NET Framework 时代的 WinForm 项目,用 Visual Studio 打开 .sln 即可。需要确认三件事:目标框架版本(项目属性里看,常见是 .NET Framework 4.5 或 4.7.2)、数据库类型(多数是 SQL Server,连接串在 App.config 的 connectionStrings 节点)、以及是否有第三方 UI 库依赖(如果引用了 DevExpress 之类的,没装会编译不过,得先补包或换回原生控件)。
打开 App.config,找到类似这样的配置:
<connectionStrings> <add name="Default" connectionString="Server=.;Database=RoleMenuDb;User Id=sa;Password=你的密码;" providerName="System.Data.SqlClient" /> </connectionStrings>把 Server 和账号密码改成你本机的,数据库名保持默认或自己建一个同名的。注意 providerName 要和实际用的驱动一致,SQL Server 就是 System.Data.SqlClient,换成别的库这里也得跟着改。
3.2 建库与初始化数据
源码里一般带一个 .sql 脚本,位置可能在根目录或 Database 文件夹。用 SSMS 或命令行执行:
sqlcmd -S . -U sa -P 你的密码 -i init.sql脚本会建表并插入初始数据,通常包含一个 admin 账号、一个「超级管理员」角色,以及若干菜单项。执行完先查一下:
SELECT u.UserName, r.RoleName FROM Users u JOIN UserRoles ur ON u.UserId = ur.UserId JOIN Roles r ON ur.RoleId = r.RoleId;能看到 admin 对应管理员角色,说明关联数据没问题。如果脚本里没插菜单,就得手动在 Menus 表补几条,否则登录后主界面是空的,容易误以为代码有 bug。
3.3 登录与菜单加载的完整链路
启动后先走 LoginForm,验证账号密码,成功后把用户信息塞进 Session,再打开 MainForm。MainForm 的 Load 事件里调用菜单服务,拿到当前用户可见的菜单树,绑定到左侧导航控件(可能是 TreeView、MenuStrip 或自绘的 Panel 列表)。关键代码大致如下:
// MainForm.cs private void MainForm_Load(object sender, EventArgs e) { // 从会话取当前登录用户 var user = Session.CurrentUser; if (user == null) { MessageBox.Show("登录已失效,请重新登录"); this.Close(); return; } // 按用户角色加载可见菜单 var menus = MenuService.GetMenusByUser(user.UserId); // 绑定到 TreeView,递归构建节点 BuildMenuTree(treeViewMenu.Nodes, menus, null); } private void BuildMenuTree(TreeNodeCollection nodes, List<MenuInfo> all, int? parentId) { // 取当前层级的菜单,按排序号排列 var children = all.Where(m => m.ParentId == parentId) .OrderBy(m => m.Sort) .ToList(); foreach (var menu in children) { var node = new TreeNode(menu.Name) { Tag = menu }; nodes.Add(node); // 递归挂子节点 BuildMenuTree(node.Nodes, all, menu.MenuId); } }逻辑说明:GetMenusByUser 内部先查 UserRoles 得到角色集合,再查 RoleMenus 得到菜单 Id 集合,最后查 Menus 表返回实体列表。BuildMenuTree 用 ParentId 做层级判断,根节点的 ParentId 为 null 或 0,递归往下挂。Tag 存整个 MenuInfo,点击节点时可以直接取 FormName 反射打开对应窗体。
参数上要注意两点:一是 Sort 字段决定同级顺序,没有它菜单顺序会随数据库返回顺序漂移;二是 ParentId 的类型要和数据库一致,int? 能兼容 null 根节点,用 int 的话根节点得约定成 0,别混用。
3.4 点击菜单打开窗体的方式
节点点击后通常这样处理:
private void treeViewMenu_AfterSelect(object sender, TreeViewEventArgs e) { if (e.Node.Tag is MenuInfo menu && !string.IsNullOrEmpty(menu.FormName)) { // 通过反射按类名创建窗体实例 var type = Type.GetType($"RoleMenuDemo.Forms.{menu.FormName}"); if (type == null) return; var form = (Form)Activator.CreateInstance(type); form.MdiParent = this; // 以 MDI 方式嵌入主框架 form.Show(); } }用反射的好处是新增菜单不用改主框架代码,数据库里配好 FormName 就能打开。代价是类名写错时运行期才报错,所以 FormName 字段最好和实际类名严格一致,改窗体名时记得同步改数据。如果项目用的是 TabControl 多标签页而不是 MDI,这里就换成往 TabPages 里加,思路一样。
4. 核心代码拆解:授权界面与权限判断
4.1 角色授权:勾选菜单并落库
RoleManageForm 是管理员给角色分配菜单的地方,左边角色列表,右边菜单树带复选框。保存时的核心是把勾选状态转成 RoleMenus 记录。常见写法是先删后插,事务包住:
public bool SaveRoleMenus(int roleId, List<int> menuIds) { using (var conn = DbHelper.GetConnection()) using (var tran = conn.BeginTransaction()) { try { // 先清空该角色原有授权,避免残留 var del = new SqlCommand( "DELETE FROM RoleMenus WHERE RoleId=@rid", conn, tran); del.Parameters.AddWithValue("@rid", roleId); del.ExecuteNonQuery(); // 再批量插入新勾选的菜单 foreach (var mid in menuIds) { var ins = new SqlCommand( "INSERT INTO RoleMenus(RoleId,MenuId) VALUES(@rid,@mid)", conn, tran); ins.Parameters.AddWithValue("@rid", roleId); ins.Parameters.AddWithValue("@mid", mid); ins.ExecuteNonQuery(); } tran.Commit(); return true; } catch { tran.Rollback(); return false; } } }逻辑说明:先删后插是最稳的增量更新方式,不用比对差异,逻辑简单不易错。事务保证删了之后插入失败能回滚,不会出现角色权限被清空的尴尬。参数 roleId 是当前选中的角色,menuIds 是界面上勾选的菜单 Id 集合,注意要把父节点被勾选时子节点也一并收集,否则会出现「父菜单可见但子菜单丢失」的断层。
这里有个细节:如果菜单树是懒加载的(只展开时才查子节点),收集勾选时容易漏掉未展开的节点。稳妥做法是保存前把整棵树完整加载一次,或者维护一个「已勾选 Id 集合」在勾选事件里实时更新,保存时直接用集合,不依赖界面遍历。
4.2 按钮级权限:用 Code 做细粒度判断
菜单级控制只能挡住「进不进得去」,进去之后的新增、删除按钮还得单独判断。源码如果带 Permissions 表,通常会给每个功能点一个 Code,比如user:add、user:delete,登录时把当前用户所有权限 Code 缓存到 Session:
// 登录成功后缓存权限码 Session.PermissionCodes = PermissionService.GetCodesByUser(user.UserId); // 窗体加载时按权限控制按钮可见性 btnAdd.Visible = Session.HasPermission("user:add"); btnDelete.Visible = Session.HasPermission("user:delete");HasPermission 就是判断集合里是否包含该 Code,实现很简单但很有效。参数说明:Code 命名建议用「模块:动作」的格式,统一小写,避免大小写不一致导致判断失效。这套机制的好处是新增功能点只需在 Permissions 表加一条、给角色勾上,代码里加一行 Visible 判断即可,不用改鉴权逻辑本身。
4.3 权限缓存与刷新时机
Session 里缓存权限是性能考虑,但会带来「改了角色权限不生效」的问题——管理员在后台改了某角色的菜单,已登录的用户还是旧菜单。常见做法有两种:一是权限变更后强制相关用户重新登录;二是在主界面加一个「刷新权限」入口,重新拉一次菜单和权限码并重建导航。中小项目用第一种最省事,第二种体验更好但要多写一点重建逻辑。源码里如果没处理这块,二次开发时值得补上,否则测试阶段会反复遇到「明明给了权限却不显示」的玄学问题。
5. 避坑与排查:权限菜单最容易翻车的几处
5.1 登录后菜单空白
现象:账号密码正确,主界面左侧一个菜单都没有。原因通常是三种:RoleMenus 表里该角色没有任何记录;菜单查询 SQL 的 ParentId 条件写死成 0 但数据里根节点是 null;或者菜单数据本身没初始化。解决:先直接查SELECT * FROM RoleMenus WHERE RoleId=某角色,为空就去授权界面勾选保存;再查 Menus 表根节点 ParentId 的实际值,把代码里的判断改成兼容 null 和 0。
5.2 父菜单显示、子菜单丢失
现象:一级菜单能出来,展开后是空的。原因多是建树时只取了 ParentId 等于当前节点 Id 的直接子节点,但数据里存在跨级或 ParentId 配错的情况;也可能是授权时只勾了父节点没勾子节点,而保存逻辑没有做父子联动。解决:授权界面勾选父节点时自动勾选全部子节点(或至少提示),建树前先打印一遍菜单列表核对 ParentId 链路是否完整。
5.3 改了角色权限但当前用户不生效
现象:管理员给角色加了菜单,对应账号重新打开主界面还是看不到。原因就是 4.3 说的缓存没刷新,Session 里还是登录时的旧数据。解决:退出重登验证;若要即时生效,在权限保存成功后清掉相关用户的缓存标记,或让主界面监听一个刷新信号重建菜单。
5.4 反射打开窗体报「未将对象引用设置到对象实例」
现象:点菜单弹异常,提示空引用。原因通常是 FormName 和实际类名不一致,Type.GetType 返回 null 后直接用了;或者命名空间拼错。解决:在反射前判空并给出友好提示,同时核对数据库 FormName 字段与窗体类名、命名空间是否完全一致。改窗体名时把数据一起改,别只改代码。
5.5 多角色用户菜单重复
现象:一个用户挂了两个角色,菜单出现重复项。原因是没有对菜单 Id 去重。解决:查询角色菜单时用SELECT DISTINCT MenuId,或在内存里用 HashSet 去重后再建树。这个坑很小但很常见,尤其是测试账号图省事挂了多个角色时必现。
6. 二次开发:把菜单权限扩成通用鉴权骨架
拿到这套源码,多数人不会只满足于「能跑」,而是想把它改成自己项目的权限底座。我的建议是从三个方向下手,按投入产出排序。
第一,把菜单和权限彻底解耦。现在很多实现是「菜单即权限」,简单但不够用——同一个窗体里不同按钮需要不同权限时就会卡住。改造方式是保留 Menus 管导航,新增 Permissions 管功能点,RolePermissions 关联角色与权限点,菜单节点通过 PermissionCode 关联到权限点。这样授权界面可以做成两级:先勾菜单,再勾该菜单下的按钮权限。表结构改动不大,但代码里所有Visible判断都能统一走Session.HasPermission(code),后期加功能不用动鉴权框架。
第二,把权限判断从窗体里抽出来。现在常见写法是在每个窗体的 Load 里写一堆if (Session.HasPermission(...)),窗体一多就散。可以做一个基类窗体或特性(Attribute)方案:
[Permission("user:view")] public partial class UserManageForm : BaseForm { // 基类在 Load 时自动校验,无权限直接关闭并提示 }基类里用反射读特性,统一校验,窗体代码只关心业务。这个改动一次性投入,后面每加一个窗体都省事,也避免了「某个窗体忘了加判断」的漏网之鱼。
第三,补上操作日志。权限系统上线后,最常被问的就是「谁在什么时候改了这个角色的权限」。在 SaveRoleMenus 成功提交后插一条日志记录(操作人、角色、变更前后菜单集合),成本很低,排查问题时却是后悔药。日志表字段不用多:LogId、OperatorId、Action、TargetId、Detail、CreateTime 就够。
验证改造是否成功,我一般走一遍固定清单:新建一个只有查看权限的角色,登录后确认菜单只显示查看项、新增删除按钮不可见;再给该角色加一个菜单,不重新登录直接刷新,确认菜单出现;最后用多角色账号登录,确认菜单无重复。这三步能覆盖大部分回归问题。
说个自己的教训:早些年给一个项目加权限,图快直接在窗体里硬编码角色名判断,结果后来角色一改名,十几处判断全失效,排查了一下午。从那以后我每次做权限相关改动,都强制走一遍「数据驱动 + 集中判断」的路子,宁可前期多写一张表,也不把角色名写死在代码里。这套源码的骨架已经帮你把集中判断的地基打好了,剩下的就是按自己项目的粒度往上长。希望帮到你。
本文还有配套的精品资源,点击获取