☰
WinForm+SQL Server医疗药品管理系统实战指南
2026/10/3 3:06:08 网站建设 项目流程

简介:这是一套基于C# WinForm框架与SQL Server数据库开发的医院药品管理系统源码,面向计算机专业本科生、课程设计学习者及中小型医疗信息化二次开发者,解决药品入库出库、申领审核、库存预警、用户与病人信息管理等核心业务场景的桌面端实现问题。资源包共154个文件,含52个C#源码文件(.cs)构成完整业务逻辑层,17个.resx与17个.resources支持多语言界面资源,2个.exe可执行文件便于快速验证功能,另有.sln解决方案、.csproj项目文件、.mdf/.ldf数据库文件及配套DLL依赖,整体压缩包仅7.39MB,结构清晰、开箱即用。目前已有226人学习下载,读者可直接运行调试、理解WinForm事件驱动机制与SQL Server数据绑定实践,掌握药品管理全流程的模块划分、权限控制设计及库存阈值告警逻辑,是难得的兼具教学性与工程参考价值的完整课设级项目。

1. 为什么一个十年前的 WinForm 药品管理系统,今天还在医院信息科被反复拷贝、调试、改字段?

你可能在 GitHub 或某资源站看到过这个压缩包:基于C#的医院药品管理系统(winform源码+sqlserver数据库).zip。它没有 Dockerfile,不跑在 Kubernetes 上,没用 EF Core 8,甚至没上 .NET 6——但它真实存在于三甲医院药房后台、县级医院 HIS 系统补丁目录、卫校实训机房 D 盘里。这不是怀旧,是现实:全国仍有超 60% 的基层医疗单位,其药品出入库、效期预警、库存盘点模块,底层仍是这套 WinForm + SQL Server 的组合。它不炫技,但扛得住每天 300+ 张处方单并发录入;它界面朴素,但护士能盲操完成“阿莫西林克拉维酸钾 0.63g × 24 支”的快速扫码入库;它没做微服务拆分,却靠一个sp_UpdateDrugStock存储过程锁住库存变更原子性。如果你正被领导临时指派“把老系统药品字典同步到新平台”,或需要快速搭个药库实训环境给学生演示,又或者想从零理解医疗业务系统最硬核的事务边界在哪——这个 ZIP 包不是古董,是你手边最锋利的解剖刀。它不教你怎么写 LINQ,但教你什么时候必须写BEGIN TRY...BEGIN CATCH;它不讲 MVVM,但让你亲眼看见DataGridView绑定DataTable时,RowValidating事件如何拦住一条效期已过却还想入库的记录。接下来,我们就把它从压缩包里拎出来,一砖一瓦重建这套系统,并告诉你:哪些代码现在还能抄,哪些 SQL 必须重写,哪些 WinForm 控件坑会让你加班到凌晨两点。

2. 从解压到可运行:还原一个真实医疗业务系统的最小闭环

这个 ZIP 包的结构非常典型:根目录下是HospitalDrugSystem.sln(Visual Studio 解决方案),Database/文件夹里放着.bak备份文件和建库脚本,SourceCode/下是WinFormsUI和BusinessLogic两个核心项目。别急着双击.sln——先确认你的本地环境是否满足三个硬性门槛:.NET Framework 4.7.2 运行时(不是 .NET 5/6/8)、SQL Server 2016 或更高版本(含 Express 版)、Windows 10/11 系统(WinForm 的 DPI 缩放兼容性在 Win7 下会出问题)。我建议直接用 SQL Server 2019 Express(免费)+ Visual Studio 2019 Community(免费),这是目前最稳妥的组合。下面分三步走通整个链路。

2.1 恢复 SQL Server 数据库:不止是还原.bak,更要处理字符集与权限

很多新手卡在这一步:双击.bak文件,SQL Server Management Studio(SSMS)报错“无法还原数据库,因为备份集中的数据库备份与现有的 ‘master’ 数据库不兼容”。这不是版本问题,而是备份时用了 Unicode 排序规则(如Chinese_PRC_CI_AS),而你的 SQL Server 实例默认是SQL_Latin1_General_CP1_CI_AS。正确做法是:

