资讯详情

C# WinForms医院挂号管理系统开发:从数据库设计到登录权限完整实战

📅 2026/10/16 6:42:28 | 华诺云谱 👁 阅读
C# WinForms医院挂号管理系统开发:从数据库设计到登录权限完整实战
简介这是一份使用C#语言与Windows窗体技术开发的医院挂号管理系统完整项目采用客户端/服务器架构以及模型-视图-控制器分层设计适合有一定编程基础的学习者、课程设计或毕业设计参考。系统涵盖用户管理、科室管理、医生管理、门急诊挂号、挂号查询、修改口令、挂号单打印、帮助文档等模块利用存储过程操作数据库对用户密码进行加密处理以图文报表形式展示统计信息并支持医生照片上传。压缩包共包含185个文件以源代码文件、界面图片、资源文件、数据库脚本以及可执行程序为主另附帮助文档和安装包整体体积约10MB目录结构清晰便于按模块检索学习。已有1265人学习下载。读者可以通过查看完整源码与数据库脚本理解三层架构和业务实现也可运行程序体验挂号流程是一份适合实战练习的完整样例。1. 医院挂号管理系统为什么越来越多课设和入职练手都选这个项目如果你正在找一份能完整跑通“数据库 登录权限 核心业务流”的练手项目C# WinForms 做的医院挂号管理系统是个绕不开的选择。它不像商城系统那样重页面交互也不像进销存那样依赖复杂报表它的业务边界很清晰患者登记、科室医生维护、挂号收费、退号、排队叫号、简单统计。这套流程恰好覆盖了桌面应用开发里最高频的技能点——DataGridView 绑定、事务处理、参数化 SQL、MD5 加密、自定义控件封装。对刚学完 C# 基础但没写过完整项目的人来说它是最容易在两周内把代码跑起来、又能讲清楚设计思路的项目之一。对已经工作一段时间的人它也是快速回顾 WinForms 开发细节的好样本。这篇文章我会从数据库设计讲到登录权限再到挂号主流程和几个典型的翻车点尽量把每一步都落到能直接复现的程度。2. 先搭地基看这套系统的表结构、字段语义和初始化数据怎么设计2.1 五张核心表覆盖一套最小可用的挂号业务闭环一个能运行的挂号系统数据库是最先要定的部分。常见的做法是设计五张表用户表、患者表、科室表、医生表、挂号记录表。这五张表彼此关联覆盖了从“谁在使用系统”到“患者挂了哪个科、哪个医生”的完整链路。用户表Sys_User存储登录账号、密码和角色类型角色分为管理员、挂号员、医生三类。患者表Patient_Info保存患者姓名、性别、年龄、身份证号、手机号和病历号。科室表Dept_Info只保留科室编号、科室名称、位置和简介。医生表Doctor_Info关联科室编号记录医生的姓名、职称、排班时间段、挂号费用和每日可挂号数量。挂号记录表Register_Record是整个系统的核心关联患者、科室、医生三张表记录挂号时间、号源序号、状态已挂号/已就诊/已退号和收费金额。这个设计其实抓住了门诊挂号最朴素的逻辑一个患者在一段时间内可以在多个科室挂号因此挂号记录表必须单独存在而不是挂在患者表下面。医生和科室是一对多关系一个科室有多个医生所以医生表里存的是科室外键而不是反过来存。2.2 建库建表脚本与初始化数据直接复制就能跑以下是这套系统最常见的建表脚本表名和字段名沿用了项目内的原始命名规范方便你直接对号入座CREATE DATABASE HospitalDB; GO USE HospitalDB; GO CREATE TABLE Sys_User ( UserId INT PRIMARY KEY IDENTITY(1,1), UserName NVARCHAR(50) NOT NULL UNIQUE, PassWord NVARCHAR(64) NOT NULL, RoleType INT NOT NULL DEFAULT 2, -- 0管理员 1医生 2挂号员 RealName NVARCHAR(50) NULL, DeptId INT NULL ); CREATE TABLE Patient_Info ( PatientId INT PRIMARY KEY IDENTITY(1001,1), PatientNo NVARCHAR(20) NOT NULL UNIQUE, -- 病历号 PatientName NVARCHAR(50) NOT NULL, Gender CHAR(2) NOT NULL, Age INT NOT NULL, IdCard NVARCHAR(18) NULL, Phone NVARCHAR(11) NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE Dept_Info ( DeptId INT PRIMARY KEY IDENTITY(1,1), DeptName NVARCHAR(50) NOT NULL, DeptLocation NVARCHAR(100) NULL, DeptDesc NVARCHAR(200) NULL ); CREATE TABLE Doctor_Info ( DoctorId INT PRIMARY KEY IDENTITY(10001,1), DoctorName NVARCHAR(50) NOT NULL, DeptId INT NOT NULL REFERENCES Dept_Info(DeptId), Title NVARCHAR(20) NULL, -- 职称主任医师/副主任医师/主治医师 WorkTime NVARCHAR(100) NULL, -- 排班描述周一至周五 上午8:00-12:00 RegFee DECIMAL(10,2) NOT NULL DEFAULT 10.00, -- 挂号费 DailyLimit INT NOT NULL DEFAULT 30 -- 每日可挂数量 ); CREATE TABLE Register_Record ( RegId INT PRIMARY KEY IDENTITY(1,1), PatientId INT NOT NULL REFERENCES Patient_Info(PatientId), DeptId INT NOT NULL REFERENCES Dept_Info(DeptId), DoctorId INT NOT NULL REFERENCES Doctor_Info(DoctorId), RegDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), RegTime DATETIME NOT NULL DEFAULT GETDATE(), RegNo INT NOT NULL, -- 当天该医生的号源序号 Status INT NOT NULL DEFAULT 0, -- 0已挂号 1已就诊 2已退号 RegFee DECIMAL(10,2) NOT NULL );建表脚本里的两个 IDENTITY 种子值不是随便写的。患者从 1001 开始医生从 10001 开始是为了让程序里的编号看起来更接近真实医院的病历号和工号形式避免从 1 开始造成的“演示感过强”。RegNo 字段是挂号当天该医生名下的第几个号系统里要配合“查询当天已挂数量 1”的逻辑来生成不需要自增列。初始化数据这一步很容易被忽略但没有基础数据的系统登录界面都进不去。至少要插入一个管理员账号、一个挂号员账号、三到五个科室和对应医生INSERT INTO Sys_User (UserName, PassWord, RoleType, RealName) VALUES (admin, E10ADC3949BA59ABBE56E057F20F883E, 0, 系统管理员); INSERT INTO Sys_User (UserName, PassWord, RoleType, RealName) VALUES (gui, E10ADC3949BA59ABBE56E057F20F883E, 2, 挂号员小张); -- 密码统一为 123456MD5 十六位小写形式 INSERT INTO Dept_Info (DeptName, DeptLocation, DeptDesc) VALUES (内科, 门诊楼2层201, 呼吸、消化系统疾病); INSERT INTO Dept_Info (DeptName, DeptLocation, DeptDesc) VALUES (外科, 门诊楼2层205, 普通外科常见病); INSERT INTO Doctor_Info (DoctorName, DeptId, Title, WorkTime, RegFee, DailyLimit) VALUES (李某, 1, 主任医师, 周一至周五上午, 20.00, 25); INSERT INTO Doctor_Info (DoctorName, DeptId, Title, WorkTime, RegFee, DailyLimit) VALUES (王某, 1, 主治医师, 周一至周六全天, 10.00, 40); INSERT INTO Doctor_Info (DoctorName, DeptId, Title, WorkTime, RegFee, DailyLimit) VALUES (赵某, 2, 副主任医师, 周二周四上午, 15.00, 30);密码这里多说一句项目里常见的做法是对 123456 做一次 MD5 加密后直接存库代码如下所示。管理员账号默认就是 admin登录后应该立刻改密码这个习惯在部署到任何环境里都一样重要。3. 从登录到首页权限分级、MD5 加密和界面框架的实现细节3.1 为什么登录模块值得单独看而不是直接查库比对不少人做登录模块时习惯直接在按钮点击事件里写一条 SQL 把用户名和密码查出来比对。这个方法在小demo里没问题但放到这套挂号系统里会漏掉两件重要的事密码不能明文存储、不同角色登录后看到的界面必须不同。项目里常见的做法是用 MD5 加密存储密码登录验证时分角色跳转主窗体。MD5 加密这一层严格说不是不可逆吗为什么还要用它存储登录密码原因在于这套项目是学习和练手用途MD5 加盐的方式已经能挡住“数据库泄露后直接看到明文密码”这类最常见的问题。生产环境推荐 SHA256 或 BCrypt但课设和内部工具用 MD5 的项目我见过很多重点是代码里不要把“比较密码”的逻辑写成把用户输入的密码拼接进 SQL 的方式。public static string GetMD5(string input) { using (MD5 md5 MD5.Create()) { byte[] bytes Encoding.UTF8.GetBytes(input); byte[] result md5.ComputeHash(bytes); StringBuilder sb new StringBuilder(); for (int i 0; i result.Length; i) { sb.Append(result[i].ToString(x2)); } return sb.ToString(); } }这段代码做的事情很简单把字符串转成 UTF-8 字节数组再做 MD5 计算最后逐字节转成十六进制小写字符串。要注意的是 Encoding.UTF8 这步换成 ASCII 或默认编码在某些包含中文的情况下会得到不同的哈希值写登录功能时前后端编码必须一致。3.2 登录窗口到主窗口角色判断和控制台输出验证登录按钮的处理逻辑通常是这样先判断用户名和密码不能为空再用参数化查询取出用户记录比对密码哈希值然后按 RoleType 跳转到不同的主窗体。代码里的核心判断部分大致如下private void btnLogin_Click(object sender, EventArgs e) { if (string.IsNullOrWhiteSpace(txtUser.Text) || string.IsNullOrWhiteSpace(txtPwd.Text)) { MessageBox.Show(用户名和密码不能为空); return; } string sql SELECT UserId, UserName, RoleType, RealName, DeptId FROM Sys_User WHERE UserName name AND PassWord pwd; using (SqlConnection conn new SqlConnection(DBHelper.ConnStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(name, txtUser.Text.Trim()); cmd.Parameters.AddWithValue(pwd, GetMD5(txtPwd.Text.Trim())); conn.Open(); using (SqlDataReader reader cmd.ExecuteReader()) { if (reader.Read()) { CurrentUser.UserId Convert.ToInt32(reader[UserId]); CurrentUser.UserName reader[UserName].ToString(); CurrentUser.RoleType Convert.ToInt32(reader[RoleType]); CurrentUser.DeptId reader[DeptId] DBNull.Value ? 0 : Convert.ToInt32(reader[DeptId]); if (CurrentUser.RoleType 0) { AdminMainForm mainForm new AdminMainForm(); mainForm.Show(); } else if (CurrentUser.RoleType 1) { DoctorMainForm docForm new DoctorMainForm(); docForm.Show(); } else { RegisterMainForm regForm new RegisterMainForm(); regForm.Show(); } this.Hide(); } else { MessageBox.Show(用户名或密码错误); } } } }这个实现里有三个值得注意的点。第一是 CurrentUser 这个静态类它在项目里扮演“全局会话”的角色存的是当前登录用户的编号、姓名、角色和科室后面的挂号记录写入需要用到 UserId 来记录操作人。第二是 AddWithValue 这个参数化方式虽然它偶尔会被性能极客吐槽但在课设和内部系统里完全够用重点是养成“任何用户输入都走参数化”的习惯这套系统的所有 SQL 都遵循了这个原则基本免疫 SQL 注入。第三是 RoleType 判断三种角色分别打开三个主窗体这是项目最常见的权限管理实现方式——不是做细力度权限控制而是用不同的 MainForm 来天然隔离功能边界。3.3 主窗体的菜单权限配置与公共样式这套系统的主窗体用的是传统 WinForms 的 MenuStrip 方案。管理员窗体的菜单包括患者管理、科室管理、医生管理、挂号收费、退号处理、排队叫号、统计报表、系统设置。挂号员窗体只有患者登记、挂号收费、退号处理、排队叫号。医生窗体则只有我的排班、我的患者、就诊确认。菜单这一层不建议在代码里反复写 if 控制可见性更好的做法是每个角色窗体里只放该角色需要的菜单项也就是每个 MainForm 都单独设计菜单结构。这样做的好处是业务边界清楚A角色永远不可能在界面上找到 B 角色的入口代码排查问题时也不用逐条判断菜单为何隐藏。缺点是要维护三个 MainForm但对这套体量的系统来说代码清晰度比省那点窗体数量重要得多。界面风格上项目里常见的做法是给所有子窗体设置公共的 Font、BackColor 和按钮样式封装成一个 BaseForm 类让所有窗体继承。这样做主要是为了统一视觉避免出现一个窗体是宋体9号、另一个是微软雅黑11号的情况。我在实际拆过的项目里见过很多窗体风格不统一的这虽然不是业务 bug但答辩或演示时观感会差不少。4. 核心业务流患者登记、挂号收费、退号、叫号的完整代码链路4.1 患者登记病历号自动生成与重复检查患者首次来医院要先建档案这块功能在系统里是一个独立的 “患者登记” 窗体。病历号不是自增列而是用固定前缀加日期加当日序号拼出来的例如 P-20250107-001。这样做的目的是病历号本身携带日期信息线下拿纸质病历本时能和挂号日期对上比纯自增数字直观得多。private void btnSavePatient_Click(object sender, EventArgs e) { if (txtName.Text.Trim() || cmbGender.Text || txtAge.Text.Trim() ) { MessageBox.Show(姓名、性别、年龄为必填项); return; } string patientNo P- DateTime.Now.ToString(yyyyMMdd) - GetTodayPatientSeq(); string sql INSERT INTO Patient_Info (PatientNo, PatientName, Gender, Age, IdCard, Phone) VALUES (no, name, gender, age, idcard, phone); using (SqlConnection conn new SqlConnection(DBHelper.ConnStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(no, patientNo); cmd.Parameters.AddWithValue(name, txtName.Text.Trim()); cmd.Parameters.AddWithValue(gender, cmbGender.Text.Trim()); cmd.Parameters.AddWithValue(age, Convert.ToInt32(txtAge.Text.Trim())); cmd.Parameters.AddWithValue(idcard, string.IsNullOrWhiteSpace(txtIdCard.Text) ? DBNull.Value : (object)txtIdCard.Text.Trim()); cmd.Parameters.AddWithValue(phone, string.IsNullOrWhiteSpace(txtPhone.Text) ? DBNull.Value : (object)txtPhone.Text.Trim()); conn.Open(); cmd.ExecuteNonQuery(); MessageBox.Show(患者登记成功病历号 patientNo); LoadPatientList(); } } private string GetTodayPatientSeq() { string sql SELECT COUNT(*) FROM Patient_Info WHERE PatientNo LIKE prefix; using (SqlConnection conn new SqlConnection(DBHelper.ConnStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(prefix, P- DateTime.Now.ToString(yyyyMMdd) -%); conn.Open(); int count (int)cmd.ExecuteScalar(); return (count 1).ToString(D3); } }这里要注意 IdCard 和 Phone 允许为空所以在参数赋值时用了 DBNull.Value 做空值转换。这个细节在很多项目里都会翻车——直接把空字符串赋给可空字段SQL Server 会报字符串转换失败。用 GetTodayPatientSeq 拼接病历号有个潜在问题如果当天有删除患者的操作序号会重复因为 COUNT 只统计当前存在的记录。项目里常用的解法是允许一定概率的序号空缺或者干脆用 IDENTITY 自增列做编号主体日期前缀只是展示用。课设阶段用 COUNT 统计是大家最常写的方案代码简单且够用。4.2 挂号收费同一事务里完成号源占用与记录写入挂号是整个系统里最核心的一步它在一次操作里要同时做三件事查询医生当天的剩余号源、生成新的号源序号、写入挂号记录。这三件事必须在一个数据库事务里完成否则会出现“记录写进去了但号源数没减”这类脏数据。private void btnRegister_Click(object sender, EventArgs e) { if (cmbDept.SelectedValue null || cmbDoctor.SelectedValue null || txtPatientNo.Text.Trim() ) { MessageBox.Show(请选择科室、医生和患者病历号); return; } int deptId Convert.ToInt32(cmbDept.SelectedValue); int doctorId Convert.ToInt32(cmbDoctor.SelectedValue); decimal regFee Convert.ToDecimal(txtRegFee.Text.Trim()); string sqlPatient SELECT PatientId FROM Patient_Info WHERE PatientNo patientNo; string sqlCheck SELECT COUNT(*) FROM Register_Record WHERE DoctorId doctorId AND RegDate regDate AND Status 2; string sqlInsert INSERT INTO Register_Record (PatientId, DeptId, DoctorId, RegDate, RegTime, RegNo, Status, RegFee) VALUES (patientId, deptId, doctorId, regDate, regTime, regNo, 0, regFee); using (SqlConnection conn new SqlConnection(DBHelper.ConnStr)) { conn.Open(); SqlTransaction tran conn.BeginTransaction(); try { int patientId; string regNo; using (SqlCommand cmd new SqlCommand(sqlPatient, conn, tran)) { cmd.Parameters.AddWithValue(patientNo, txtPatientNo.Text.Trim()); patientId Convert.ToInt32(cmd.ExecuteScalar()); } int regNoInt; using (SqlCommand cmd new SqlCommand(sqlCheck, conn, tran)) { cmd.Parameters.AddWithValue(doctorId, doctorId); cmd.Parameters.AddWithValue(regDate, DateTime.Now.Date); regNoInt Convert.ToInt32(cmd.ExecuteScalar()) 1; } regNo regNoInt.ToString(D3); if (regNoInt 30) { tran.Rollback(); MessageBox.Show(该医生今日号源已满); return; } using (SqlCommand cmd new SqlCommand(sqlInsert, conn, tran)) { cmd.Parameters.AddWithValue(patientId, patientId); cmd.Parameters.AddWithValue(deptId, deptId); cmd.Parameters.AddWithValue(doctorId, doctorId); cmd.Parameters.AddWithValue(regDate, DateTime.Now.Date); cmd.Parameters.AddWithValue(regTime, DateTime.Now); cmd.Parameters.AddWithValue(regNo, regNoInt); cmd.Parameters.AddWithValue(regFee, regFee); cmd.ExecuteNonQuery(); } tran.Commit(); MessageBox.Show(挂号成功号源序号 regNo); LoadTodayRecords(); } catch (Exception ex) { tran.Rollback(); MessageBox.Show(挂号失败 ex.Message); } } }SqlTransaction 是这套系统里最关键的一个对象它保证了 sqlPatient、sqlCheck、sqlInsert 三条 SQL 要么全部生效要么全部回滚。这里一个容易被新手忽略的点是使用了事务之后所有 SqlCommand 都必须显式传入同一个 transaction 对象否则会抛出 “ExecuteNonQuery requires the command to have a transaction” 的异常。每天号源数上限写在代码里是 30但这个数值应该来自医生表的 DailyLimit 字段实际项目里是从医生行数据读出来再参与比较的建议改成动态获取。4.3 退号和叫号状态流转中的细节坑退号不是删除记录而是把挂号记录的状态改成 2已退号。为什么不能直接 DELETE因为挂号记录涉及收费统计和后续追溯物理删除会让当天的挂号统计、医生工作量报表全部失准。正确的处理是 UPDATE 状态同时把退号操作记录下来。private void btnCancelRegister_Click(object sender, EventArgs e) { if (dgvRecords.CurrentRow null) return; int regId Convert.ToInt32(dgvRecords.CurrentRow.Cells[RegId].Value); string sql UPDATE Register_Record SET Status 2 WHERE RegId regId AND Status 0; using (SqlConnection conn new SqlConnection(DBHelper.ConnStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(regId, regId); conn.Open(); int rows cmd.ExecuteNonQuery(); if (rows 0) MessageBox.Show(退号成功); else MessageBox.Show(该记录无法退号可能已就诊或已退号); LoadTodayRecords(); } }SQL 里加了 Status 0 这个条件目的是防止并发场景下同一条记录被重复退号——第一遍退成功 status 变成 2第二遍再来时 rows 为 0界面提示无法退号。这套系统没有处理并发锁但用条件更新就天然规避了重复退号问题。叫号模块相对简单从当天挂号记录里查出状态为 0 的记录按 RegNo 排序点击“下一个”按钮时把当前 RegNo 展示到界面上同时把状态更新为 1已就诊。这个模块有一个频繁踩坑点DataGridView 显示的是查询结果集更新数据库后如果不重新查询界面上的状态还是旧数据。所以叫号按钮的事件里更新完数据库必须重新执行一遍查询并重新绑定 DataSource否则界面和数据库会出现不一致演示时会显得特别不专业。5. 避坑指南WinForms SQL Server 开发中常见的六个翻车点5.1 DataGridView 绑定后修改数据不生效现象DataGridView 绑定 List 或 DataTable 后界面上改了单元格内容关掉程序重新打开数据库里的值没变。原因DataGridView 默认只负责展示不会自动把修改写回数据库。它绑定的是数据的副本不是数据的实时同步通道。解决在 CellEndEdit 事件里手动取当前行的主键和新值执行 UPDATE 语句。或者更省事的方式——不让用户直接在 DataGridView 里改数据双击行弹出编辑窗体在窗体里用明确的输入控件维护数据。5.2 连接字符串里的服务器名在别的机器上失效现象项目在自己电脑上跑得正常拷贝到答辩机器或同事电脑上一运行就报“建立与服务器的连接时出错”。原因App.config 里的 Data Source 写的是 localhost 或者固定的机器名目标机器的 SQL Server 实例名不同。解决把连接字符串里的 Data Source 显式写清楚并且把配置文件改成如下形式便于部署时快速修改。connectionStrings add nameHospitalDB connectionStringData Source.;Initial CatalogHospitalDB;Integrated SecurityTrue;MultipleActiveResultSetsTrue providerNameSystem.Data.SqlClient / /connectionStringsData Source.; 表示本机默认实例比 localhost 兼容性更好。MultipleActiveResultSetsTrue 这段配置也很关键——如果你的程序里出现了“一个连接对象上同时开启多个 DataReader 未关闭”的情况这个参数能保证程序不崩溃但它只是兜底根治还是要养成写完 reader 立刻 close 的习惯。5.3 事务里混入查询语句导致锁表现象挂号高峰期系统偶尔卡死数据库里大量阻塞进程。原因代码里在事务中先执行了 SELECT再执行 INSERT同时事务没有及时提交。SQL Server 默认的隔离级别下事务中的 SELECT 可能持有共享锁和另一个事务的排他锁冲突。解决事务的粒度要最小化只把 INSERT、UPDATE、DELETE 放进事务里查询语句尽量放在事务开启之前执行。或者用 READ COMMITTED 隔离级别配合 Index 索引减少锁的持续时间。5.4 日期时间字段的字符串拼接错误现象按日期筛选挂号记录时查不出当天的数据或者查出前一天的数据。原因常见于当日历控件 DateTimePicker 的值直接拼进 SQLValue 属性默认带时间部分而数据库字段是 date 类型二者不匹配。解决所有日期参数在传值前统一格式化。string sql SELECT * FROM Register_Record WHERE RegDate date; cmd.Parameters.AddWithValue(date, dtpDate.Value.Date.ToString(yyyy-MM-dd));记住这句基础操作DateTimePicker.Value 几乎永远要带 .Date。天天写这个后来形成肌肉记忆了——凡是日期字段赋值和比较都先统一格式能少掉一多半的玄学 bug。5.5 用户输入年龄导致 SQL 转换错误现象年龄文本框里输入了非数字点保存直接崩溃。原因Convert.ToInt32(txtAge.Text) 遇到非数字字符串抛出格式异常。解决不要用 Convert.ToInt32用 int.TryParse 并在失败时提示用户或者给 TextBox 挂上 KeyPress 事件限制输入为数字。这一条属于基础但高发的错误项目评审时经常被问到改动成本很低建议直接从第一天就写好。5.6 重新打开窗体时重复加载导致界面闪烁现象主窗体打开子窗体后关闭第二次打开时界面加载明显变慢甚至出现拖影。原因每次 Open 时都重新实例化窗体并且重新查询数据没有做缓存。解决主窗体里用单例模式持有子窗体实例关闭时只是 Hide 而不是 Dispose。这种写法在这个项目里有两个好处一是切换菜单时数据加载速度明显提升二是避免频繁 new Form 造成的句柄泄漏。代价是窗体常驻内存但在单机系统里这个代价完全可接受。6. 进阶修改把三层架构和 SQL 助手类用进这个系统读完上面这些代码你会发现这套系统最值得下手的改进点是抽出两个公共类一个是数据库操作封装另一个是当前登录用户会话。大多数网上下载到的项目版本里这两块代码是散落在各个窗体里的每次用到就连一次数据库代码冗余非常严重。把这块抽出来的过程恰好是初学阶段理解三层架构的最好路径。数据库助手类常见的最小实现长这样public static class DBHelper { public static string ConnStr ConfigurationManager.ConnectionStrings[HospitalDB].ConnectionString; public static DataTable Query(string sql, params SqlParameter[] parameters) { using (SqlConnection conn new SqlConnection(ConnStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) using (SqlDataAdapter da new SqlDataAdapter(cmd)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); DataTable dt new DataTable(); da.Fill(dt); return dt; } } public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (SqlConnection conn new SqlConnection(ConnStr)) using (SqlCommand cmd new SqlCommand(sql, conn)) { if (parameters ! null) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteNonQuery(); } } }这个 DBHelper 抽出来之后所有窗体的数据库代码可以缩短一半以上而且每个方法自带连接释放不再出现忘记 Close 的问题。调用方式变成单行DBHelper.Query(SELECT * FROM Dept_Info) 直接拿到 DataTable。这套代码写完以后再回去看之前写的登录逻辑、挂号逻辑都会明显感觉到清爽很多这也正是不少开发者经历的进阶过程——先功能跑通再想怎么组织代码而不是一上来就搬多层架构。当前登录用户会话的封装也一样把 Login 窗体和主窗体里到处传的用户信息抽成 CurrentUser 静态类不但代码更干净也为后续做操作日志埋下了基础。如果想让这套系统看起来更像生产级可以再加一张操作日志表把每次挂号、退号、改密操作都记录进去这个功能本身不难但对系统的完整性提升很明显。把号源序号生成从 COUNT 改成独立计数表、把密码存储从 MD5 升级成加盐哈希、把每个窗体的数据访问换成 DBHelper 调用——这三步做完这套系统的代码质量会有质的提升。我每次拿到这类项目做代码审查时都会优先看这三个点前两个是安全底线第三个是维护效率。项目本来就是拿来学习和二次开发的从能跑进化到好改这段路本身就是最值得花时间的部分。希望这些记录你能用得上遇到具体问题也欢迎回来对照着看。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