☰
C# WinForms快递单打印系统:从订单录入到面单出纸的完整实现
2026/10/24 2:36:38 网站建设 项目流程

简介:这份资源是一套基于 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.md

Forms只负责界面事件和输入校验,DAL只负责 SQL,Printing只负责把实体画到纸上。这种分法看起来老派,但对打单工具特别合适:界面改版不影响打印,换数据库不影响界面。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 List<Order> _orders; private int _index; public ExpressPrinter() { // 指定打印机,留空则用系统默认 _doc.PrinterSettings.PrinterName = Config.DefaultPrinter; _doc.PrintPage += DrawPage; } public void PrintBatch(List<Order> 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 判断,结果三个月后面单格式改了四次,那段事件方法长到两百行,谁都不敢动。从那以后我每次加打印字段,都强制先走一遍「模板配置 → 绘制方法 → 业务调用」这条链路,宁可多写一个方法,也不让绘制逻辑和业务逻辑缠在一起。希望这个习惯能帮到你,少在面单偏移和重复出单上栽跟头。

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

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

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

立即咨询