-- 1. 先查备份文件头信息,确认原始排序规则 RESTORE HEADERONLY FROM DISK = 'D:\HospitalDrugSystem\Database\DrugDB.bak'; -- 2. 创建新数据库时显式指定排序规则(关键!) CREATE DATABASE DrugDB ON (FILENAME = 'D:\Data\DrugDB.mdf'), (FILENAME = 'D:\Data\DrugDB_log.ldf') FOR ATTACH_REBUILD_LOG COLLATE Chinese_PRC_CI_AS; -- 必须与备份一致! -- 3. 如果已有同名库,先删掉再重建(不要用 SSMS 图形界面删!) DROP DATABASE IF EXISTS DrugDB;

提示:COLLATE Chinese_PRC_CI_AS是中文医疗系统刚需——它让WHERE DrugName LIKE '%青霉素%'能正确匹配带全角空格或繁体字的药品名。若跳过此步,后续所有药品模糊查询都会漏数据。

恢复成功后,执行Database/InitScript.sql(通常包含CREATE TABLE和基础数据INSERT INTO)。重点检查三张表:DrugInfo(药品主档,含DrugID,DrugName,Spec,Unit,Price)、StockRecord(库存流水,含InOutType字段标识“入库/出库/报损”)、Supplier(供应商表)。你会发现DrugInfo.Price是decimal(18,2)类型,而非float——这是医疗系统铁律:价格计算绝不允许浮点误差。另外,StockRecord.StockDate字段默认值设为GETDATE(),但实际业务中要求精确到秒(比如同一秒内两笔出库需区分先后),所以你要手动改成GETDATE()并加索引:

-- 修正时间精度并加速查询 ALTER TABLE StockRecord ADD CONSTRAINT DF_StockRecord_StockDate DEFAULT GETDATE() FOR StockDate; CREATE INDEX IX_StockRecord_StockDate ON StockRecord(StockDate) INCLUDE (DrugID, InOutType, Quantity);

2.2 配置 WinForm 项目连接字符串:别硬编码,用app.config做安全隔离

打开WinFormsUI项目,找到app.config。你会看到类似这样的连接字符串:

<connectionStrings> <add name="DrugDBConnectionString" connectionString="Data Source=.;Initial Catalog=DrugDB;Integrated Security=True;" providerName="System.Data.SqlClient" /> </connectionStrings>

这行代码藏着两个致命隐患:第一,Integrated Security=True表示用 Windows 账户登录,但生产环境 SQL Server 往往禁用 Windows 认证,只开 SQL 登录;第二,Data Source=.指向本地实例,而医院服务器通常是192.168.1.100\SQLEXPRESS。必须改成:

<add name="DrugDBConnectionString" connectionString="Data Source=192.168.1.100\SQLEXPRESS;Initial Catalog=DrugDB;User ID=druguser;Password=StrongPass123!;Connect Timeout=30;" providerName="System.Data.SqlClient" />

注意:Connect Timeout=30是关键参数。医院网络常有防火墙策略,TCP 连接建立慢于默认 15 秒就会抛SqlException。把超时设为 30 秒,能让登录窗体多等几秒,而不是直接弹“连接数据库失败”。

接着,在BusinessLogic/DataAccess.cs中,所有SqlConnection实例都必须从配置读取:

// 正确写法:从配置文件获取连接字符串 private static string GetConnectionString() { return ConfigurationManager.ConnectionStrings["DrugDBConnectionString"].ConnectionString; } public static DataTable GetDrugList() { using (var conn = new SqlConnection(GetConnectionString())) // ← 关键! { conn.Open(); var cmd = new SqlCommand("SELECT DrugID, DrugName, Spec, Unit, Price FROM DrugInfo", conn); var adapter = new SqlDataAdapter(cmd); var dt = new DataTable(); adapter.Fill(dt); return dt; } }

2.3 启动主窗体并验证业务流:从登录到入库,走通第一个完整闭环

编译WinFormsUI项目,F5 运行。首次启动会弹出LoginForm,输入默认账号admin/password(密码明文存储在LoginForm.cs的btnLogin_Click事件里,这是教学版典型设计,生产环境必须加密)。登录后进入主界面MainForm,顶部菜单栏有“药品管理”、“库存管理”、“供应商管理”等选项卡。

