简介:这份C# WinForm角色权限菜单源码包,面向需要在Windows桌面应用中实现权限控制的开发者,提供了一套基于角色的动态菜单完整解决方案。核心设计思路是让权限与角色绑定,用户通过分配角色自动获得对应操作权限,从而大幅降低权限管理成本,特别适合企业级管理软件、OA系统等场景。包体共121个文件,以C#源码(64个cs)为主,辅以SQL数据库脚本(3个sql,用于建立用户、角色、权限及映射关系)、resx界面资源、ico图标与配置文件,压缩包仅383KB,结构清晰便于学习。源码覆盖用户认证与会话管理、角色权限服务、基于权限的动态菜单生成、以及数据库交互等关键模块;从内容预览可见,项目包含角色管理、权限配置、客户分组、主窗体等多个功能窗体,支持直接运行验证或二次扩展。已有686人学习下载,适合正在设计权限模块或希望快速搭建具有菜单级控制功能的WinForms开发者参考借鉴。
1. 角色权限设计使用:这份 WinForm 源码把“登录后菜单怎么变”整条链路都摊开了
在 WinForm 项目里做角色权限设计使用,最容易卡住的不是登录逻辑,而是登录之后那棵菜单树怎么样按角色动态生成。这个源码包把整套链路都摆出来了:五张表存用户、角色、菜单和两对关系,登录后一次查库拼出当前角色能看到的菜单,再通过反射把叶子节点映射到具体窗体;角色一换,界面跟着变,业务代码一行不用动。适合刚接触权限模块的 WinForm 开发者照着搭骨架,也适合给老系统做权限改造时当参照物。下文我会按“模型设计 → 数据库落地 → 界面加载 → 踩坑记录 → 进阶收敛”的顺序,把这套源码里最值得抄的部分拆开讲。
2. 先把权限模型立住:五表 RBAC 为什么比“写死 if”更抗折腾
2.1 先定角色模型:为什么“写死 if (user.Role == “admin”)”不是权限设计
很多老项目处理角色权限的方式,是在登录后写一个全局静态变量存当前用户,然后每个窗体加载时判断角色:
if (Global.CurrentUser.RoleName == "admin") { btnDelete.Visible = true; }这段代码在角色只有“管理员/普通用户”两个时没问题。一旦变成管理员、部门主管、操作员、只读访客四类,每个窗体里都堆满这种判断,改需求的时候到处翻代码,改完还容易漏。这不是权限设计,这是给权限逻辑埋雷。
源码包里采用的是基于 RBAC 的标准做法:把权限收敛到“角色”和“菜单”两张实体的关系上。用户不直接绑定菜单,用户先绑定角色,角色再绑定菜单。用户登录后看到的菜单,是“用户 → 角色 → 菜单”这条关系的投影。这么设计的核心收益是:调整某类人的可见范围,只需要改角色和菜单的关系表,不需要动代码、不需要重新发布。
做选型时还会碰到一个经典问题:三表够不够?这里列一下对比。
| 方案 | 表结构 | 适用场景 |
|---|---|---|
| 三表 | 用户、角色、菜单 | 用户直接绑角色,角色表里存菜单ID列表,适合菜单数量少、权限粒度粗的小工具 |
| 五表 | 用户、角色、菜单、用户角色关系、角色菜单关系 | 菜单数量多、权限需要频繁调整、需要支持多角色叠加 |
这份源码用的是五表。三表方案在菜单数量超过二十个以后,角色表里的菜单ID字段会变成一长串逗号拼接的字符串,查询和去重都很别扭。五表的好处是“用户和角色”“角色和菜单”各自独立变化,互不干扰。
2.2 菜单表字段设计:parent_id 撑起树形,menu_code 撑起按钮
有了表关系,再看菜单表本身。源码里 sys_menu 的关键字段是这几个:
menu_id INT PRIMARY KEY IDENTITY, parent_id INT NOT NULL DEFAULT 0, menu_name NVARCHAR(50) NOT NULL, menu_code NVARCHAR(100) NOT NULL, menu_type INT NOT NULL DEFAULT 2, sort_no INT NOT NULL DEFAULT 0每个字段都有它的用途。parent_id 是父子关系的锚点,根节点为 0;menu_name 是界面显示的文字;menu_code 是权限的唯一标识,像order:export、user:delete,这个值一旦定了就不要改,后面按钮权限判断全靠它;menu_type 区分目录(1)、菜单(2)、按钮(3);sort_no 控制同一层级下的显示顺序。
| 字段值 | 含义 | 界面表现 |
|---|---|---|
| menu_type = 1 | 目录 | 只出现在父节点,不可点击打开窗体 |
| menu_type = 2 | 菜单 | 叶子节点,点击后反射打开窗体 |
| menu_type = 3 | 按钮 | 不进入菜单树,只作为权限标识存在 |
这里最容易被忽略的是 menu_code。常见错误是拿 menu_name 或者控件 Text 去判断权限,比如if (menuItem.Text == "删除用户")。界面文字随时可能被产品改,一改名,权限判断就失效了。用 menu_code 做唯一标识,等于给每个权限点上了身份证,改名不受影响。菜单树的父子关系判断用 menu_id,权限判断用 menu_code,两者分工明确。
2.3 角色与菜单挂接:目录不需要手工分配,叶子节点分配权限
五表里承担“角色 ↔ 菜单”挂接的是 sys_role_menu 表,字段只有 role_id 和 menu_id。给“部门主管”角色分配菜单时,常见的做法是先查出这个角色,再查出目标菜单,然后插入关系记录。注意一个细节:目录(menu_type = 1)不需要直接分配,只要它的子节点被分配了,程序端加载时会自动把目录带出来。
INSERT INTO sys_role_menu (role_id, menu_id) SELECT r.role_id, m.menu_id FROM sys_role r, sys_menu m WHERE r.role_name = '部门主管' AND m.menu_code IN ('order:query', 'order:export', 'user:view');这段 SQL 先把 role_id 和 menu_id 都查出来再插入,避免手工去翻主键。order:query是查询订单的菜单权限,order:export是导出按钮权限,user:view是查看用户的菜单权限。这里能看到按钮权限和菜单权限共用一张菜单表和一张关系表,区别只在于menu_type。
3. 数据库与核心查询:建表脚本、种子数据、判断“有权限”的那条 SQL
3.1 建表脚本:五张表一次建齐,外键关系别偷懒
源码包里带了完整的建表脚本,我用 SQL Server 语法还原一下核心结构。实际项目如果用的 MySQL,语法差别不大,把 IDENTITY 换成 AUTO_INCREMENT 就行。
CREATE TABLE sys_user ( user_id INT PRIMARY KEY IDENTITY, user_name NVARCHAR(50) NOT NULL UNIQUE, password NVARCHAR(128) NOT NULL, real_name NVARCHAR(50) NULL ); CREATE TABLE sys_role ( role_id INT PRIMARY KEY IDENTITY, role_name NVARCHAR(50) NOT NULL UNIQUE ); CREATE TABLE sys_menu ( menu_id INT PRIMARY KEY IDENTITY, parent_id INT NOT NULL DEFAULT 0, menu_name NVARCHAR(50) NOT NULL, menu_code NVARCHAR(100) NOT NULL, menu_type INT NOT NULL DEFAULT 2, sort_no INT NOT NULL DEFAULT 0 ); CREATE TABLE sys_user_role ( user_id INT NOT NULL, role_id INT NOT NULL, PRIMARY KEY (user_id, role_id) ); CREATE TABLE sys_role_menu ( role_id INT NOT NULL, menu_id INT NOT NULL, PRIMARY KEY (role_id, menu_id) );建表时两个细节值得照抄。一是 sys_user_role 和 sys_role_menu 用复合主键,天然防止同一条关系重复插入;二是 sys_menu 的 parent_id 默认值设为 0,根节点的父 ID 不用每次显式写。外键我一般建议加上,但源码里为了批量初始化方便,先把外键注释掉了。实际部署时建议把 FK 补上,否则误删用户或角色后,关系表里会留下脏数据,查询结果会出现“幽灵菜单”。
3.2 一条 JOIN 取出当前用户可见菜单,连排序都带出来
登录成功后,程序端要做的事情很明确:拿当前用户 ID,查出他所有角色的可见菜单,按父子关系和 sort_no 排序,然后递归生成树。核心查询就一条,源码里是这么写的:
SELECT DISTINCT m.menu_id, m.parent_id, m.menu_name, m.menu_code, m.menu_type, m.sort_no FROM sys_menu m INNER JOIN sys_role_menu rm ON m.menu_id = rm.menu_id INNER JOIN sys_user_role ur ON rm.role_id = ur.role_id WHERE ur.user_id = @userId AND m.menu_type IN (1, 2) ORDER BY m.parent_id, m.sort_no;这里的逻辑要拆开看。INNER JOIN先从角色菜单关系表里过滤出用户所有角色对应的菜单,再回到菜单表取完整信息;DISTINCT是防一个用户挂了多个角色、多个角色分配了同一个菜单时出现重复行;menu_type IN (1, 2)把按钮权限排除掉,因为按钮不参与菜单树的渲染,只参与按钮的 Enabled 判断。最后ORDER BY m.parent_id, m.sort_no保证父节点排在前面,同层级的按 sort_no 递增,这样程序端构建树的时候,顺序天然是对的。
注意参数化写法。@userId是参数占位符,实际执行时用 SqlParameter 传值,不要拼字符串,这是 WinForm 程序防注入的基本盘。
3.3 按钮权限判断:Count 与 Exists,选谁
按钮权限和菜单权限共用 sys_role_menu,区别在menu_type = 3。判断当前用户有没有“导出订单”的按钮权限,常用的做法是给用户、角色、菜单三张表做一个关联查询,用 EXISTS 判断记录是否存在:
SELECT CASE WHEN EXISTS ( SELECT 1 FROM sys_user_role ur INNER JOIN sys_role_menu rm ON ur.role_id = rm.role_id INNER JOIN sys_menu m ON rm.menu_id = m.menu_id WHERE ur.user_id = @userId AND m.menu_code = @permissionCode AND m.menu_type = 3 ) THEN 1 ELSE 0 END;这里用EXISTS而非SELECT COUNT(1)是刻意的。EXISTS 只要查到一条就立刻返回,不走全表聚合;当用户权限点很多时,性能差距不明显,但养成这个习惯后,到百万级权限数据的系统里不容易翻车。@permissionCode传的是order:export这类标识,menu_type = 3限定只能命中按钮权限,防止菜单权限和按钮权限同码时误判。
我自己在实际项目里习惯把这段包装成一个工具方法HasPermission(string permissionCode),返回 bool,后面所有按钮控制、菜单控制都走这个入口,不会到处写 SQL。
4. WinForm 端动态菜单加载:登录后拼树、反射开窗体、按钮级权限控制
4.1 登录后递归构建 TreeView:节点 Tag 里到底存什么
数据库查出菜单列表后,下一步是把一行行平铺数据变成 TreeView 的树形结构。这个逻辑在源码里是一个递归方法,我先贴核心实现:
private void LoadMenuTree(TreeView tvMenu, DataTable dtMenu) { tvMenu.Nodes.Clear(); // 先找到根节点(parent_id = 0) DataRow[] rootRows = dtMenu.Select($"parent_id = 0"); foreach (DataRow root in rootRows) { TreeNode rootNode = CreateMenuNode(root); tvMenu.Nodes.Add(rootNode); // 递归挂子节点 AppendChildNodes(rootNode, dtMenu, Convert.ToInt32(root["menu_id"])); } tvMenu.ExpandAll(); } private void AppendChildNodes(TreeNode parentNode, DataTable dtMenu, int parentId) { DataRow[] childRows = dtMenu.Select($"parent_id = {parentId}"); foreach (DataRow child in childRows) { TreeNode childNode = CreateMenuNode(child); parentNode.Nodes.Add(childNode); AppendChildNodes(childNode, dtMenu, Convert.ToInt32(child["menu_id"])); } } private TreeNode CreateMenuNode(DataRow row) { TreeNode node = new TreeNode { Text = row["menu_name"].ToString(), Tag = new MenuTag { MenuId = Convert.ToInt32(row["menu_id"]), MenuCode = row["menu_code"].ToString(), MenuType = Convert.ToInt32(row["menu_type"]) } }; return node; }两层递归结构很清晰:第一层找根节点当作起点,第二层根据父节点的 menu_id 不断往下挂。这里要特别说明 Tag 的设计。很多初学写法是Tag = row["menu_code"],只存一个字符串,到后面点击事件里再拿字符串去反射,类型信息丢失,还得做一堆字符串判断。源码里把 menu_id、menu_code、menu_type 三个关键信息打包成 MenuTag 对象放在 Tag 里,点击时一次取全,不需要回查数据库。
dtMenu.Select($"parent_id = {parentId}")是 DataTable 的筛选表达式,性能对几百行的菜单配置完全够用。菜单数量上千再用字典预处理,这个量级下没必要。
4.2 叶子节点点击开窗体:反射二步走,别在 Click 里写一堆 switch
菜单点击事件是整个权限系统里最容易被写烂的地方。一个常见做法是在 TreeView 的 AfterSelect 里写:
switch (node.Text) { case "用户管理": OpenForm(new FrmUserManage()); break; case "订单查询": OpenForm(new FrmOrderQuery()); break; }这种写法菜单一多就膨胀,而且每加一个菜单就要改一次事件。源码的做法是用反射做“菜单码到窗体类型”的映射,约定一个命名规则:菜单的 menu_code 是user:manage,对应的窗体类名就是FrmUserManage,中间用代码拼类名。
private void tvMenu_AfterSelect(object sender, TreeViewEventArgs e) { MenuTag tag = e.Node.Tag as MenuTag; if (tag == null || tag.MenuType != 2) return; // 只处理叶子菜单 string formName = "MyApp.Forms.Frm" + ConvertMenuCodeToClassName(tag.MenuCode); Type formType = Type.GetType(formName); if (formType == null) { MessageBox.Show($"菜单 [{e.Node.Text}] 对应的窗体 {formName} 不存在"); return; } // 检查是否已打开,避免重复实例化 foreach (Form child in this.MdiChildren) { if (child.GetType() == formType) { child.Activate(); return; } } Form frm = (Form)Activator.CreateInstance(formType); frm.MdiParent = this; frm.Show(); } private string ConvertMenuCodeToClassName(string menuCode) { // user:manage -> UserManage string[] parts = menuCode.Split(':'); string className = ""; foreach (string part in parts) { className += char.ToUpper(part[0]) + part.Substring(1); } return className; }这个方案有几个隐晦的好处。第一,新加一个业务窗体,只要在数据库菜单表里插一行menu_code = 'report:monthly',再把对应的FrmReportMonthly类写出来,菜单系统不用改代码;第二,Type.GetType用带命名空间的全名,避免了类名冲突时的黑匣子问题,找不到类时能给明确提示;第三,foreach遍历 MdiChildren 检查窗体重叠,避免同一个窗体开三份,这是做 MDI 程序最容易遗漏的细节。
注意MenuType != 2的判断把目录节点挡在门外。目录节点点击不应该开窗体,这个判断不能省。
4.3 窗体内部按钮级控制:灰掉比隐藏更体面
菜单级别的权限解决了“能不能看到这个功能入口”,按钮级别的权限解决的是“进了窗体后哪些操作能用”。源码里在每个窗体加载时做一次统一控制,核心代码是这样:
private void FrmOrderQuery_Load(object sender, EventArgs e) { // 加载业务数据 LoadOrderData(); // 按钮级权限 btnExport.Enabled = PermissionUtil.HasPermission("order:export"); btnDelete.Enabled = PermissionUtil.HasPermission("order:delete"); btnEdit.Enabled = PermissionUtil.HasPermission("order:edit"); }这里我特别认同源码用Enabled而不是Visible的做法。按钮置灰是明示“你知道有这个功能,但当前角色无权使用”;隐藏则会让用户以为系统没有这功能,遇到需要申请权限时都不知道该向管理员要什么。置灰还有个额外好处:ToolTip 上可以提示“需要 xx 权限”,引导用户走权限申请流程。
需要留意的是,按钮权限判断依赖HasPermission方法,而这个方法内部每次都会去查数据库。单窗体打开不频繁时无所谓,如果窗体内部要频繁调用业务方法做权限校验,建议把权限码列表在登录时一次性装进内存,后面只查内存。
5. 权限菜单避坑四连:缓存不刷新、节点误判、窗体重叠、权限残留
5.1 改了角色菜单,重新登录还是老菜单
现象:管理员给某角色勾掉了“用户管理”菜单,让该角色用户重新登录,菜单树里“用户管理”还在。
原因:这是权限缓存没刷新。很多方案在登录时把菜单列表存进静态变量,比如Global.MenuTable,用户注销时只关窗体没有清空这个静态变量,换账号登录时直接复用了旧数据。
解决:把“读取菜单并构建树”的逻辑收敛到一个方法里,登录成功和注销后各强制调用一次,同时清空静态缓存。注销代码里必须写Global.MenuTable.Clear()或直接置null,不让旧数据跨账号存活。从那以后我每次做登录功能都强制走一遍“登录构建 / 注销清空”的对称逻辑。
5.2 递归建树时按节点文字判断层级,重名菜单全挂错
现象:两个一级菜单下都有“查询”子菜单,递归构建后,子菜单全部挂到第一个“查询”节点下面。
原因:递归函数里用node.Text == "查询"去匹配父子关系。菜单名在界面上可能重复,但 menu_id 是唯一的。用文字做关系判断等于用显示名做主键,必然撞车。
解决:父子关系判断只用menu_id和parent_id,Text 只负责显示。源码里AppendChildNodes接收的参数是parentId而不是父节点的文本,这个设计本身就是对的,改代码时不要“优化”成传名字。
5.3 点击菜单第二次打开窗体,提示“窗体已存在”
现象:同一个菜单点两次,第二次报异常或者打开一个空白的新窗体。
原因:反射每次Activator.CreateInstance都是新建对象,MDI 子窗体没有检查是否已存在同类型实例。这在有状态窗体(比如正在编辑数据未保存)时危害很大,用户可能丢失未保存内容。
解决:在反射创建前遍历this.MdiChildren,用child.GetType() == formType判断是否已打开,已打开就Activate()置顶,不开新实例。这段代码我在前面的菜单点击事件里已经贴了,属于源码里值得原样保留的部分。
5.4 A 账号注销后 B 账号登录,看到 A 的菜单
现象:同一台机器上,管理员注销后登录普通操作员账号,菜单树里依然有管理员的菜单。
原因:TreeView 控件没有在注销时清空,权限判断用的静态变量也没有重置。代码只做了登录时加载,没做注销时清理。
解决:注销按钮事件里,除了关闭当前窗体,要显式执行tvMenu.Nodes.Clear(),并把所有静态权限缓存恢复初始状态。最稳妥的做法是把“构建菜单树”做成一个统一入口,注销时把它重置为一个空白树,而不是依赖 GC 回收。这个坑最容易在测试时出现,开发机一直用同一个账号登录,根本发现不了。
6. 进阶:把权限校验收敛成一个 Attribute,菜单权限和按钮权限一次查完
前面几章的写法是“每个窗体 Load 里手动调HasPermission”,逻辑没错,但窗体多了以后每个 Load 里都堆几行btnXxx.Enabled = ...。更进一步的做法,是自定义一个权限特性,配合反射在窗体打开前统一拦截,源码里可以在此基础上二次扩展。
下面是我在自己项目里用过的方案,思路是从这个源码包的反射机制延伸出来的:
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] public class RequiredPermissionAttribute : Attribute { public string PermissionCode { get; } public RequiredPermissionAttribute(string permissionCode) { PermissionCode = permissionCode; } }业务窗体上加特性:
[RequiredPermission("order:query")] public partial class FrmOrderQuery : Form { // 业务代码不变 }打开窗体前统一检查:
private void OpenFormWithPermissionCheck(Type formType) { // 反射取特性 var attr = formType.GetCustomAttributes(typeof(RequiredPermissionAttribute), false) .Cast<RequiredPermissionAttribute>() .FirstOrDefault(); if (attr != null && !PermissionUtil.HasPermission(attr.PermissionCode)) { MessageBox.Show($"当前角色缺少权限:{attr.PermissionCode}"); return; } // 原有的去重与创建逻辑 foreach (Form child in this.MdiChildren) { if (child.GetType() == formType) { child.Activate(); return; } } Form frm = (Form)Activator.CreateInstance(formType); frm.MdiParent = this; frm.Show(); }Attribute 方案的价值在于权限检查和业务逻辑彻底解耦。窗体该不该对当前用户开放,写在类定义上,一眼能看到;已有的HasPermission方法负责实际判断,完全复用。缺点是特性只在反射创建窗体时有效,窗体内部按钮还要自己控制,但这两者本来就不在一个层级。
配合这个方案,权限码的查询也可以优化。登录时把当前用户所有menu_type IN (1, 2, 3)的 menu_code 装进一个静态HashSet<string>,HasPermission直接变成_permissionSet.Contains(permissionCode),不再每次查库。这步做完之后,整个权限系统几乎不再产生 SQL 开销,菜单加载只发生在登录时,按钮判断是纯内存操作。
从那以后我在任何 WinForm 项目里做权限菜单,都强制走一遍“五表模型 → 一次 JOIN 拉菜单 → 反射开窗体 → 特性化权限校验”这套路径,不管项目多小都保留最小版的权限设计。兜兜转转还是这套源码给的底子最顺,希望帮到你。
本文还有配套的精品资源,点击获取