菜单不是「画」出来的,是「算」出来的:登录拿到角色,角色查出功能,功能生成菜单项。 子窗体要放进主面板,不是弹一堆新窗口——弹窗是新人最常见的界面错误。 把当前用户当参数传下去,别用全局静态变量——静态变量在测试时改不回来。
19.5 解决了「能不能进来」,19.6 解决「进来之后看到什么」。 这一节的技术含量不高,但它是「权限」这件事落到界面的唯一出口。 教材 19.6 分三小节:概述、技术分析、实现过程。 「技术分析」里那句「根据用户权限动态生成菜单」是全节的题眼—— 它把「权限表」和「界面」连起来了。 沙箱没有 WinForms,所以菜单树用「打印三份菜单」的方式复刻, 复刻的是「菜单怎么算出来」,不是「菜单怎么画出来」。 最后那一节讲 WinForms 的坑,是照实说的「桌面项目才会遇到」的问题。
一、一个窗体 + 一个面板 + 一棵菜单树
| 控件 | 作用 |
|---|---|
MenuStrip |
顶部菜单,五个一级项 |
ToolStrip |
常用按钮,其实就是菜单的快捷方式 |
StatusStrip |
底部,显示当前用户和角色 |
Panel(主面板) |
所有子窗体都塞在这里,不弹新窗口 |
子窗体放进 Panel 而不是 ShowDialog(),是为了让用户始终在一个窗口里操作。
弹一堆窗口是新人最常见的界面错误——关的时候找不到爹,任务栏里一堆图标。
做法是设 TopLevel = false,然后 panel1.Controls.Add(f),最后 f.Show()。
这三步少一步都出不来——少 TopLevel = false 就变成独立窗口,少 Add 就看不见。
StatusStrip 上那一行「当前用户:李慧(人事)」看着是装饰,其实有用——
用户随时知道自己是谁、有什么权限,就不会问「为什么我这个按钮是灰的」。
二、菜单按角色算:三份菜单真跑一遍
| 角色 | 一级节点 | 二级项 |
|---|---|---|
| 管理员 | 5 | 11 |
| 人事 | 3 | 7 |
| 普通 | 1 | 1 |
「管理员」是 11 个二级项(3+2+2+2+2),「人事」是 7 个, 「普通」只有 1 个。 这个数字差不是歧视,是职责边界。 注意「人事」拿到 7 个之后,一级节点只剩 3 个—— 因为「组织管理」和「系统管理」下的项一个都没给,整块就消失了。 「一级节点下没有可显示的项,就整块隐藏」这条规则要写进代码, 否则会留下一个点开是空白的菜单。 菜单和窗体必须一一对应。 少一个映射,菜单点下去就是空—— 加功能时最容易漏的就是这个映射表,而且漏了不会报错,只会静默无反应。
三、打开子窗体:把当前用户传下去
| 传法 | 好处 | 坏处 |
|---|---|---|
| 构造参数/属性 | 依赖清楚,好测 | 每个窗体都要多写一行 |
| 全局静态变量 | 随便哪儿都能取 | 测试改不回来,多线程不安全 |
| 每次查库 | 永远最新 | 多一次往返,且权限变了界面不会自动刷 |
教材用的是「属性传参」(f.CurrentUser = LoginUser),这是三种里最稳的。
静态变量看着方便,但它的生命周期跟进程一样长——换个人登录,上一个用户还在里面。
子窗体拿到用户之后干什么? 最常见的是「按权限隐藏按钮」——
普通员工进来,「删除」按钮就不该出现。
但要注意:隐藏按钮只是「界面礼貌」,不是安全措施——真正的拦截必须在 SQL 层或者库权限上。
四、一个真实的坑:主窗体关了,进程还在
| 现象 | 原因 |
|---|---|
| 点右上角关闭,窗口没了,任务管理器还有进程 | 还有子窗体或线程没结束 |
| 进程留着,数据库连接不释放 | 连接池挂着,库那边看到连接数不降 |
| 再启动一次,报「端口/文件被占用」 | 上一个进程还抓着文件 |
这不是 SQL 的问题,但它是「桌面项目」必然会遇到的一类问题:
语言和框架的默认行为,往往跟你的直觉相反。
解法是在 FormClosing 里显式调 Application.Exit()。
这一行不加,任务管理器里就会攒一堆进程——这是 WinForms 的经典坑。
这类坑只能靠「实际跑一遍再发布」发现,看文档看不出来。
所以桌面项目的验收清单里,应该有一条「反复开关十次,看进程数」——这条比任何代码审查都有效。
五、动手 Demo:按角色生成三份菜单
这一段把「权限表 → 菜单树」这段换算真跑一遍,打印出三种角色各自能看到的菜单。 看重点不是菜单长什么样,是「一级节点怎么跟着消失」。
MENU = [
('员工管理', ['员工档案', '调岗与离职', '花名册导出']),
('组织管理', ['部门设置', '岗位设置']),
('考勤管理', ['每日考勤', '月度汇总']),
('工资管理', ['工资计算', '工资条']),
('系统管理', ['用户与角色', '数据备份']),
]
GRANT = {'管理员': None, '人事': [...], '普通': ['每日考勤']}
def build(role):
ok = GRANT[role]
out = []
for top, subs in MENU:
keep = subs if ok is None else [x for x in subs if x in ok]
if keep: # 一个都不剩就整块不要
out.append((top, keep))
return out
for role in ('管理员', '人事', '普通'):
tree = build(role)
print('[%s] 一级 %d 个,二级 %d 个'
% (role, len(tree), sum(len(s) for _, s in tree)))
for top, subs in tree:
print(' ' + top + ':' + '、'.join(subs))
实跑输出:
--------------------------------------------------------------------
主窗体:菜单是权限表的投影
--------------------------------------------------------------------
① 一个窗体 + 一个面板 + 一棵菜单树
--------------------------------------------------------------------
| 控件 | 作用 |
|---|---|
| MenuStrip | 顶部菜单,五个一级项 |
| ToolStrip | 常用按钮,其实就是菜单的快捷方式 |
| StatusStrip | 底部,显示当前用户和角色 |
| Panel(主面板) | 所有子窗体都塞在这里,不是弹新窗口 |
子窗体放进 Panel 而不是 ShowDialog(),是为了让用户始终在一个窗口里操作。
弹一堆窗口是新人最常见的界面错误——关的时候找不到爹。
② 菜单树按角色生成:真跑三份
--------------------------------------------------------------------
[管理员] 一级 5 个,二级 11 个
员工管理:员工档案、调岗与离职、花名册导出
组织管理:部门设置、岗位设置
考勤管理:每日考勤、月度汇总
工资管理:工资计算、工资条
系统管理:用户与角色、数据备份
[人事] 一级 3 个,二级 7 个
员工管理:员工档案、调岗与离职、花名册导出
考勤管理:每日考勤、月度汇总
工资管理:工资计算、工资条
[普通] 一级 1 个,二级 1 个
考勤管理:每日考勤
菜单不是「画」出来的,是「算」出来的。登录拿到角色 → 查角色能看的功能 → 按功能生成菜单项。
这样做的好处:加一个功能、调一次权限,都不用改界面代码,改数据就行。
坏处:菜单项和窗体必须一一对应——少一个映射,菜单点下去就是空。
③ 点菜单 → 打开窗体 → 传当前用户
--------------------------------------------------------------------
[教材原码 · 本沙箱不执行]
| private void menuEmp_Click(object sender, EventArgs e)
| {
| FormEmp f = new FormEmp();
| f.MdiParent = this; // 或者 f.TopLevel = false; panel1.Controls.Add(f);
| f.CurrentUser = LoginUser; // 把登录时拿到的用户传下去
| f.Show();
| }
CurrentUser 这个属性是关键:子窗体要按权限隐藏「删除」按钮。
为什么要传,不用全局静态变量? 因为静态变量在测试时改不回来。
传参虽然啰嗦,但一个窗体的行为只取决于传进来的东西,好测。
④ 一个真实的坑:关掉主窗体,进程还在
--------------------------------------------------------------------
[教材原码 · 本沙箱不执行]
| // 主窗体关闭时,如果还有子窗体没关,进程可能残留
| private void FrmMain_FormClosing(object sender, FormClosingEventArgs e)
| {
| if (MessageBox.Show("确定退出?", "提示",
| MessageBoxButtons.YesNo) == DialogResult.No)
| {
| e.Cancel = true; // 取消关闭
| }
| else
| {
| Application.Exit(); // 真正结束进程
| }
| }
少了 Application.Exit(),任务管理器里会留一堆进程——这是 WinForms 的经典坑。
这不是 SQL 的问题,但它是「桌面项目」必然会遇到的一类问题:语言和框架的默认行为,
往往跟你的直觉相反。这类坑只能靠「实际跑一遍再发布」发现,看文档看不出来。
if keep: 这一行是关键。 去掉它,一级节点会全留着——
「普通」点开「组织管理」,里面一片空白。
这种「空菜单」比没有菜单更让人困惑。
还有一种做法是「一级节点留着、置灰」,用来告诉用户「你有这个模块但没权限」。
两种做法都行,取决于业务——工资这种敏感模块,建议直接不显示。
这段代码里权限是硬编在 GRANT 字典里的。 真实系统要存在表里(tb_role_menu),
否则改一次权限就得发一次版。
这一节的 Demo 为了聚焦「换算逻辑」,把存储省掉了。
30 秒实验
- 给
GRANT['普通']加上「工资条」,看菜单怎么变。 再想想:普通员工看自己的工资条,数据层要怎么限制「只能看自己的」? 靠WHERE emp_id = 当前用户。 - 把
if keep:改成if True:,看「普通」的菜单变成什么样。这就是「空菜单」的样子。 - 想一想:如果菜单项有 50 个,每个都写一个
menuXxx_Click合适吗? 不合适——应该用一个事件,靠Tag区分是哪个菜单项。
常见误区
- 「每个功能弹一个新窗口」。 关的时候找不到爹。 子窗体放进主面板,用户始终在一个窗口里。
- 「用全局静态变量存当前用户」。 换个人登录,上一个用户还在里面。 属性传参虽然啰嗦,但依赖清楚。
- 「隐藏了按钮就安全了」。 隐藏只是界面礼貌。 用户能直接连库,SQL 层的限制才是真的。
自测三问(点开对答案)
1. 菜单是怎么「算」出来的? —— **登录拿到角色 → 查角色能看的功能 → 按功能生成菜单项。** **改权限只改数据,不改界面代码。** 2. 为什么一级节点下没有可显示项时要整块隐藏? —— **否则会留下一个点开是空白的菜单。** **空菜单比没有菜单更让人困惑。** 3. 「隐藏删除按钮」算不算权限控制? —— **不算,只是界面礼貌。** **真正的控制在 SQL 的 `WHERE` 和数据库账号的 `GRANT` 上。**下一篇 19.7,进业务模块: 入职一个事务、调岗留历史、考勤批量写、工资按公式算——四个模块共用一套骨架。
- ☐ (本篇完,下一篇 19.7 业务模块设计)