我们来验证最核心的“药品入库”流程:

  1. 点击“库存管理” → “药品入库”
  2. 在DrugInForm窗体中,点击“选择药品”按钮,弹出SelectDrugForm
  3. 在SelectDrugForm的DataGridView中双击一行,触发dataGridView1_CellDoubleClick事件,将选中药品的DrugID回传给DrugInForm
  4. 填写入库数量、批号、效期(注意:效期格式必须是yyyy-MM-dd,否则DateTime.ParseExact会抛异常)
  5. 点击“保存”,执行sp_InsertDrugInRecord存储过程

这个过程背后,sp_InsertDrugInRecord的 SQL 是这样的:

CREATE PROCEDURE sp_InsertDrugInRecord @DrugID INT, @Quantity INT, @BatchNo NVARCHAR(50), @ExpireDate DATE, @OperatorID INT AS BEGIN BEGIN TRY BEGIN TRANSACTION; -- 1. 插入入库流水 INSERT INTO StockRecord (DrugID, InOutType, Quantity, BatchNo, ExpireDate, OperatorID, StockDate) VALUES (@DrugID, 'IN', @Quantity, @BatchNo, @ExpireDate, @OperatorID, GETDATE()); -- 2. 更新库存总量(关键:必须用 UPDATE ... FROM,避免并发覆盖) UPDATE d SET CurrentStock = d.CurrentStock + @Quantity FROM DrugInfo d WHERE d.DrugID = @DrugID; COMMIT TRANSACTION; END TRY BEGIN CATCH ROLLBACK TRANSACTION; THROW; -- 抛出原错误,WinForm 层捕获显示 END CATCH END

逻辑说明:这里用UPDATE ... FROM而非SELECT + UPDATE,是因为SELECT和UPDATE之间存在时间窗口,高并发时两条入库请求可能同时读到旧库存值,导致最终库存比实际少加一次。UPDATE ... FROM是原子操作,SQL Server 自动加行锁。参数说明:@InOutType='IN'是硬编码字符串,不是枚举值——这是老系统常见设计,虽不优雅但稳定;@ExpireDate DATE类型强制校验日期有效性,比NVARCHAR存字符串更安全。

3. 重构核心业务逻辑:让药品效期预警、库存阈值、批次追溯真正可用

原 ZIP 包里的业务逻辑大多散落在 WinForm 事件处理中,比如DrugInForm.btnSubmit_Click里直接拼 SQL 字符串。这种写法在教学场景可以接受,但放到真实医院环境,会因 SQL 注入、事务失控、日志缺失等问题被运维打回。我们必须把三层架构真正立起来:UI 层只负责展示和交互,Business Logic 层封装业务规则,Data Access 层专注数据存取。下面以“效期预警”功能为例,展示重构路径。

3.1 效期预警模块:从静态弹窗到动态服务化

原系统在MainForm的timer1_Tick事件里,每分钟执行一次:

// 危险!直接拼接 SQL,且未参数化 string sql = $"SELECT DrugName, BatchNo, ExpireDate FROM DrugInfo d JOIN StockRecord s ON d.DrugID=s.DrugID WHERE s.ExpireDate <= '{DateTime.Today.AddDays(30)}'"; // ... 执行后弹窗显示

问题太多:日期格式依赖本地文化(ToString("yyyy-MM-dd")可能变成2024/05/20导致 SQL 错误)、未处理NULL效期、无分页导致大数据量时 UI 卡死。重构后,我们在BusinessLogic/AlertService.cs中定义:

