简介:面向医疗信息化初学者与WinForm开发者的一套医疗设备管理系统源码与配套文档,围绕医院设备台账、合同、配件、维修及统计打印等业务展开,可用于课程设计、毕业设计或小型医院管理系统的二次开发参考。压缩包共155个文件,约12.32MB,包含cs源码、dll程序集、resx/resources资源、config配置、exe程序、mdb数据库以及需求文档与操作手册等,覆盖界面资源、数据库到部署安装的完整结构。已有778人学习,在同类医疗管理示例中有参考价值。读者可据此梳理设备信息管理、合同管理、配件管理、维修管理和信息统计等模块的实现思路,结合内置管理员与普通账号进入系统,并通过需求文档和操作手册了解业务流程、功能边界与部署方式,为改造成医院设备管理项目或完成相关课题提供可复用基础。
1. 从一台挂号窗口的老机器说起:WinForm 医疗管理系统能干什么
有人抱着一套 C# 写的医疗管理系统 WinForm 版本找我,说客户想在门诊挂号旁边加一块设备借用登记,他打开解决方案看到二十多个 csproj 就停在原地了。这类 C/S 架构的医院系统在国内中小型机构里存量极大:桌面端双击即用,不用配浏览器,打印机、扫码枪、串口设备插上就能调,不少基层门诊、体检中心和专科诊所至今还在跑。
它适合两类人。一是接手遗留系统、需要先把业务链路读懂再动需求的开发者;二是想拿一个完整的 WinForm 三层架构项目练手的人。功能覆盖登录权限、门诊挂号、收费结算、药品库存、医疗设备管理和报表打印,数据落在 SQL Server。下面按工程结构、核心链路、设备模块、运行排查、二次开发这条线讲。
2. 工程结构解剖:一份 WinForm 医疗管理系统的解决方案目录
先看骨架,再看业务。这套医疗管理系统 WinForm 版本的组织方式是很典型的四层加公共库,把界面和 SQL 彻底隔开。如果你以前只写过单窗体小工具,第一次看到这么多项目会觉得被拆碎了,但这恰好是二次改需求时最省钱的地方。
2.1 三层架构与项目引用关系
引用方向必须严格单向,这是整套代码能维护下去的前提:UI 引用 BLL 和 Model,BLL 引用 DAL 和 Model,DAL 引用 Model,Common 谁都能引用,反过来一律不允许。我在项目里见过 UI 直接 new SqlConnection 的写法,一个挂号窗体里塞了四十行 SQL,后面要改表结构就是血泪经验。
MediManager.sln ├── MediManager.Model/ # 实体类,只有属性,没有任何依赖 │ ├── UserInfo.cs │ ├── RegisterInfo.cs │ └── DeviceInfo.cs ├── MediManager.DAL/ # 数据访问,唯一允许出现 SqlCommand 的地方 │ ├── SqlHelper.cs # 连接、参数化、事务的统一入口 │ ├── UserDAL.cs │ └── DeviceDAL.cs ├── MediManager.BLL/ # 业务规则,例如"号源是否已满""设备能否借出" │ ├── UserBLL.cs │ └── DeviceBLL.cs ├── MediManager.Common/ # 配置读取、MD5、日志、Excel 导出 │ ├── AppConfig.cs │ └── Md5Helper.cs ├── MediManager.UI/ # 窗体,只负责取值、调 BLL、渲染结果 │ ├── LoginForm.cs │ ├── RegisterForm.cs │ └── DeviceManageForm.cs └── MediManager.Report/ # RDLC 报表定义与打印封装Model 层保持"纯净"很关键:它不引用任何东西,所以你把这个 DLL 丢给报表项目、WebAPI 项目都能直接用,这也是后来给这套系统补一个管理后台接口时最省事的地方。Common 里我一般会放一个 Logger,WinForm 没有全局异常页,出问题全靠日志定位,缺了它排查起来就是黑匣子。
BLL 层要写校验,不要只做转发。很多源码里的 BLL 只是return dal.GetList();,等于白拆一层。合格的写法是:查重、状态判断、权限判断都在 BLL,DAL 只关心怎么把 SQL 跑对。
2.2 数据库表结构与实体类映射
表设计看几张核心的就够判断整体水平。患者、挂号、收费、设备、借用记录这几张表通常构成主干,字典表负责科室、性别、支付方式这类枚举。
CREATE TABLE DeviceInfo ( DeviceId INT IDENTITY(1,1) PRIMARY KEY, DeviceCode NVARCHAR(32) NOT NULL UNIQUE, -- 设备编号,扫码枪扫的就是这个 DeviceName NVARCHAR(64) NOT NULL, DeptId INT NOT NULL, -- 所属科室,关联 DeptInfo Status TINYINT NOT NULL DEFAULT 1, -- 1在库 2借出 3维修 4报废 PurchaseDate DATETIME NULL, Price DECIMAL(12,2) NULL, Remark NVARCHAR(200) NULL ); CREATE TABLE DeviceBorrowRecord ( RecordId INT IDENTITY(1,1) PRIMARY KEY, DeviceId INT NOT NULL, BorrowEmp NVARCHAR(32) NOT NULL, -- 借用人 BorrowTime DATETIME NOT NULL, ExpectBack DATETIME NULL, BackTime DATETIME NULL, -- 为空表示未归还 AuditStatus TINYINT NOT NULL DEFAULT 0 -- 0待审 1通过 2驳回 );实体类跟表一一对应,属性名建议和列名保持一致,别搞Name对DeviceName的映射,后期维护时找不到字段。
// MediManager.Model/DeviceInfo.cs public class DeviceInfo { public int DeviceId { get; set; } public string DeviceCode { get; set; } public string DeviceName { get; set; } public int DeptId { get; set; } /// <summary>1在库 2借出 3维修 4报废,业务判断只看这个枚举</summary> public byte Status { get; set; } public DateTime? PurchaseDate { get; set; } public decimal? Price { get; set; } public string Remark { get; set; } }注意Status用byte而不是int,和TINYINT对齐,省得每次取值都要强转。日期字段一律用可空类型DateTime?,因为采购日期在录入时经常留空,用非空类型会在DataReader读取时抛SqlNullValueException。
2.3 配置文件与第三方依赖
连接字符串放在App.config,不要写死在代码里,这是换机器部署时第一个翻车点。
<configuration> <connectionStrings> <add name="SqlConn" connectionString="Data Source=.;Initial Catalog=MediDB;User ID=sa;Password=123456;Connect Timeout=15" providerName="System.Data.SqlClient" /> </connectionStrings> <appSettings> <add key="ReportPath" value="Reports\" /> <add key="PageSize" value="20" /> </appSettings> </configuration>读取封装在Common/AppConfig.cs,其他地方只调AppConfig.ConnStr。第三方依赖一般这几样:NPOI做 Excel 导出(设备台账、收费日报都要),Newtonsoft.Json做配置序列化或接口对接,Microsoft.ReportingServices.ReportViewerControl.WinForms承载 RDLC 报表。NuGet 还原不下来的情况在老项目里很常见,常见做法是把packages目录一起打包进源码,虽然土,但能保证拿到就能编译。
提示:
Data Source=.只适用于本机默认实例,换成.\SQLEXPRESS或127.0.0.1,1433都行,但端口实例名写错的表现是连接超时而不是报错,排查时先确认实例名。
3. 核心业务链路:从挂号到收费的一次完整请求
理解了分层,再顺着一个患者从进门诊到缴费的路径走一遍,基本就抓住了这套医疗管理系统的业务主干。这一章拆登录、挂号、收费三段,每段都是可以直接抄改的代码结构。
3.1 登录与角色权限校验
登录窗体只做三件事:取输入、调 BLL、把当前用户挂到全局上下文。密码在前端做一次 MD5,后端比对密文,明文不落库。
// MediManager.UI/LoginForm.cs private void btnLogin_Click(object sender, EventArgs e) { string account = txtAccount.Text.Trim(); string pwd = txtPwd.Text.Trim(); if (string.IsNullOrEmpty(account) || string.IsNullOrEmpty(pwd)) { lblTip.Text = "账号和密码不能为空"; return; } try { // UI 层不碰 SqlConnection,只调业务层 UserInfo user = new UserBLL().Login(account, Md5Helper.Encrypt(pwd)); if (user == null) { lblTip.Text = "账号或密码错误"; return; } AppContext.CurrentUser = user; // 全局上下文,主窗体按 RoleId 控制菜单 new MainForm().Show(); this.Hide(); } catch (Exception ex) { Logger.Error("登录异常", ex); // 写日志,不把堆栈甩给用户 MessageBox.Show("登录失败,请联系管理员"); } }AppContext.CurrentUser是个静态属性,主窗体在Load事件里根据RoleId遍历菜单项控制可见性。这种做法的好处是改权限不用动业务代码,坏处是静态变量在多窗口之间会被互相污染,所以只存只读信息(用户 ID、姓名、角色),不要往里塞业务数据。
3.2 门诊挂号与号源分配
挂号的核心是并发下的号源分配。两个人同时点"挂号",如果先SELECT MAX(No)再INSERT,很容易出重号。正确姿势是把"取号 + 插入 + 扣减余量"放进同一个事务,并在取号时加锁。
// MediManager.DAL/RegisterDAL.cs public int AddRegister(RegisterInfo info) { using (SqlConnection conn = new SqlConnection(AppConfig.ConnStr)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) { try { // 1. 取当前医生当天最大号,UPDLOCK 防止并发读到同一个值 string sqlNo = @"SELECT ISNULL(MAX(QueueNo),0) + 1 FROM RegisterInfo WITH (UPDLOCK, ROWLOCK) WHERE DoctorId = @DoctorId AND CONVERT(DATE, RegisterTime) = CONVERT(DATE, @Now)"; SqlCommand cmd = new SqlCommand(sqlNo, conn, tran); cmd.Parameters.AddWithValue("@DoctorId", info.DoctorId); cmd.Parameters.AddWithValue("@Now", DateTime.Now); int queueNo = Convert.ToInt32(cmd.ExecuteScalar()); // 2. 插入挂号记录 string sqlInsert = @"INSERT INTO RegisterInfo (PatientId, DoctorId, DeptId, QueueNo, RegisterTime, Fee, Status) VALUES (@PatientId, @DoctorId, @DeptId, @QueueNo, @RegisterTime, @Fee, 1)"; cmd = new SqlCommand(sqlInsert, conn, tran); cmd.Parameters.AddWithValue("@PatientId", info.PatientId); cmd.Parameters.AddWithValue("@DoctorId", info.DoctorId); cmd.Parameters.AddWithValue("@DeptId", info.DeptId); cmd.Parameters.AddWithValue("@QueueNo", queueNo); cmd.Parameters.AddWithValue("@RegisterTime", DateTime.Now); cmd.Parameters.AddWithValue("@Fee", info.Fee); cmd.ExecuteNonQuery(); tran.Commit(); return queueNo; } catch { tran.Rollback(); throw; } } } }UPDLOCK是关键参数,它让并发事务在读取时持有更新锁,第二个请求会排队等第一个提交,号就不会重。ROWLOCK限制锁粒度,避免整表锁影响其他医生。如果不加锁,实测高峰期重号概率能到千分之几,属于典型的玄学问题——平时测不出来,业务一忙就炸。
3.3 收费结算与事务提交
收费涉及三张表:收费主表、收费明细、挂号或医嘱状态回写。这三步必须原子完成,中途失败要整体回滚,否则会出现"钱收了但医嘱没结算"的脏数据。
// MediManager.BLL/ChargeBLL.cs public bool Settle(int registerId, List<ChargeItem> items, decimal total) { if (items == null || items.Count == 0) throw new ArgumentException("收费明细不能为空"); if (items.Sum(x => x.Amount) != total) throw new ArgumentException("明细金额与合计不一致"); // BLL 拦一道,防止界面算错 return new ChargeDAL().Settle(registerId, items, total); }BLL 这一层的价值在这里体现得最明显:界面计算可能因为浮点误差、手工改价导致合计对不上,业务层先校验一遍,比等数据库约束报错友好得多。DAL 里再开事务,先写主表拿到ChargeId,再批量插明细,最后UPDATE RegisterInfo SET Status = 2,任何一步ExecuteNonQuery()返回 0 都直接Rollback并抛出业务异常。
4. 医疗设备模块:台账、借用归还与状态流转
医院系统里设备管理常被忽略,但它是中小机构真正的高频需求:谁借了哪台监护仪、什么时候还、有没有到期未还。这一章讲设备台账、状态机和外部设备接入。
4.1 设备台账的查询与分页封装
台账页面的数据量通常不大(几百到几千台),但查询条件多:设备名、编号、科室、状态。用ROW_NUMBER()分页在 SQL Server 2008 及以上都能跑,兼容性最好。
-- 按条件分页查询设备台账,@start/@end 由 BLL 根据页码算出 SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY DeviceId DESC) AS RowNum, * FROM DeviceInfo WHERE (@DeviceName = '' OR DeviceName LIKE '%' + @DeviceName + '%') AND (@DeptId = 0 OR DeptId = @DeptId) AND (@Status = 0 OR Status = @Status) ) t WHERE t.RowNum BETWEEN @start AND @end; -- 单独一条统计总数,供分页控件算总页数 SELECT COUNT(1) FROM DeviceInfo WHERE (@DeviceName = '' OR DeviceName LIKE '%' + @DeviceName + '%') AND (@DeptId = 0 OR DeptId = @DeptId) AND (@Status = 0 OR Status = @Status);这种"空值当通配"的写法能让界面少写一堆 if,代价是索引可能失效。设备量上万时建议改成动态拼接 WHERE,我一般会在 BLL 里判断条件数量,超过两个就切动态 SQL。
4.2 借用归还的状态机实现
设备状态只有四个值,但流转规则必须严格:只有"在库"能借出,只有"借出"能归还,维修中的设备不能被借。把这些规则写成一个方法集中管理,别散落在各个按钮事件里。
// MediManager.BLL/DeviceBLL.cs public bool Borrow(int deviceId, string emp, DateTime expectBack) { DeviceInfo d = new DeviceDAL().GetById(deviceId); if (d == null) throw new Exception("设备不存在"); if (d.Status != 1) throw new Exception("当前设备不是在库状态,无法借出"); using (SqlConnection conn = new SqlConnection(AppConfig.ConnStr)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) { try { // 主表状态改为借出,记录表插入一条待归还 new DeviceDAL().UpdateStatus(conn, tran, deviceId, 2); new DeviceDAL().InsertBorrow(conn, tran, deviceId, emp, expectBack); tran.Commit(); return true; } catch { tran.Rollback(); throw; } } } }判断顺序很重要:先查存在性,再查状态,最后开事务。如果先开事务再查,异常路径多,回滚点容易漏。归还的方法结构一样,额外做一件事:如果BackTime超过ExpectBack,在返回值里带一个"逾期天数",界面用红色提示。
4.3 串口采集与报表打印
医疗设备管理系统的外设接入主要是扫码枪和电子秤,扫码枪多数是键盘模拟输入,插上就能往文本框里打字,不需要额外代码;电子秤走串口,需要SerialPort读取。
// 串口读取电子秤重量,协议为连续输出,每帧以 \r\n 结尾 private SerialPort _port; private void InitScale(string portName) { _port = new SerialPort(portName, 9600, Parity.None, 8, StopBits.One); _port.DataReceived += Port_DataReceived; _port.Open(); } private void Port_DataReceived(object sender, SerialDataReceivedEventArgs e) { string raw = _port.ReadLine(); // 阻塞到换行符 if (decimal.TryParse(raw.Trim(), out decimal weight)) { // 跨线程更新界面必须用 Invoke,直接赋值会抛跨线程异常 this.Invoke(new Action(() => txtWeight.Text = weight.ToString("F2"))); } }报表用 RDLC,数据源直接喂DataTable,注意ReportViewer的LocalReport.DataSources每次刷新都要重新Clear()再Add(),否则第二次打印会报"数据源名称重复"。
5. 跑起来与避坑:环境还原、连接配置与高频翻车排查
代码看懂是一回事,能跑起来是另一回事。这一章按顺序把环境、数据库、编译、运行四个环节过一遍,再列五条我实际踩过的坑。
5.1 环境准备与数据库还原
需要的东西不多:Visual Studio 2019 或 2022(装 .NET 桌面开发工作负载)、.NET Framework 4.7.2 或 4.8 运行时、SQL Server 2016 以上(Express 版够用)、SSMS 用来还原备份。如果项目里有 RDLC 报表,还要额外装Microsoft Report Viewer运行时,缺了它编译能过,一打开报表窗体就闪退。
数据库还原步骤:SSMS 里右键"数据库"→"还原数据库"→选.bak文件→在"选项"里勾上"覆盖现有数据库"→确认文件路径存在。还原完检查一下MediDB的兼容级别,低版本备份还原到高版本实例上有时会卡在"正在还原"。
5.2 编译、调试与发布
用 VS 打开.sln后先做一次"还原 NuGet 包",然后"重新生成解决方案"。如果报某个packages路径找不到,说明源码包里带了packages目录但.csproj里的HintPath写的是绝对路径,这种情况在csproj里全局替换一下路径前缀即可。
发布用"生成"→"发布",目标框架选net472,部署模式选框架依赖。别选 ClickOnce 除非你确实需要自动更新,本地部署直接拷Release目录更省心。发布后注意Reports文件夹和App.config要跟着 exe 一起走。
5.3 五类高频故障:现象、原因与解决
第一条:登录时提示"未将对象引用设置到对象的实例"。现象是点了登录没反应,弹窗只有一句空引用。原因通常是AppConfig.ConnStr返回null——App.config里connectionStrings的name和代码里读的 key 不一致。解决是把两处名字对齐,并且在AppConfig里加一句判空抛明确异常,别让 null 一路传下去。
第二条:换一台电脑后所有查询都超时。现象是界面卡十几秒后报"连接超时"。原因多半是连接字符串里的Data Source还写着开发机的机器名,或者 SQL Server 没开 TCP/IP 协议。解决是改成127.0.0.1,1433并在 SQL Server 配置管理器里启用 TCP/IP、重启服务。
第三条:报表窗体一打开就闪退,事件查看器里是FileNotFoundException。现象是编译无错、运行崩溃。原因是 RDLC 报表文件没被设为"复制到输出目录",或者报表路径写成了绝对路径。解决是把.rdlc的生成操作设为"内容"、复制到输出目录设为"始终复制",代码里用Path.Combine(Application.StartupPath, "Reports")拼路径。
第四条:导出 Excel 时提示"未能加载文件或程序集 ICSharpCode.SharpZipLib"。现象是点导出就报程序集找不到。原因是 NPOI 的依赖版本和packages里的 DLL 版本对不上,常见于从别人那里拷来的源码包。解决是删掉bin和obj,在 NuGet 里重新安装同版本 NPOI,让它自动带齐依赖。
第五条:连续挂号出现重号。现象是上午十点后偶发两个患者拿到同一个号。原因就是 3.2 里说的没加UPDLOCK,SELECT MAX读到的是脏值。解决是按 3.2 的事务写法加锁,并在数据库上给(DoctorId, RegisterTime)建唯一索引兜底。
注意:改完
App.config之后要重新生成,直接按 F5 有时 VS 用的是旧的bin目录配置,改了等于没改。
6. 二次开发改造:从能跑到能用的几个动手点
拿到这套医疗管理系统 WinForm 版本之后,最典型的改造需求是"设备借用要加审批"。原始流程是借用人直接点了就借出,实际机构里往往需要科室主任点头。这个改造涉及四个位置,我在下面这张表里列清楚,避免东改一处西改一处。
| 改造点 | 文件位置 | 具体动作 |
|---|---|---|
| 数据库 | DeviceBorrowRecord表 | 已有AuditStatus字段,确认默认值 0 |
| DAL | DeviceDAL.cs | 新增AuditBorrow(conn, tran, recordId, status) |
| BLL | DeviceBLL.cs | Borrow里状态从 2 改为 0(待审),审批通过才改 2 |
| UI | DeviceBorrowForm.cs | 借用按钮文案改「提交申请」,新增审批列表页 |
状态机的改动最容易翻车,因为原来的Borrow方法直接把设备状态改成"借出",加了审批之后必须拆成两步:提交申请只写记录不动设备状态,审批通过才动设备。这意味着在审批前,同一台设备可能被多人申请,所以审批时要再查一次设备状态。
// 审批通过:必须重新校验设备状态,因为申请提交后设备可能已被别的流程占用 public bool ApproveBorrow(int recordId, int auditorId) { using (SqlConnection conn = new SqlConnection(AppConfig.ConnStr)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) { try { // 带锁复查状态,防止两个审批同时通过 DeviceInfo d = new DeviceDAL().GetByIdForUpdate(conn, tran, recordId); if (d == null || d.Status != 1) { tran.Rollback(); return false; } new DeviceDAL().AuditBorrow(conn, tran, recordId, 1, auditorId); new DeviceDAL().UpdateStatus(conn, tran, d.DeviceId, 2); tran.Commit(); return true; } catch { tran.Rollback(); throw; } } } }改造完怎么验证?别只点一次界面就交差。我一般会写一个小的控制台程序,起 20 个线程同时调Borrow再同时调ApproveBorrow,跑三轮看设备状态和记录数是否对得上;再手动造一条逾期记录,确认红色提示和报表统计都能对上。这套"并发 + 边界数据"的验证习惯,是从一次线上重号事故之后养成的,从那以后我每次动状态机都强制走一遍,哪怕只是改个枚举值。
代码量不大,但每处改动都得跟数据库字段、界面文案、报表口径对齐,这才是接手遗留系统真正的成本。希望帮到你。
本文还有配套的精品资源,点击获取