🪟 第19章 C#与SQL Server实现企业人事管理系统
📄 一句话概括

菜单不是「画」出来的,是「算」出来的:登录拿到角色,角色查出功能,功能生成菜单项。 子窗体要放进主面板,不是弹一堆新窗口——弹窗是新人最常见的界面错误。 把当前用户当参数传下去,别用全局静态变量——静态变量在测试时改不回来。

📝 本章和前面几章的关系

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 秒实验

  1. 给 GRANT['普通'] 加上「工资条」,看菜单怎么变。 再想想:普通员工看自己的工资条,数据层要怎么限制「只能看自己的」? 靠 WHERE emp_id = 当前用户。
  2. 把 if keep: 改成 if True:,看「普通」的菜单变成什么样。这就是「空菜单」的样子。
  3. 想一想:如果菜单项有 50 个,每个都写一个 menuXxx_Click 合适吗? 不合适——应该用一个事件,靠 Tag 区分是哪个菜单项。

常见误区

  1. 「每个功能弹一个新窗口」。 关的时候找不到爹。 子窗体放进主面板,用户始终在一个窗口里。
  2. 「用全局静态变量存当前用户」。 换个人登录,上一个用户还在里面。 属性传参虽然啰嗦,但依赖清楚。
  3. 「隐藏了按钮就安全了」。 隐藏只是界面礼貌。 用户能直接连库,SQL 层的限制才是真的。
自测三问(点开对答案) 1. 菜单是怎么「算」出来的? —— **登录拿到角色 → 查角色能看的功能 → 按功能生成菜单项。** **改权限只改数据,不改界面代码。** 2. 为什么一级节点下没有可显示项时要整块隐藏? —— **否则会留下一个点开是空白的菜单。** **空菜单比没有菜单更让人困惑。** 3. 「隐藏删除按钮」算不算权限控制? —— **不算,只是界面礼貌。** **真正的控制在 SQL 的 `WHERE` 和数据库账号的 `GRANT` 上。**

下一篇 19.7,进业务模块: 入职一个事务、调岗留历史、考勤批量写、工资按公式算——四个模块共用一套骨架。

相关笔记