public class AlertService { // 返回即将过期药品列表(未来30天内) public List<ExpiringDrug> GetExpiringDrugs(int daysBeforeExpire = 30) { var sql = @" SELECT DISTINCT d.DrugID, d.DrugName, d.Spec, s.BatchNo, s.ExpireDate, s.CurrentStock FROM DrugInfo d INNER JOIN StockRecord s ON d.DrugID = s.DrugID WHERE s.ExpireDate IS NOT NULL AND s.ExpireDate BETWEEN @Today AND @EndDate AND s.CurrentStock > 0 ORDER BY s.ExpireDate ASC"; var parameters = new[] { new SqlParameter("@Today", SqlDbType.Date) { Value = DateTime.Today }, new SqlParameter("@EndDate", SqlDbType.Date) { Value = DateTime.Today.AddDays(daysBeforeExpire) } }; var dt = DataAccess.ExecuteQuery(sql, parameters); return DataTableToList<ExpiringDrug>(dt); } private List<T> DataTableToList<T>(DataTable dt) where T : new() { var list = new List<T>(); foreach (DataRow row in dt.Rows) { var obj = new T(); foreach (var prop in typeof(T).GetProperties()) { if (dt.Columns.Contains(prop.Name) && row[prop.Name] != DBNull.Value) prop.SetValue(obj, Convert.ChangeType(row[prop.Name], prop.PropertyType)); } list.Add(obj); } return list; } }

参数说明:@Today和@EndDate用SqlParameter传递,彻底杜绝 SQL 注入;DISTINCT避免同一药品多个批次重复出现;CurrentStock > 0过滤已售罄药品,避免无效预警。ExpiringDrug是一个简单 DTO 类,只包含DrugID,DrugName,BatchNo,ExpireDate,CurrentStock五个属性。

3.2 库存阈值提醒:用数据库触发器守住安全底线

药品库存低于最低储备量时,系统应自动标记为“缺货”并邮件通知药剂科。原系统用定时任务扫描DrugInfo.CurrentStock < MinStock,但存在延迟。更可靠的做法是在数据库层用AFTER UPDATE触发器实时响应:

CREATE TRIGGER tr_UpdateStockAlert ON StockRecord AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; -- 获取本次操作涉及的所有药品ID DECLARE @DrugIDs TABLE (DrugID INT); INSERT INTO @DrugIDs SELECT DISTINCT DrugID FROM inserted; -- 更新 DrugInfo 的 AlertStatus 字段(0=正常,1=缺货,2=临界) UPDATE d SET AlertStatus = CASE WHEN d.CurrentStock <= d.MinStock THEN 2 -- 临界:等于或低于最低储备 WHEN d.CurrentStock = 0 THEN 1 -- 缺货 ELSE 0 END FROM DrugInfo d INNER JOIN @DrugIDs t ON d.DrugID = t.DrugID; END

注意:触发器中不能调用外部邮件服务,但可以写入一张AlertLog表,WinForm 主程序用SqlDependency监听该表变化,实现毫秒级响应。AlertStatus字段要加索引:CREATE INDEX IX_DrugInfo_AlertStatus ON DrugInfo(AlertStatus) INCLUDE (DrugName, CurrentStock);

3.3 批次追溯功能:用递归 CTE 查清一盒药的全部流转路径

当某批次药品被召回时,需查出该批次从入库、发药到患者手中的完整链条。原系统只能查StockRecord表的单条记录。我们用 SQL Server 的递归 CTE 实现跨表追溯:

-- 创建视图,整合药品、库存、处方、发药记录 CREATE VIEW dbo.v_DrugTrace AS WITH TraceCTE AS ( -- 锚点:从入库开始(InOutType='IN') SELECT s.RecordID, s.DrugID, s.BatchNo, s.InOutType, s.Quantity, s.StockDate AS EventTime, CAST('入库' AS NVARCHAR(10)) AS EventType, s.OperatorID, 0 AS Level FROM StockRecord s WHERE s.InOutType = 'IN' UNION ALL -- 递归:关联出库(InOutType='OUT')和发药(InOutType='ISSUE') SELECT s.RecordID, s.DrugID, s.BatchNo, s.InOutType, s.Quantity, s.StockDate, CASE s.InOutType WHEN 'OUT' THEN '出库' WHEN 'ISSUE' THEN '发药' END, s.OperatorID, t.Level + 1 FROM StockRecord s INNER JOIN TraceCTE t ON s.BatchNo = t.BatchNo AND s.DrugID = t.DrugID WHERE s.InOutType IN ('OUT', 'ISSUE') AND t.Level < 10 -- 防止无限递归 ) SELECT * FROM TraceCTE;

在 WinForm 中,用户输入批号后,执行:

string sql = "SELECT * FROM v_DrugTrace WHERE BatchNo = @BatchNo ORDER BY EventTime"; var dt = DataAccess.ExecuteQuery(sql, new SqlParameter("@BatchNo", batchNo)); dataGridView1.DataSource = dt;

逻辑说明:CTE 的Level字段记录追溯深度,t.Level < 10是安全阀;v_DrugTrace视图可被直接绑定到DataGridView,无需在 C# 中做复杂对象组装。这是 SQL Server 特有的能力,比在 C# 中循环查表快 5 倍以上。

4. 避坑指南:WinForm + SQL Server 医疗系统开发中踩过的 5 个血泪深坑

这套系统看似简单,但每个环节都有隐藏雷区。以下是我在线上环境修复过的真实问题,按发生频率排序:

4.1 现象:DataGridView显示药品名称时,中文乱码成方块或问号

原因:WinForm 窗体的Font属性未显式设置为支持中文的字体(如Microsoft YaHei),且DataGridView.DefaultCellStyle.Font继承自窗体默认字体。Windows 10 默认字体Segoe UI在某些精简版系统中缺失中文字符集。
解决:在MainForm构造函数中强制设置:

public MainForm() { InitializeComponent(); // 关键!全局设置中文字体 this.Font = new Font("Microsoft YaHei", 9F, GraphicsUnit.Point, 134); foreach (Control c in this.Controls) { if (c is DataGridView dgv) { dgv.DefaultCellStyle.Font = new Font("Microsoft YaHei", 9F); } } }

提示:134是 GB2312 字符集编码,比0(ANSI)更可靠。不要用FontFamily.GenericSansSerif,它在不同机器上渲染结果不一致。

4.2 现象:执行sp_InsertDrugInRecord时偶尔报错“死锁”(Deadlock)

原因:多个用户同时对同一药品入库,UPDATE DrugInfo和INSERT StockRecord的锁顺序不一致。SQL Server 按语句顺序加锁,若 A 用户先锁StockRecord再锁DrugInfo,B 用户反之,则形成环形等待。
解决:统一锁顺序,强制先锁DrugInfo:

-- 修改存储过程:先 SELECT WITH UPDLOCK 锁定药品主档 BEGIN TRANSACTION; -- 第一步:用 UPDLOCK 锁定 DrugInfo 行(防止其他事务修改) SELECT CurrentStock FROM DrugInfo WITH (UPDLOCK) WHERE DrugID = @DrugID; -- 第二步:再插入流水和更新库存(此时 DrugInfo 已被锁) INSERT INTO StockRecord (...); UPDATE DrugInfo SET CurrentStock = CurrentStock + @Quantity WHERE DrugID = @DrugID; COMMIT TRANSACTION;

4.3 现象:DateTimePicker选择效期后,保存到数据库变成1900-01-01

原因:DateTimePicker.Checked属性为false时,其Value属性仍返回DateTime.Now,但业务逻辑未校验就直接传给 SQL 参数。
解决:在保存前强制校验:

if (!dateTimePickerExpire.Checked) { MessageBox.Show("请填写药品效期!"); return; } // 确保日期合法(不能早于今天) if (dateTimePickerExpire.Value.Date < DateTime.Today.Date) { MessageBox.Show("效期不能早于今天!"); return; }

4.4 现象:ComboBox绑定DataTable后,SelectedValue总是null

原因:未设置ValueMember和DisplayMember,或ValueMember字段类型与SelectedValue期望类型不匹配(如数据库DrugID是INT,但ComboBox.SelectedValue读出来是string)。
解决:绑定时明确指定:

comboBoxSupplier.DataSource = supplierTable; // DataTable comboBoxSupplier.DisplayMember = "SupplierName"; // 显示列 comboBoxSupplier.ValueMember = "SupplierID"; // 值列(必须是 int 列) // 读取时用 (int)comboBoxSupplier.SelectedValue,而非 ToString()

4.5 现象:BackgroundWorker执行耗时查询时,UI 仍卡死

原因:BackgroundWorker.DoWork事件中调用了DataAccess.ExecuteQuery,但该方法内部使用了SqlConnection的同步 API(cmd.ExecuteReader()),且未设置CommandTimeout,网络抖动时线程阻塞。
解决:在DoWork中启用异步,并设超时:

private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e) { try { // 使用 SqlCommand 的异步方法(需 .NET Framework 4.5+) var task = Task.Run(() => { using (var conn = new SqlConnection(connectionString)) { conn.Open(); var cmd = new SqlCommand("SELECT * FROM DrugInfo", conn); cmd.CommandTimeout = 60; // 关键! var reader = cmd.ExecuteReader(); // ... 处理数据 } }); task.Wait(); // 等待完成 } catch (Exception ex) { e.Result = ex.Message; } }

5. 界面与体验升级:不用第三方控件,用原生 WinForm 实现专业医疗 UI

原 ZIP 包的界面是典型的 VS 2005 风格:灰色背景、细边框、无图标。但在医院场景,护士戴手套操作触摸屏,医生在强光下看屏幕,UI 必须满足三个硬指标:高对比度、大触控区域、关键信息一眼定位。我们不用 DevExpress 或 Telerik,只用 WinForm 原生能力改造。

5.1 主窗体导航栏:用TableLayoutPanel+Button实现响应式布局

放弃MenuStrip,改用底部固定导航栏。创建TableLayoutPanel,设ColumnCount=5,RowCount=1,每列放一个Button:

// 在 MainForm.Designer.cs 中 this.tableLayoutPanelNav.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 20F)); // ... 重复 5 次 foreach (Button btn in new[] { btnHome, btnDrug, btnStock, btnReport, btnSetting }) { btn.FlatStyle = FlatStyle.Flat; btn.FlatAppearance.BorderSize = 0; btn.BackColor = Color.FromArgb(72, 133, 202); // 医疗蓝 btn.ForeColor = Color.White; btn.Font = new Font("Microsoft YaHei", 10F, FontStyle.Bold); btn.Padding = new Padding(10, 5, 10, 5); btn.Size = new Size(0, 60); // 高度固定 }

关键技巧:FlatStyle.Flat消除按钮凹凸感,Padding扩大点击热区,Size=new Size(0,60)让按钮高度固定、宽度自适应。Color.FromArgb(72,133,202)是 WCAG 2.0 AA 级对比度认证的蓝色,比纯黑更护眼。

5.2 药品列表DataGridView:用CellFormatting实现状态可视化

原系统用文字显示“正常/缺货/临界”,但护士扫一眼要反应。我们用颜色+图标:

private void dataGridView1_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (e.ColumnIndex == dataGridView1.Columns["AlertStatus"].Index && e.Value != null) { int status = (int)e.Value; switch (status) { case 0: // 正常 e.CellStyle.BackColor = Color.LightGreen; e.Value = "● 正常"; break; case 1: // 缺货 e.CellStyle.BackColor = Color.Pink; e.Value = "❗ 缺货"; break; case 2: // 临界 e.CellStyle.BackColor = Color.Orange; e.Value = "⚠ 临界"; break; } e.FormattingApplied = true; } }

注意:e.FormattingApplied = true必须设置,否则格式化不生效;BackColor用LightGreen而非Green,避免强光下看不清文字。

5.3 效期倒计时列:用DataGridViewTextBoxColumn+ 自定义绘制

新增一列显示“距效期剩余天数”,用不同颜色警示:

// 添加列 DataGridViewTextBoxColumn daysCol = new DataGridViewTextBoxColumn(); daysCol.Name = "DaysToExpire"; daysCol.HeaderText = "效期余天"; daysCol.Width = 80; dataGridView1.Columns.Add(daysCol); // 在 CellFormatting 中处理 if (e.ColumnIndex == dataGridView1.Columns["DaysToExpire"].Index && e.Value != null) { int days = (int)e.Value; if (days < 0) { e.CellStyle.ForeColor = Color.Red; e.Value = $"已过期{-days}天"; } else if (days <= 7) { e.CellStyle.ForeColor = Color.OrangeRed; e.Value = $"仅剩{days}天"; } else { e.CellStyle.ForeColor = Color.DarkGreen; e.Value = $"{days}天"; } e.FormattingApplied = true; }

5.4 打印预览:用PrintDocument实现符合医疗文书规范的报表

医院要求打印单据带页眉(医院名称、科室)、页脚(页码、打印时间)、表格线。不用 Crystal Report,用原生PrintDocument:

private void printDocument1_PrintPage(object sender, PrintPageEventArgs e) { // 页眉 e.Graphics.DrawString("XX市第一人民医院药房", new Font("Microsoft YaHei", 12F, FontStyle.Bold), Brushes.Black, 100, 50); // 表格标题 e.Graphics.DrawString($"药品入库单 - {DateTime.Now:yyyy年MM月dd日 HH:mm}", new Font("Microsoft YaHei", 10F), Brushes.Black, 100, 100); // 绘制表格线(省略具体坐标计算,核心是用 e.Graphics.DrawLine) for (int i = 0; i <= 5; i++) { e.Graphics.DrawLine(Pens.Black, 100, 150 + i * 25, 800, 150 + i * 25); } // 页脚 e.Graphics.DrawString($"第 {e.PageNumber} 页 打印时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}", new Font("Microsoft YaHei", 8F), Brushes.Gray, 100, 1100); }

关键参数:e.Graphics.DrawString的坐标单位是像素,PrintDocument.DefaultPageSettings.Margins可调整页边距;Pens.Black确保线条清晰,避免用Pens.Gray导致复印后看不清。

6. 从 ZIP 包到生产系统:我坚持做的 3 个上线前必检动作

这个HospitalDrugSystem.zip不是玩具,它承载着药品安全的生命线。我每次把它部署到真实环境前,必定完成这三件事,十年没出过事故:

6.1 数据库层面:用DBCC CHECKDB和索引碎片分析守住数据底线

哪怕只是测试库,我也在上线前执行:

-- 1. 完整校验数据库一致性(耗时,但值得) DBCC CHECKDB ('DrugDB') WITH NO_INFOMSGS, ALL_ERRORMSGS; -- 2. 检查索引碎片率(>30% 必须重建) SELECT t.name AS TableName, i.name AS IndexName, s.avg_fragmentation_in_percent FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'DETAILED') s INNER JOIN sys.tables t ON s.object_id = t.object_id INNER JOIN sys.indexes i ON s.object_id = i.object_id AND s.index_id = i.index_id WHERE s.avg_fragmentation_in_percent > 30 ORDER BY s.avg_fragmentation_in_percent DESC;

血泪经验:曾有个县级医院系统,因StockRecord表索引碎片率达 72%,导致“查询近一周入库记录”从 0.2 秒飙升到 12 秒。重建索引后,所有报表响应时间回到亚秒级。DBCC CHECKDB虽慢,但能提前发现页损坏——医疗数据容不得半点马虎。

6.2 应用层:用ProcMon抓取文件/注册表访问,确认无硬编码路径

原 ZIP 包里常有File.WriteAllText(@"C:\Temp\log.txt", ...)这种代码。生产环境 C 盘可能无写入权限。我用 Sysinternals 的ProcMon过滤Process Name为WinFormsUI.exe,操作一遍入库、出库、打印,导出 CSV 后搜索PATH NOT FOUND或ACCESS DENIED:

  • 若发现C:\Temp\路径,立即改为AppDomain.CurrentDomain.BaseDirectory + "Logs\\"
  • 若发现读取HKEY_LOCAL_MACHINE\SOFTWARE\XXX失败,改用App.config配置项

提示:ProcMon的Stack列能定位到具体哪行 C# 代码触发了文件访问,比日志更精准。

6.3 业务验证:用真实药品数据跑通“采购→入库→发药→盘点”全链路

最后一步,我准备四组真实数据:

  • 采购单:阿莫西林胶囊 0.25g × 24 粒/盒,进价 8.5 元,效期 2025-12-31
  • 入库单:扫码入库 100 盒,批号 AMX20240501
  • 发药单:门诊处方发药 12 盒,生成StockRecord出库记录
  • 盘点单:人工盘点剩余 87 盒,执行sp_AdjustStock校正

全程不看代码,只操作 UI,记录每个环节耗时。如果“发药后库存未实时更新”,说明sp_InsertDrugOutRecord的UPDATE DrugInfo语句没生效;如果“盘点校正后效期预警消失”,说明sp_AdjustStock未更新StockRecord.ExpireDate。只有这条链路 100% 顺畅,我才敢点“部署上线”。

这套系统不会拿去参加技术峰会,但它每天在药房电脑上安静运行,确保每一盒抗生素准确送达患者手中。它不追求云原生,但用最朴实的SqlConnection和SqlTransaction守住医疗数据的底线;它界面不够酷,但DataGridView的每一行颜色都在为护士争取那零点几秒的判断时间。我把这些细节写下来,不是为了证明 WinForm 过时,而是想说:在技术浪潮里,真正重要的不是框架多新,而是你写的每一行代码,是否经得起药剂师戴着橡胶手套、在凌晨三点核对库存时的凝视。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询