
简介这份资源是一套基于 C# WinForms 开发的快递单打印系统完整源码面向具备一定 C# 基础、希望学习桌面应用与打印技术的开发者可用于理解模板定制、数据绑定、数据库查询与权限控制等典型业务场景的实现思路。压缩包共 97 个文件约 2.73MB以 34 个 cs 源码文件为核心配合 14 个 resx 与 14 个 ico 资源、16 个 gif 及若干 bmp、jpg 图片另有 csproj、sln 工程文件与 mdf、ldf 数据库文件覆盖界面、数据访问与资源素材各层。已有 753 人学习下载。源码按 DAL、UI、Common、Database、CustomControl 等目录组织包含登录窗体、主窗体与自定义控件读者可据此梳理打印模板绘制、PrintDocument 打印流程、ADO.NET 查询展示及管理员角色权限的落地方式并参考其中的异常处理与输入校验写法快速搭建同类单据打印项目的基础框架。1. 快递单打印系统一个 WinForms 桌面工具的真实定位电商仓库里最不起眼却最容易卡壳的环节往往不是下单而是打单。订单一多客服在后台复制地址、切到 Word 排版、再点打印一张面单折腾两分钟错一个字就得重来。这个基于 C# WinForms 的快递单打印系统解决的就是这类重复劳动把收件人信息、订单号、商品明细填进固定模板一键生成面单并直接送打印机。它适合中小卖家、仓库打单员也适合想学 WinForms 桌面开发、需要一套完整增删改查加打印实战的开发者。整套东西不依赖浏览器装个 .NET 运行时就能跑对没有前端环境的仓库电脑很友好。2. 技术选型与工程结构WinForms 为什么还值得用2.1 技术栈拆解WinForms ADO.NET 打印组件这个项目的技术栈非常朴素朴素到很多人第一眼会怀疑它是不是过时了。核心是 .NET Framework 或 .NET 6 的 WinForms数据访问走 ADO.NET 直连数据库打印部分用System.Drawing.Printing里的PrintDocument配合Graphics手绘面单。没有 ORM没有 MVVM 框架也没有第三方 UI 库。先说为什么选 WinForms。打单场景的运行环境通常是仓库里一台老电脑系统可能是 Win7 或 Win10装不了太新的运行时也不需要联网。WinForms 的部署成本极低编译出来一个 exe 加几个 dll拷过去就能用。相比之下 WPF 虽然界面更现代但学习曲线和部署体积都更大对一个以「稳定出单」为目标的工具来说属于过度设计。数据层用 ADO.NET 而不是 Entity Framework理由也很实际面单数据表结构简单字段固定查询以「按订单号取一条」和「按日期批量取」为主用SqlCommand拼参数化查询足够还能精确控制连接打开和关闭的时机。打印部分是这个项目的技术核心PrintDocument的PrintPage事件里用Graphics.DrawString和DrawRectangle逐项绘制坐标全部按毫米换算成像素保证不同 DPI 的打印机输出位置一致。提示如果目标机器是 64 位系统但装了 32 位的 Office 或旧驱动编译时把平台目标设成 x86 往往能少踩很多打印相关的坑。2.2 目录结构一个典型的三层 WinForms 工程项目目录按职责分层虽然规模不大但边界清晰二次开发时不容易改乱。下面是我拆这类工程时最关注的结构ExpressPrint/ ├── ExpressPrint.sln ├── ExpressPrint/ │ ├── Program.cs // 入口启动主窗体 │ ├── Forms/ │ │ ├── MainForm.cs // 主界面订单列表 打印按钮 │ │ ├── OrderEditForm.cs // 新增/编辑订单 │ │ └── TemplateForm.cs // 面单模板配置 │ ├── Models/ │ │ ├── Order.cs // 订单实体 │ │ └── ExpressTemplate.cs // 模板实体 │ ├── DAL/ │ │ ├── DbHelper.cs // 连接字符串与通用执行方法 │ │ ├── OrderRepository.cs // 订单增删改查 │ │ └── TemplateRepository.cs // 模板读写 │ ├── Printing/ │ │ ├── ExpressPrinter.cs // 打印主逻辑 │ │ └── PrintLayout.cs // 坐标与字体常量 │ └── App.config // 连接字符串、打印机名称 └── README.mdForms只负责界面事件和输入校验DAL只负责 SQLPrinting只负责把实体画到纸上。这种分法看起来老派但对打单工具特别合适界面改版不影响打印换数据库不影响界面。App.config里放连接字符串和默认打印机名换仓库环境时改配置就行不用重新编译。2.3 数据模型与建表订单表和模板表怎么设计订单表围绕面单需要的字段设计模板表则把面单上每个元素的坐标、字体、是否显示存成配置。这样同一套代码可以适配不同快递公司的面单格式不用为每家写死一份绘制逻辑。-- 订单表一条记录对应一张待打印面单 CREATE TABLE Orders ( OrderId INT IDENTITY(1,1) PRIMARY KEY, OrderNo NVARCHAR(32) NOT NULL, -- 订单号业务唯一 ReceiverName NVARCHAR(32) NOT NULL, -- 收件人 ReceiverPhone NVARCHAR(20) NOT NULL, -- 电话 ReceiverAddr NVARCHAR(200) NOT NULL, -- 详细地址 SenderName NVARCHAR(32), -- 寄件人 SenderPhone NVARCHAR(20), GoodsDesc NVARCHAR(100), -- 商品简述 Weight DECIMAL(8,2) DEFAULT 1, -- 重量影响运费 PrintStatus TINYINT DEFAULT 0, -- 0未打印 1已打印 CreatedAt DATETIME DEFAULT GETDATE() ); -- 模板表面单上每个字段的绘制参数 CREATE TABLE ExpressTemplates ( TemplateId INT IDENTITY(1,1) PRIMARY KEY, TemplateName NVARCHAR(50) NOT NULL, FieldKey NVARCHAR(30) NOT NULL, -- 对应 Order 的属性名 PosX INT NOT NULL, -- 左上角 X单位毫米 PosY INT NOT NULL, -- 左上角 Y单位毫米 FontSize INT DEFAULT 10, IsVisible BIT DEFAULT 1 );PrintStatus这个字段是打单流程的关键批量打印时只取PrintStatus 0的记录打完立刻置 1避免重复出单。ExpressTemplates用FieldKey和订单属性做映射绘制时通过反射取值新增字段只要加一行模板配置不用改打印代码。这种设计在面单格式频繁调整的场景下能省下大量返工。3. 核心模块拆解从订单录入到面单出纸的完整链路3.1 订单录入与校验别让脏数据流到打印机订单录入窗体负责收集输入并做基础校验。电话、地址这类字段一旦有脏数据打印出来就是废单所以校验必须放在入库之前。下面这段是保存按钮的典型写法private void btnSave_Click(object sender, EventArgs e) { // 必填项校验空值直接拦截 if (string.IsNullOrWhiteSpace(txtOrderNo.Text) || string.IsNullOrWhiteSpace(txtReceiverName.Text)) { MessageBox.Show(订单号和收件人不能为空); return; } // 电话只允许数字和常见分隔符长度做下限保护 string phone txtReceiverPhone.Text.Trim(); if (!Regex.IsMatch(phone, ^[\d\-]{7,20}$)) { MessageBox.Show(电话格式不正确); return; } var order new Order { OrderNo txtOrderNo.Text.Trim(), ReceiverName txtReceiverName.Text.Trim(), ReceiverPhone phone, ReceiverAddr txtReceiverAddr.Text.Trim(), GoodsDesc txtGoodsDesc.Text.Trim(), Weight numWeight.Value, PrintStatus 0 }; // 订单号重复时提示避免同一单打两次 if (_orderRepo.Exists(order.OrderNo)) { MessageBox.Show(该订单号已存在); return; } _orderRepo.Insert(order); LoadOrders(); // 刷新列表 }这段代码的逻辑很直白先拦空值再拦格式最后拦重复。Regex里的^[\d\-]{7,20}$允许数字和短横线长度限制在 7 到 20 之间覆盖手机号和带区号的座机。Exists方法在仓储层执行SELECT COUNT(1)比先查全表再判断高效得多。PrintStatus强制置 0保证新单一定进入待打印队列。注意地址字段不要做过于严格的正则校验中文地址里出现括号、井号、英文都是常事卡太死反而误伤正常订单。3.2 打印主逻辑PrintDocument 的 PrintPage 事件怎么画打印模块是整个项目最需要耐心的部分。PrintDocument的工作方式是调用Print()后系统按页触发PrintPage事件你在事件里用Graphics画内容画完通过HasMorePages告诉系统还有没有下一页。public class ExpressPrinter { private readonly PrintDocument _doc new PrintDocument(); private ListOrder _orders; private int _index; public ExpressPrinter() { // 指定打印机留空则用系统默认 _doc.PrinterSettings.PrinterName Config.DefaultPrinter; _doc.PrintPage DrawPage; } public void PrintBatch(ListOrder orders) { _orders orders; _index 0; _doc.Print(); // 触发逐页绘制 } private void DrawPage(object sender, PrintPageEventArgs e) { var order _orders[_index]; var g e.Graphics; // 毫米转像素1 毫米 1/25.4 英寸乘 DPI 得到像素 float mm2px e.Graphics.DpiX / 25.4f; // 画外框面单常见 100mm x 150mm g.DrawRectangle(Pens.Black, 0, 0, 100 * mm2px, 150 * mm2px); // 逐字段绘制坐标来自模板配置 foreach (var tpl in _templates.Where(t t.IsVisible)) { string text GetFieldValue(order, tpl.FieldKey); var font new Font(宋体, tpl.FontSize); var rect new RectangleF( tpl.PosX * mm2px, tpl.PosY * mm2px, 90 * mm2px, tpl.FontSize * 2 * mm2px); g.DrawString(text, font, Brushes.Black, rect); } _index; e.HasMorePages _index _orders.Count; // 还有单就继续 } }关键点有三个。第一mm2px的换算必须用e.Graphics.DpiX不能写死 96否则换一台 300 DPI 的标签打印机所有元素会缩到左上角。第二DrawString传的是RectangleF而不是点坐标这样长地址会自动换行超出区域的部分被裁剪不会糊到别的字段上。第三HasMorePages是批量打印的开关_index每画完一页自增等于总数时置 false打印任务自然结束。GetFieldValue用反射从Order取属性值模板里配ReceiverName就取收件人配OrderNo就取订单号。新增一个「运费」字段时只要在Order加属性、在模板表加一行打印代码一行不用动。3.3 批量打印与状态回写打完立刻标记避免重复出单批量打印的入口在主窗体选中多条订单后调用打印器打印完成后统一回写状态。这里有个顺序问题必须等Print()真正结束再更新数据库否则打印中途取消状态却已经置 1这批单就永远打不出来了。private void btnBatchPrint_Click(object sender, EventArgs e) { var selected GetSelectedOrders(); // 从 DataGridView 取勾选行 if (selected.Count 0) { MessageBox.Show(请先选择要打印的订单); return; } var printer new ExpressPrinter(); // EndPrint 在打印任务结束后触发此时回写最安全 printer.PrintCompleted (s, args) { if (args.Canceled) return; // 用户取消则不标记 _orderRepo.MarkPrinted(selected.Select(o o.OrderId).ToList()); LoadOrders(); }; printer.PrintBatch(selected); }PrintCompleted是包装过的事件内部挂到PrintDocument.EndPrint上通过e.Cancel判断是否被取消。MarkPrinted接收 ID 列表用IN子句一次性更新避免循环开连接。这个「先打印、后回写」的顺序是血泪经验早期版本先回写再打印结果打印机卡纸重打时发现状态已是已打印只能手动改库。4. 跑起来与避坑环境、驱动、坐标三类高频问题4.1 从零跑通环境准备与首次打印拿到源码后第一步是确认运行时。项目如果是 .NET Framework 4.7.2 编译的目标机器需要装对应运行时如果是 .NET 6则要装桌面运行时。用 Visual Studio 打开.sln还原 NuGet 包这个项目依赖很少通常只有数据库驱动然后改App.config里的连接字符串指向本地数据库执行建表脚本F5 启动。首次打印前先在系统里把标签打印机设成默认或者在配置里填准确的打印机名称。打印机名称必须和「设备和打印机」里显示的一字不差包括空格。然后放一张测试面单点单条打印观察输出位置。如果整体偏移调模板表里的PosX、PosY如果字太大或太小调FontSize。这套参数化设计的意义就在这里校准一次后面批量出单就稳了。4.2 避坑清单五条真实踩过的坑现象打印出来所有内容挤在左上角一小块。原因是用固定 96 DPI 换算毫米而标签打印机实际 DPI 是 203 或 300。解决是把换算基准改成e.Graphics.DpiX让系统告诉你当前设备的真实分辨率。现象中文显示成方框或乱码。原因是Font用了不支持中文的字体或者打印机驱动缺少中文字库。解决是把字体固定为「宋体」或「微软雅黑」并在目标机器上确认字体已安装。现象批量打印到第三张就停住。原因是HasMorePages逻辑写错或者在PrintPage里做了耗时查询导致超时。解决是把数据一次性查出来放内存PrintPage里只做绘制不碰数据库。现象订单号重复但系统没拦住。原因是Exists查询用了字符串拼接订单号里带单引号时 SQL 报错被吞掉。解决是全部改用参数化查询OrderNo传值既防注入也防特殊字符。现象打印完状态没更新同一批单重复出纸。原因是状态回写挂在Print()之后同步执行而Print()是异步返回的。解决是挂到EndPrint事件里并判断e.Cancel取消的任务不标记。4.3 调试打印的笨办法先画到图片再上纸打印调试最难受的是每改一次就要浪费一张面单。我的做法是先加一个「预览」按钮把PrintPage的绘制逻辑抽成一个接受Graphics的方法预览时画到Bitmap上显示在PictureBox里。确认位置对了再真打。public Bitmap RenderPreview(Order order, int widthMm, int heightMm) { // 按 96 DPI 生成预览图1 毫米约 3.78 像素 int w (int)(widthMm * 96 / 25.4); int h (int)(heightMm * 96 / 25.4); var bmp new Bitmap(w, h); using (var g Graphics.FromImage(bmp)) { g.Clear(Color.White); DrawContent(g, order, 96 / 25.4f); // 复用正式打印的绘制方法 } return bmp; }DrawContent就是正式打印里画字段的那段逻辑抽出来之后预览和打印共用一份代码预览准了打印基本不会偏。这个办法看着土但省下的面单钱和调试时间很实在。5. 二次开发换快递模板、接扫码枪、加导出5.1 换一家快递公司只改模板表不改代码这个项目最值得二次开发的地方是模板驱动的绘制逻辑。换快递公司时面单尺寸、字段位置、条码区域都不同但不需要动ExpressPrinter。做法是在ExpressTemplates里新增一组记录TemplateName填新快递名FieldKey对应订单属性坐标按新面单量好填进去。主界面加一个模板下拉框打印前选模板ExpressPrinter按TemplateName过滤即可。如果新面单需要条码加一个FieldKey Barcode的模板项在DrawContent里判断到这个 key 时改用g.DrawString配合条码字体或者引入一个轻量条码库画Rectangle序列。条码区域要留白边否则扫描枪读不出来这是实际部署时最容易忽略的细节。5.2 接扫码枪键盘钩子还是文本框焦点扫码枪本质是一个快速输入的键盘扫一次等于敲一串字符加回车。最简单的接法是主窗体放一个隐藏的TextBox始终持有焦点KeyDown里判断回车就触发查询。但这样会和用户点按钮抢焦点体验不好。更稳的做法是用窗体级KeyPreview true在Form.KeyDown里累积字符遇到回车就当作一次扫码完成。扫码枪输入速度极快可以用时间间隔区分人工输入和扫码两次按键间隔小于 50 毫秒的视为扫码。这个判断逻辑不复杂但能避免用户手动输订单号时被误判。private readonly StringBuilder _scanBuffer new StringBuilder(); private DateTime _lastKeyTime DateTime.Now; private void MainForm_KeyDown(object sender, KeyEventArgs e) { var now DateTime.Now; // 间隔超过 100ms 视为人工输入清空缓冲 if ((now - _lastKeyTime).TotalMilliseconds 100) _scanBuffer.Clear(); _lastKeyTime now; if (e.KeyCode Keys.Enter _scanBuffer.Length 5) { string orderNo _scanBuffer.ToString(); _scanBuffer.Clear(); var order _orderRepo.GetByOrderNo(orderNo); if (order ! null) HighlightOrder(order); // 定位到对应行 e.Handled true; } else if (e.KeyCode ! Keys.Enter) { _scanBuffer.Append((char)e.KeyValue); } }_scanBuffer累积字符回车且长度超过 5 才认为是有效订单号避免误触。HighlightOrder在DataGridView里选中对应行打单员扫一下就能确认是不是这单减少发错货的概率。5.3 导出与对账把打印记录变成一张 Excel仓库月底要对账需要知道哪些单打了、哪些没打。项目里PrintStatus和CreatedAt已经够用加一个导出按钮用DataGridView的数据直接写 CSV 最省事不需要引入 Excel 库。private void btnExport_Click(object sender, EventArgs e) { var sb new StringBuilder(); sb.AppendLine(订单号,收件人,电话,状态,创建时间); foreach (DataGridViewRow row in dgvOrders.Rows) { if (row.IsNewRow) continue; sb.AppendLine(string.Join(,, row.Cells[OrderNo].Value, row.Cells[ReceiverName].Value, row.Cells[ReceiverPhone].Value, (int)row.Cells[PrintStatus].Value 1 ? 已打印 : 未打印, row.Cells[CreatedAt].Value)); } File.WriteAllText(对账单.csv, sb.ToString(), Encoding.UTF8); }CSV 用 UTF-8 写Excel 打开中文不乱码。字段里如果出现逗号会串列正式用的时候给每个值包一层双引号并转义内部引号这个细节不做地址里带逗号的单就会错位。5.4 一个改造习惯先抽绘制方法再动业务我改这类打印项目有个固定习惯任何新需求进来先看能不能只动模板表不能的话再把绘制逻辑抽成独立方法最后才碰业务代码。早期我图快直接在PrintPage里加 if 判断结果三个月后面单格式改了四次那段事件方法长到两百行谁都不敢动。从那以后我每次加打印字段都强制先走一遍「模板配置 → 绘制方法 → 业务调用」这条链路宁可多写一个方法也不让绘制逻辑和业务逻辑缠在一起。希望这个习惯能帮到你少在面单偏移和重复出单上栽跟头。本文还有配套的精品资源点击获取