简介:这是一套基于C#与ASP.NET的Web设备管理系统完整源码工程,主要面向C#初中级学习者、高校软件相关专业学生以及需要快速搭建设备管理后台的开发者。系统覆盖设备档案维护、借用归还登记、维修工单处理和报表统计等典型管理环节,源码中充分体现MVC分层设计、ADO.NET数据访问、登录验证与权限控制等实用知识点,并附带SQL Server数据库备份文件,可还原后直接运行体验。压缩包共241个文件,其中94个C#源文件与14个ASP.NET页面是核心代码,另有DLL库、数据库备份、样式表、资源文件及Visual Studio解决方案文件,结构清晰,整体仅4.69MB。已有716人学习使用,通过这套工程可直观了解完整业务系统的表结构设计、页面跳转逻辑和增删改查实现方式,也便于进一步扩展维修工单、设备保养等模块,是实践C#企业级Web开发的合适参照。
1. C# 设备管理系统:从 Excel 台账到可追溯的设备全生命周期
做设备管理最怕的不是设备多,而是台账乱。我曾接手过一个几十台设备的维护任务,原来的记录分散在 Excel、纸质维修单和个别同事的脑子里,每次盘点都要翻半天,到了审计的时候更是找不全数据。后来我整理了一套基于 C# 的 Windows 桌面端设备管理系统,把设备采购、领用、维修、报废全部串成一条可追溯的记录链。这套系统的核心价值是:不用昂贵的商业软件,用 C# 配合本地数据库就能把设备台账、状态追踪和统计报表跑起来,适合中小型公司的设备管理员、车间班组长,以及想练手完整业务逻辑的开发者。下文把整个系统怎么拆、怎么搭、参数怎么设、坑在哪都讲清楚。
2. 系统架构与技术选型:为什么用 WinForms + SQLite 而不是别的组合
2.1 WinForms 依然是设备管理这类内部工具的最优解
选型不能跟风。设备管理系统本质是给少数内部人员用的 CRUD 加报表工具,使用频率高、并发极低、界面以表单和表格为主。我见过有人非要用 Web 前端加后端服务,结果部署在内网还得配 IIS 和数据库服务,维护成本陡增;也见过用 WPF 的,界面确实漂亮,但数据绑定和异步处理的复杂度对这类小项目来说有点用力过猛。WinForms 在 .NET 6/8 环境下对系统资源占用低,DataGridView 直接绑定数据源,事件模型简单直接,开发效率极高。系统每次启动自动检查数据库文件,不存在就创建,关机即走,不需要安装任何服务端组件。这套方案我在模拟项目X里反复验证过:一个 2000 条设备记录以内的项目,WinForms 的加载速度完全可以接受,没必要引入重量级框架。
2.2 SQLite 作为本地文件库的边界与参数调优
数据库我选的是 SQLite,而不是 SQL Server 或 MySQL。原因很直接:设备管理系统的数据量通常在几万条以内,单机使用居多,SQLite 一个 .db3 文件就能装下全部数据,备份时直接复制文件,恢复时直接替换文件。但 SQLite 不是无脑用就好,有几个参数必须先确认。我一般把连接字符串写成这样:
string connStr = "Data Source=device_manage.db3;Version=3;Pooling=True;Max Pool Size=10;Foreign Keys=True;";逻辑说明:连接字符串里显式声明了Foreign Keys=True,这是 SQLite 默认关闭的外键约束开关。如果不打开,在业务代码里删了某个分类 ID,台账表里的分类字段就会变成悬空引用。Pooling=True让连接复用,避免频繁开关数据库文件带来的 IO 抖动。Max Pool Size=10对于单机桌面应用足够,设置过大反而会占用句柄。
参数说明:SQLite 写入性能在单用户桌面场景下不是瓶颈,但如果未来要支持多人同时写入,就要考虑WAL日志模式。你可以在启动时执行PRAGMA journal_mode=WAL;,让读写互不阻塞,代价是目录下会多出 -wal 和 -shm 两个临时文件,备份时要把它们一起处理。
2.3 数据表设计:一张主表 + 三张字典表就是完整的骨架
整套系统的表结构非常简单,核心是设备台账主表,加上分类、状态、维修记录三个字典或明细表。分类表和状态表用来约束录入,避免同一种设备被写成"空压机"和"空气压缩机"两笔记录。维修记录表则是一对多关联主表,保存每次故障的现象、处理人和费用。这里主表的字段设计直接决定了报表能不能统计出来,我用的结构如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| Id | INTEGER PRIMARY KEY AUTOINCREMENT | 设备唯一编号 |
| AssetCode | TEXT | 资产编码,建议 YYYY + 流水号 |
| DeviceName | TEXT | 设备名称 |
| CategoryId | INTEGER | 关联分类字典表 |
| StatusId | INTEGER | 1 在库 2 已出库 3 维修中 4 报废 |
| PurchaseDate | TEXT | 采购日期 |
| UserName | TEXT | 当前领用人或责任人 |
| Remark | TEXT | 备注 |
资产编码为什么要自己生成 YYYY 加流水号,而不是依赖自增 Id?因为自增 Id 删掉一条记录后会出现空洞,将来盘点对不上账。编码字段独立出来之后,即使中间删除数据,编码也不重排,符合固定资产管理的习惯。这个表里尽量避免存金额以外的冗余统计字段,维修费用累计应当在报表时通过子查询算出来,而不是在每笔记录上维护一个"累计值"。
3. 台账与状态流转功能落地:核心窗体与业务约束怎么写
3.1 设备录入窗体的必填校验与着装校验逻辑
录入窗体是整个系统里用户看得最多的界面,校验在这里做严,后面就少很多脏数据。除了常规的必填项检查,值得专门写的是"资产编码唯一性"和"状态合法性"。前者用 SQL 的 COUNT 语句查重,后者则是防止用户把一台还在库的设备直接选成"维修中",必须要求先领用人,出现故障再进入维修状态。这里有三个关键校验点我在代码里写成了私有方法:
private bool ValidateInput() { if (string.IsNullOrWhiteSpace(txtAssetCode.Text)) { MessageBox.Show("资产编码不能为空"); return false; } if (string.IsNullOrWhiteSpace(txtDeviceName.Text)) { MessageBox.Show("设备名称不能为空"); return false; } if (string.IsNullOrWhiteSpace(cboStatus.Text)) { MessageBox.Show("请选择设备状态"); return false; } return true; }逻辑说明:IsNullOrWhiteSpace比IsNullOrEmpty更严格,能拦截掉用户只敲空格的情况。资产编码的格式校验建议用正则^\d{4}-[0-9A-Za-z]{4,8}$,限定年号和流水号的有效字符集,避免中文和特殊符号混入编码。这些校验全部在点击"保存"按钮的 Click 事件里触发,不要在 TextChanged 里实时检查,否则录入长编码时会被反复提示。
参数说明:状态下拉框的数据源直接绑定字典表,绑定用的是DisplayMember和ValueMember两个属性,保存在数据库里的只有状态 Id,界面显示的是状态名称。这样做的好处是以后要加"闲置""外借"等新状态,只需改字典表,业务代码不用动。
3.2 领用与退库操作如何避免脏状态
设备管理系统里最容易出脏数据的位置是领用和退库。最常见的错误是直接 UPDATE 主表的状态和领用人,不记录历史。我一般会加一张"流转记录表",每次领用、退库、维修、报废都插入一条历史记录。这样将来追责时能查出某个时间段这台设备的责任人和状态。核心方法如下:
private void ExecuteTransfer(int deviceId, int newStatusId, string userName, string remark) { using (var conn = new SQLiteConnection(connStr)) { conn.Open(); using (var tx = conn.BeginTransaction()) { var updateSql = "UPDATE device_main SET StatusId=@status, UserName=@user, Remark=@remark WHERE Id=@id"; var insertSql = "INSERT INTO device_log(DeviceId, OldStatus, NewStatus, UserName, ChangeDate, Remark) VALUES(@id, @oldStatus, @newStatus, @user, @date, @remark)"; // 先查出旧状态,再执行更新和插入日志 tx.Commit(); } } }逻辑说明:这段代码把"更新主表"和"插入日志"放进同一个事务里,只要任何一个失败,两个操作一起回滚,不会出现状态已经变了但日志没记的尴尬情况。事务的粒度一定要覆盖到这两步,不能用两个独立连接分别执行。
参数说明:ChangeDate字段建议用本地时间DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")而不是DateTime.Today,因为同一天内可能有多次流转,时间戳精确到秒才能正确排序。如果将来系统要跨时区使用,再考虑把时间存成 UTC,但设备管理属于纯内部应用,本地时间足够。
3.3 DataGridView 列表查询:过滤条件拼装与防注入
列表页是用户每天面对的界面,必须支持按资产编码、设备名称、状态、日期区间过滤。很多人写查询时喜欢直接拼 SQL 字符串,这在桌面内部工具里也不至于惹大祸,但仍存在注入风险。我习惯的做法是使用参数化查询,并把过滤条件动态拼接进 WHERE 子句:
private DataTable QueryDevices(string keyword, int statusId, string beginDate, string endDate) { var sql = "SELECT * FROM device_main WHERE 1=1"; var conditions = new List<string>(); var parameters = new Dictionary<string, object>(); if (!string.IsNullOrWhiteSpace(keyword)) { conditions.Add("(AssetCode LIKE @kw OR DeviceName LIKE @kw)"); parameters["@kw"] = "%" + keyword + "%"; } if (statusId > 0) { conditions.Add("StatusId = @sid"); parameters["@sid"] = statusId; } if (!string.IsNullOrWhiteSpace(beginDate) && !string.IsNullOrWhiteSpace(endDate)) { conditions.Add("PurchaseDate BETWEEN @b AND @e"); parameters["@b"] = beginDate; parameters["@e"] = endDate; } if (conditions.Count > 0) sql += " AND " + string.Join(" AND ", conditions); sql += " ORDER BY AssetCode"; // 执行 sql,用 parameters 字典添加参数 }逻辑说明:WHERE 1=1是拼 SQL 时的常用技巧,它让后面的AND子句不用判断是否为首个条件,代码结构更干净。参数化查询的关键是parameters字典和 SQL 里的@kw、@sid一一对应,值单独传递,不混进 SQL 文本。用户名键盘输入的引号、百分号都不会破坏语句结构。
参数说明:LIKE @kw里的参数值需要手动在前后加%通配符,这一点容易漏。很多人写成LIKE '@kw',结果查询结果永远为空,因为 SQL 把@kw当成普通字符串了。另外日期过滤的边界值是BETWEEN,包含当天,如果要排除当天,就得用>=和<结合次日的零点。
4. 维修记录与费用统计:报表怎么算才不失真
4.1 维修单与设备主表的关联设计
维修记录表不能偷懒只存一个"维修说明"字段。我设计维修单表时固定了这样几个字段:设备 Id、故障类型、维修描述、维修人、维修日期、费用金额。故障类型应当接字典表,因为后续统计"哪类故障占比最高"时要按这个字段分组统计。维修人这里要注意:它和领用人是两个维度,维修人可能是厂家的售后工程师,不是内部员工,所以不要复用设备主表的 UserName。
在代码里,打开维修记录窗体时会先加载设备列表,用户选一台设备,然后保存一条维修单。这之间的外键关系就是device_main.Id对应device_repair.DeviceId。为了查询方便,我习惯建立联合视图,把设备编码和名称直接带进维修记录展示里:
CREATE VIEW IF NOT EXISTS v_repair_detail AS SELECT r.Id, m.AssetCode, m.DeviceName, r.FaultType, r.RepairDesc, r.Repairer, r.RepairDate, r.Cost FROM device_repair r LEFT JOIN device_main m ON r.DeviceId = m.Id;逻辑说明:视图的好处是查询代码不用每次 JOIN 两遍,界面层直接把视图当成一张表来绑定。需要注意的是LEFT JOIN保证了如果维修记录对应的设备被删除了,维修记录依然能查出来,只是设备名称显示为空,这种数据安全策略比INNER JOIN更稳妥。
4.2 维修费用的累计统计:SUM 与 GROUP BY 的坑
维修费用是设备管理里最敏感的数字,统计时最容易踩的坑是 GROUP BY 的粒度不对。例如想统计每台设备的年维修费用,如果 GROUP BY 只用了 AssetCode 而 SELECT 却包含 DeviceName,那么 SQLite 在某些模式下可能返回意外结果,或者取出了随机一行。更稳妥的方案是分两步:先按设备分组汇总费用,再关联主表补上设备名称。我常用这样的查询:
SELECT t.AssetCode, t.TotalCost, m.DeviceName FROM ( SELECT DeviceId, SUM(Cost) AS TotalCost FROM device_repair WHERE RepairDate >= @yearBegin AND RepairDate < @yearEnd GROUP BY DeviceId ) t LEFT JOIN device_main m ON t.DeviceId = m.Id ORDER BY t.TotalCost DESC;逻辑说明:子查询t里只保留了 DeviceId 和费用合计,这样 GROUP BY 的列只有 DeviceId,语义清晰不歧义。外层再 JOIN 设备主表取名称,不会干扰聚合结果。@yearBegin和@yearEnd的取值规则是当 2025 年 1 月 1 日到 2026 年 1 月 1 日,左闭右开,这比用BETWEEN加 12 月 31 日更严谨,因为如果你把结束日期写 2025-12-31,就会漏掉当天 23 点 59 分 59 秒之后的记录。
参数说明:费用字段在 SQLite 里建议用REAL或NUMERIC(10,2)。用 REAL 可能在二进制浮点表示上有微小误差,比如 0.1 加 0.2 不等于 0.3。对于金额统计,我更建议把费用以"分"为单位存成 INTEGER,例如 95.50 元存 9550。输出时再除以 100.0,这样能彻底避开浮点误差。这个习惯是从一次对账差了两分钱的血泪教训里来的。
4.3 状态分布统计与 DataGridView 导出 Excel
状态分布统计直接按 StatusId 分组计数就能跑通,但展示时用户看到的应该是一行"维修中: 3台"这种中文内容。这里要在内存里做一次映射,把 Id 转成名称。至于导出 Excel,WinForms 本身没有原生的 Excel 组件,常见做法是生成 CSV 文件, Excel 能直接打开。CSV 有个编码坑:如果直接用File.WriteAllText默认编码写中文,Excel 打开会乱码。解决办法是写入带 BOM 的 UTF-8 文件:
var sb = new StringBuilder(); sb.AppendLine("资产编码,设备名称,故障时间,维修人,费用"); foreach (DataRow row in dt.Rows) { sb.AppendLine($"{row["AssetCode"]},{row["DeviceName"]},{row["RepairDate"]},{row["Repairer"]},{row["Cost"]}"); } System.IO.File.WriteAllText("repair_report.csv", sb.ToString(), new UTF8Encoding(true));逻辑说明:UTF8Encoding(true)参数表示写入 BOM 头。这对 Excel 来说是识别 UTF-8 的标记,不加的话 Excel 默认按 GBK 解读,中文全变问号。另外 CSV 里的内容如果本身含逗号或换行,需要用双引号包裹并转义内部引号,这里可以封装一个EscapeCsvField方法处理,避免导出后行错位。
参数说明:导出的文件路径不要写死到应用程序目录,那种路径在用户的机器上可能没有写权限。我一般用SaveFileDialog让用户选位置,或者默认写到Environment.GetFolderPath(Environment.SpecialFolder.Desktop),保证用户能立刻看到文件。
5. 避坑指南:设备管理系统实装中常见的六个翻车点
5.1 备份时只复制数据库文件,忘了 WAL 文件
现象:明明是刚备份的 .db3 文件,在另一台电脑上打开却提示数据库损坏或缺少最近的数据。原因:SQLite 在 WAL 模式下,最近的写入先落在 -wal 文件里,尚未合并进主库。解决:备份前先执行一条PRAGMA wal_checkpoint(TRUNCATE);强制合并,这个操作会把 WAL 文件内容写回主库,此时再复制 .db3 就完整了。或者干脆使用 SQLite 的VACUUM INTO 'backup.db3'命令生成备份,这个命令能生成一个一致性快照,不用手动处理 WAL。
5.2 设备编码用自增 Id,删除记录后账序全乱
现象:删除了 3 条设备记录后,新设备编码从 4 开始,资产管理员觉得不吉利也不规范。原因:AUTOINCREMENT 本身不保证编码连续,只保证唯一。解决:放弃直接用 Id 作为资产编码,改为DateTime.Now.Year加当天插入的自增序号。这个序号可以单独存一张seq_asset表,每次生成新编码时在其中取下一个值。注意不要用 Max(Id)+1 这种方式算序号,并发插入时可能拿到重复值。
5.3 维修窗体保存慢,界面卡死
现象:用户点击保存后,窗体要卡两三秒才返回。原因:我在维修记录保存时同步执行了费用统计的 SQL,统计涉及全表聚合,数据量稍大就会卡顿。解决:把统计查询拆到独立的刷新按钮或后台线程里,保存操作只做单条 INSERT,不要让主流程承担额外查询。如果一定要自动刷新,就在保存后启动一个Task.Run去查,查询结果通过委托回填到界面,或者直接把 DataGridView 的刷新放在BackgroundWorker中。
5.4 日期筛选查不出当天记录
现象:选择 2025-03-01 到 2025-03-31,结果 3 月 31 当天的记录没了。原因:日期字段实际存储带时间,BETWEEN @begin AND @end中 @end 按 '2025-03-31 00:00:00' 处理,当天下午的记录被过滤掉了。解决:范围过滤统一使用RepairDate >= @begin AND RepairDate < @end + 1天,建议传入 endDate 后在其基础上加一天再拼接 SQL。这个坑我至少在三个项目里见过不同的人踩进去。
5.5 状态为"维修中"的设备还能被领用
现象:设备明明在维修,界面上的领用按钮依然可用。原因:没有做状态机校验,任何状态都能执行领用操作。解决:在触发领用的方法里先查询当前状态,如果状态 Id 为 3,则弹出提示并终止操作。更完整的做法是写一个状态流转许可表,定义每种状态允许跳转到哪些目标状态,例如在库只能到已出库或维修中,维修中只能到在库或报废,已出库只能回在库。这套规则虽然简单,却能把绝大多数操作错误挡在界面前。
5.6 DataGridView 绑定数据源后新增行不见了
现象:用户点"新增设备",DataGridView 上出现了新行,但数据库里没有。原因:DataGridView 的AllowUserToAddRows默认是 true,用户可以在界面上直接编辑新行,但如果你没有把编辑后的行显式提交到数据库,它只停留在内存里。解决:把AllowUserToAddRows设为 false,强制用户通过"新增设备"窗体录入数据,避免这种假象。类似的还有AllowUserToDeleteRows,防止用户直接点行首删除数据且不经确认。
6. 进阶技巧:把设备管理系统做成一个可移植的数据维护工具
这套系统做到能跑能用只是第一步,真正让它省心的是我后来加的三个细节。第一个细节是启动时自动做数据库结构校验。程序每次运行会检查device_main、device_repair、device_log三张表是否存在,不存在就用CREATE TABLE IF NOT EXISTS语句补建。这样就彻底摆脱了"软件要先装数据库工具"的依赖,任何一台 Windows 机器拷贝过去双击就能跑。第二个细节是支持从 Excel 旧台账导入初始化。旧数据通常是一张表头混乱的 Excel,我的导入代码只做三件事:把表头映射到 XML 配置文件、逐行读取字段、用临时 DataTable 做校验后批量 INSERT。校验失败的记录全部输出到一个文本文件里,方便用户手动修正,而不会导入一半就中断。
第三个细节是记录操作日志。除了设备本身的流转日志,我还写了一个app_log表记录谁在什么时间删除了哪条设备记录。这个表不提供删除功能,数据只增不改,作为最后的后悔药。有一次同事误删了一条设备数据,就是从这张表里找到了原始编码和名称,手动补了回去。从那以后我每次给别人部署这套系统,都会强制确认日志表是否有写入权限,并让用户养成定期导出备份的习惯。设备管理系统说到底治的是"信息不落地"的病,数据库结构再漂亮,没有严格的日志和备份机制,数据一样会丢。希望这套思路和代码里的细节能帮你在自己的项目上少踩几个坑。
本文还有配套的精品资源,点击获取