C#实战:用WinForms+SQLite+Dapper从零开发图书管理系统
发布时间:2026/9/26 11:42:22来源:尧图网络
1. 从学完C#不知道做什么到第一行业务代码如果你正在学C#大概率会经历这样一个尴尬期跟着教程敲过控制台计算器、写过猜数字游戏、弄过几个窗体界面语法看着都眼熟可一旦让你独立做一个像样点的东西脑子就开始空白。图书管理系统恰好是绕开这个尴尬期的绝佳项目——它不涉及复杂的数学算法没有高深的设计模式却把C#开发中最常用、最该练熟的东西几乎全串起来了集合、委托、字符串处理、数据库操作、面向对象、UI与逻辑分离。难度曲线缓覆盖面广做完之后你对这门语言的感觉会和之前完全不一样。这篇博文不是给你贴一段完整的源码就完事而是把我自己做图书管理系统从设计思路到代码落地的全过程拆开讲。内容覆盖了需求边界怎么划、技术选型怎么定、数据库表怎么设计、核心功能怎么编码以及真实开发中一连串踩过的坑。适合两类人看C#语法基础刚学完、正在找第一个完整项目的初学者跟着走可以少走很多弯路用C#做过零散小工具、但没系统梳理过业务系统开发流程的开发者这篇能帮你把项目结构的观念立起来。顺带说一下我选的实践路径是 WinForms SQLite Dapper这个组合在轻量级桌面管理系统里非常能打后面会详细解释为什么这么选。现在从头开始说。2. 动手之前图书管理系统的功能边界和核心难点在哪很多教程上来就贴数据库脚本和窗体代码但真正写项目的人都知道想清楚系统到底要管什么才是第一道坎。图书管理往大了说可以做成图书馆全套解决方案往小了说其实就是围绕书和人的关系做增删改查。我给自己定的边界很简单面向小型图书室或个人学习的场景核心闭环是图书入库、图书检索、借书、还书、借阅记录查询这五件事。2.1 需求拆解把一句话描述变成可落地的功能清单先别急着写代码拿张纸把业务场景走一遍。一本书到了管理员手里要录入系统读者来了要查书、借书过一段时间要把书还回来管理员要能知道哪本书借出去了、被谁借的、借了多久。基于这个流程功能点拆出来就这些图书管理新增图书、编辑图书信息、下架/删除图书。这里注意图书的唯一标识不能用书名因为同名书可能有很多版本。读者管理新增读者、编辑读者信息、禁用/启用借书权限。没有读者管理借阅记录就失去关联对象。图书检索按书名、作者、ISBN、分类任意组合查询。这一块是考验字符串处理和SQL拼接技巧的好地方。借书操作选择读者、选择图书、登记借出日期和应还日期。核心校验逻辑是这本书是否在馆和这个读者是否有权限借书。还书操作根据借阅记录归还图书计算是否逾期。借阅记录查询分历史记录和在借列表两种视图按读者或按图书维度查询。这就是MVP最小可行产品思维——第一版不追求权限分级、批量导入、图书封面识别这些花哨功能把核心链路跑通后面再慢慢扩展。我见过太多人第一版就想做角色权限结果项目做了两个月还没跑起来其实对学习项目来说复杂度是逐步长出来的一上来就上重量级设计只会让你中途放弃。2.2 新手最容易忽略的建模难点真正动手写过之后你会发现图书管理系统最纠结的地方不在于界面上放几个按钮而在于三个容易想岔的建模问题图书和库存的关系一本《C#高级编程》可能买了三本每本都有独立编号流水号或条码号。所以在设计上图书是书目信息书名、作者、出版社、ISBN馆藏副本才是具体某一本实体书。借书还书操作的对象是副本不是书目。第一次做的人如果忽略了这层关系后面处理同书多册时会非常痛苦。借阅状态谁维护最直接的做法是在读者和图书上都加一个是否借出字段但这样会埋坑。正确做法是借阅状态由借阅记录表派生——查一下是否有未归还的记录就能知道书在哪。冗余状态字段只在查询性能有明显问题时才考虑加且必须用事务保证一致。日期与逾期应还日期通常是借出日期加默认天数比如30天逾期判断不能只看日期大小还要考虑还书当天是否算逾期。这个听起来小但实际编码时很多人会因为在时间边界上的失误被测试用例卡住。把这些问题想透了后面的数据库设计和编码就会顺很多。设计阶段多花一小时编码阶段少加三天班。3. 技术选型的取舍逻辑WinForms、SQLite和Dapper为什么是黄金组合技术选型这件事很多教程直接默认了用什么什么但实际开发中每个选择都是权衡。这里我把我的选型逻辑完整说一遍你以后换技术栈时这个思考路径也能复用。桌面端框架我选了WinForms而不是WPF原因非常朴素上手曲线低。WinForms的控件拖拽模型和事件驱动模式跟控制台程序的学习路径衔接特别自然Form和控件的事件就是Core知识里委托和事件的最佳实战载体。WPF的MVVM、数据绑定、样式模板虽然上限更高但对第一个项目来说认知负担太大。如果你已经熟练WPF那这套思路完全可以平移过去只是UI层的写法会不一样。至于说C#能不能拿来开发上位机、连接西门子OPC那是老话题了WinForms至今在工业上位机领域仍是主力侧面说明这个技术栈生命周期足够长。数据库选了SQLite而不是SQL Server或MySQL。核心原因有三第一部署成本为零。SQLite是以文件形式存在的嵌入式数据库不需要单独安装服务程序跑起来库文件自动就在那里这对给学校或小图书室做演示项目极其友好第二数据就是一个.db文件备份和迁移就是拷贝文件完全没有DBA的负担第三SQLite语法兼容标准SQL的绝大部分你在这上面写的SQL知识后面切到MySQL、SQL Server几乎没有迁移成本。缺点当然也有并发写入能力弱、不支持存储过程但对单机桌面系统这都不算事。数据访问层用了Dapper而不是EF Core或原生ADO.NET。很多人学C#数据库操作时一上来就被推荐EF Core但我的经验是老实用好轻量ORM更重要。原因如下EF Core的实体映射和LINQ表达式虽然强大可它把SQL是怎么执行这件事包装得太深了初学者遇到性能问题或异常时往往一脸懵而原生ADO.NET又太过底层代码里反复写Connection、Command、DataReader样板代码太多。Dapper处在正中间让你写原生的SQL语句同时又帮你自动完成查询结果到对象的映射。SQL看得见摸得着实体映射又不用手写这样你在项目里既能练好SQL基本功又不会因为重复劳动失去耐心。而且Dapper在GitHub上有十几万Star几乎所有.NET项目都能见到它学它绝对不亏。下面用表格直接对比一下三套方案的特点对比项ADO.NETDapperEF CoreSQL控制力完全手写完全手写由LINQ生成难以完全掌控学习曲线较低但样板代码多低高涉及迁移、导航属性、状态跟踪执行效率最高但代码冗余接近原生有额外开销开发速度慢快快但出问题难排查适合场景对性能极敏感中小型项目、需要灵活SQL大型复杂域模型、快速CRUD所以最终的技术栈就锁定为C# WinForms SQLite Dapper开发工具用Visual Studio 2022 Community版数据库可视化工具我用的是DBeaver免费开源跨平台也可以直接用VS自带的Server Explorer。NuGet安装三个包就够System.Data.SQLite或Microsoft.Data.Sqlite、Dapper以及顺手加一个Dapper.Contrib用于简化插入和更新操作。这里提个细节System.Data.SQLite和Microsoft.Data.Sqlite选哪个都行前者功能全包含加密扩展是官方为ADO.NET生态维护的后者更现代、轻量是微软自己出品的。我习惯用Microsoft.Data.Sqlite因为后续如果做跨平台MAUI或ASP.NET Core能少踩坑但桌面开发差距不大你随缘选即可。4. 数据库设计的细节三张核心表和一个最容易出错的外键关系技术栈定了接下来是数据库设计。起步阶段我不建议用工具画ER图直接在代码里建库建表反而更有掌控感。一个标准的图书管理系统表结构大致是这样4.1 书目表、副本表、读者表和借阅记录表的设计思路第一张表是图书书目表Books存书的基本元信息。我把字段设计成BookId自增主键、Title书名、Author作者、Publisher出版社、ISBN国际标准书号、Category分类、Price定价、PublishDate出版日期、Description简介。Title、Author、ISBN这三项应该建索引因为检索场景几乎都走这几个条件。ISBN要特别注意它不是每本实体书的唯一标识而是每种出版物的唯一标识——同一种书印1000本ISBN是一样的所以它不能做主键。第二张表是馆藏副本表BookCopies这才是每一本具体实体书的登记表。字段是CopyId自增主键、BookId外键关联书目表、Barcode条码号对用户来说是一本书的身份证、Status在馆/借出/维修/下架、ShelfLocation馆藏位置比如A区-3排-2列。为什么非要多这么一张表我再强调一次没有它买三本《C#高级编程》就只能创建三条一模一样的书目记录后续借还时根本分不清读者还的是哪一本。有了Copies表借阅操作就变成了对CopyId的状态流转。第三张表是读者表Readers字段有ReaderId自增主键、ReaderNo借书证号可以自己按规则生成、Name、Gender、Phone、Email、RegisterDate、Status正常/禁用。借书证号这么设计系统当前年份加上四位流水号比如2025-0001。这种编号一定要在代码里生成而不是让用户填既保证唯一性又显得专业。第四张表是借阅记录表BorrowRecords承载整个系统的核心业务。字段设计为RecordId自增主键、CopyId外键关联副本表、ReaderId外键关联读者表、BorrowDate借出日期、DueDate应还日期、ReturnDate实际归还日期初始为空、OperatorId操作员先留个字段以后扩展、Remark备注。注意这张表不需要存书名和读者名全部用外键关联查询时JOIN出来即可。这既避免了数据冗余也是关系型数据库设计的核心习惯。4.2 逻辑外键和级联删除的坑表关系上还有两个容易忽略的决策点。一个是外键约束SQLite用PRAGMA foreign_keys ON才能启用物理外键约束连接串或连接对象创建后第一件事就要开这个开关否则建了外键也会被无视。Dapper封装了连接对象你可以这样写using var connection new SqliteConnection(Data Sourcebook_manager.db); connection.Open(); using var command connection.CreateCommand(); command.CommandText PRAGMA foreign_keys ON;; command.ExecuteNonQuery();建议把这句封装到获取连接的方法里每次新建连接自动执行避免忘掉。另一个是级联删除策略的取舍。我的做法是不启用级联删除而是手动控制。举个例子删除一个读者之前必须检查他有没有未归还的借阅记录如果直接执行DELETE FROM Readers WHERE ReaderId1假设有外键约束SQLite会抛异常没有外键约束则借阅记录成了孤儿数据。更稳妥的方案是在删除前先查一下有未还记录时给用户弹提示该读者名下存在未归还图书不能删除有历史记录但全部已归还时可以选择物理删除读者并保留借阅记录这时CRUD代码里要显示地先删BorrowRecords再删Readers。原因很简单借阅记录是审计数据不能因为读者被删除就消失。所以哪怕删了读者记录里仍然要能通过冗余的读者快照或直接禁止删除来保证历史可追踪。我在项目中选了历史记录保留、物理删除读者但不删记录的做法实际运行时再通过联合查询显示读者已注销。初始化表结构的SQL这里给一个精简版本方便你直接落地CREATE TABLE IF NOT EXISTS Books ( BookId INTEGER PRIMARY KEY AUTOINCREMENT, Title TEXT NOT NULL, Author TEXT NOT NULL, Publisher TEXT, ISBN TEXT UNIQUE, Category TEXT, Price REAL, PublishDate TEXT, Description TEXT ); CREATE TABLE IF NOT EXISTS BookCopies ( CopyId INTEGER PRIMARY KEY AUTOINCREMENT, BookId INTEGER NOT NULL, Barcode TEXT UNIQUE, Status TEXT DEFAULT 在馆, ShelfLocation TEXT, FOREIGN KEY (BookId) REFERENCES Books(BookId) ); CREATE TABLE IF NOT EXISTS Readers ( ReaderId INTEGER PRIMARY KEY AUTOINCREMENT, ReaderNo TEXT UNIQUE NOT NULL, Name TEXT NOT NULL, Gender TEXT, Phone TEXT, Email TEXT, RegisterDate TEXT, Status TEXT DEFAULT 正常 ); CREATE TABLE IF NOT EXISTS BorrowRecords ( RecordId INTEGER PRIMARY KEY AUTOINCREMENT, CopyId INTEGER NOT NULL, ReaderId INTEGER NOT NULL, BorrowDate TEXT NOT NULL, DueDate TEXT NOT NULL, ReturnDate TEXT, Remark TEXT, FOREIGN KEY (CopyId) REFERENCES BookCopies(CopyId), FOREIGN KEY (ReaderId) REFERENCES Readers(ReaderId) ); CREATE INDEX IF NOT EXISTS idx_borrow_copy ON BorrowRecords(CopyId); CREATE INDEX IF NOT EXISTS idx_borrow_reader ON BorrowRecords(ReaderId);日期字段我用TEXT存储格式统一为yyyy-MM-dd HH:mm:ss。这是SQLite的一个反常识点——它其实没有真正的日期时间类型存TEXT或INTEGER比较时用字符串排序或转为时间戳。为了显示方便我直接用TEXT存标准格式这样既可直接显示又可字符串比较排序也符合直觉。如果你需要做日期加减运算SQLite提供了date(now)、julianday()这些函数比在C#里操作再拼进SQL要方便得多后面计算逾期天数时会用到。5. 从界面到数据库的分层架构把代码组织得不像新手写的很多初学者做WinForms项目习惯把所有逻辑塞进Form的按钮事件里一个MainForm.cs写上几千行。这样短期看很快但当你需要增加第二个窗口共享同一个数据访问逻辑时就会开始到处复制粘贴。我的建议是哪怕项目很小也用三层结构UI层WinForms窗体 业务逻辑层服务类 数据访问层仓储类。5.1 三个层的职责划分和文件夹组织具体划分是这样的Models层实体类对应数据库表结构比如Book、BookCopy、Reader、BorrowRecord。这些类就是Dapper自动映射的目标。字段命名尽量和数据库列名一致或者用特性标注映射关系这里用简单的属性即可。Data层数据访问每个实体对应一个Repository类只负责SQL语句的发送和结果映射比如BookRepository.GetById(int id)、BorrowRecordRepository.GetUnreturnedByReader(int readerId)。方法按功能命名不掺任何界面逻辑。Services层业务逻辑真正的规则判断在这里。比如借书时Service层负责检查读者状态、检查副本状态、插入借阅记录、更新副本状态这一个业务流程对应一个Service方法。如果直接用仓储层拼装这些操作很容易出现一个按钮事件里连写六个SQL方法的灾难现场。UI层Form只做两件事——收集用户输入、调用Service层方法、把结果显示在列表或文本框里。文件夹组织成这样BookManager/ ├── Models/ │ ├── Book.cs │ ├── BookCopy.cs │ ├── Reader.cs │ └── BorrowRecord.cs ├── Data/ │ ├── DatabaseHelper.cs │ ├── BookRepository.cs │ ├── BookCopyRepository.cs │ ├── ReaderRepository.cs │ └── BorrowRecordRepository.cs ├── Services/ │ ├── BookService.cs │ ├── BorrowService.cs │ └── ReturnService.cs └── Views/ ├── MainForm.cs ├── BookManageForm.cs ├── ReaderManageForm.cs ├── BorrowForm.cs └── ReturnForm.cs实体类的写法最简单直接public class Book { public int BookId { get; set; } public string Title { get; set; } public string Author { get; set; } public string Publisher { get; set; } public string ISBN { get; set; } public string Category { get; set; } public decimal Price { get; set; } public string PublishDate { get; set; } public string Description { get; set; } }这里有个真实开发经验不要在实体类里写构造函数的业务逻辑不要写ToString重载来拼显示文本。显示层的拼接放到Item的ToString或格式化方法里实体类保持纯净这也是Dapper映射时最省心的状态。5.2 让查询代码优雅的秘诀参数化和动态SQL拼接图书管理系统最见功力的代码其实是检索功能。用户可能在界面上任意组合书名、作者、ISBN、分类你要写一个能适配不同输入组合的SQL这就是动态SQL。很多人直接用字符串拼接容易出错而且有SQL注入风险。Dapper配参数化查询既保证安全又能让SQL逻辑清晰。比如这个场景界面上有四个查询条件用户可能填其中任意几个。我的Repository方法用可选参数实现动态条件核心代码如下public IEnumerableBook SearchBooks(string title, string author, string isbn, string category) { using var connection DatabaseHelper.GetConnection(); var sql SELECT * FROM Books WHERE 11; var parameters new DynamicParameters(); if (!string.IsNullOrWhiteSpace(title)) { sql AND Title LIKE Title; parameters.Add(Title, $%{title}%); } if (!string.IsNullOrWhiteSpace(author)) { sql AND Author LIKE Author; parameters.Add(Author, $%{author}%); } if (!string.IsNullOrWhiteSpace(isbn)) { sql AND ISBN LIKE Isbn; parameters.Add(Isbn, $%{isbn}%); } if (!string.IsNullOrWhiteSpace(category)) { sql AND Category Category; parameters.Add(Category, category); } return connection.QueryBook(sql, parameters); }这段代码里有两个关键细节值得展开。第一个细节是WHERE 11。很多教程都这么写看起来别扭但它是动态拼接条件时的经典技巧——不用去判断这是第一个条件还是后面追加的条件永远用AND开头即可。当年我刚开始写动态SQL时一直纠结要不要先拼WHERE再判断写了七八个if嵌套后代码丑到没法看后来看到这个写法直接豁然开朗。第二个细节是模糊查询的占位符写法。LIKE的条件不是直接传title而是把%拼在参数值里这是很多新手搞反的地方。如果有人告诉你Dapper自动处理参数化你要知道它只负责参数值的注入安全不负责帮你加通配符。另外字段较多时用DynamicParameters很清晰如果只有一两个参数直接传匿名对象new { title }也可以但多个可选参数时DynamicParameters的可读性好得多。5.3 业务规则放对位置借书服务的正确姿势借书是整个系统中最核心的服务方法。我把它写在BorrowService里而不是直接写在BorrowForm的按钮事件里。这样做的好处是后续如果加一个控制台入口或REST API业务规则可以复用。借书方法的逻辑顺序是固定的校验读者存在且状态为正常校验副本存在且状态为在馆生成借阅记录BorrowDate为当前时间DueDate加30天更新副本状态为借出两个操作必须在一个事务里完成这里最考验功力的就是事务处理。如果插入借阅记录成功但更新副本状态失败数据就不一致了——记录显示书借出去了但状态还是在馆。写事务的代码如下public bool BorrowBook(int copyId, int readerId) { using var connection DatabaseHelper.GetConnection(); connection.Open(); using var transaction connection.BeginTransaction(); try { var reader connection.QueryFirstOrDefaultReader( SELECT * FROM Readers WHERE ReaderId ReaderId AND Status 正常, new { ReaderId readerId }, transaction); if (reader null) throw new InvalidOperationException(读者不存在或已被禁用); var copy connection.QueryFirstOrDefaultBookCopy( SELECT * FROM BookCopies WHERE CopyId CopyId AND Status 在馆, new { CopyId copyId }, transaction); if (copy null) throw new InvalidOperationException(图书不存在或不在馆); var borrowDate DateTime.Now; var dueDate borrowDate.AddDays(30); connection.Execute( INSERT INTO BorrowRecords (CopyId, ReaderId, BorrowDate, DueDate, ReturnDate) VALUES (CopyId, ReaderId, BorrowDate, DueDate, NULL), new { CopyId copyId, ReaderId readerId, BorrowDate borrowDate.ToString(yyyy-MM-dd HH:mm:ss), DueDate dueDate.ToString(yyyy-MM-dd HH:mm:ss) }, transaction); connection.Execute( UPDATE BookCopies SET Status 借出 WHERE CopyId CopyId, new { CopyId copyId }, transaction); transaction.Commit(); return true; } catch { transaction.Rollback(); throw; } }注意Dapper的BeginTransaction出来后所有Execute或Query方法都要把这个transaction对象作为第三个参数传进去否则默认用的是连接上的隐式事务根本不在你开的事务范围内。这个细节很容易踩我一个同事当年写过一个类似的系统事务开了等于没开查了一个下午最后发现就是漏传了transaction参数。还有一个体现C#基础功力的点——connection要用using声明。虽然Dapper内部会自动开启和关闭连接但显式控制更安全。我个人习惯是每个Repository方法内部用using var connection DatabaseHelper.GetConnection();方法结束连接自动释放。事务场景下特别注意一定要用connection.Open()显式打开连接因为事务要求连接必须处于打开状态Dapper的懒连接在这里不适用。6. 核心功能的编码实战检索、统计、界面细节一网打尽架构和数据库都备好了接下来就是把各个功能逐个实现。这里我不会把每个窗体的每个控件都贴一遍代码而是挑出几个真正有含金量、能学到东西的部分展开。6.1 还书时的逾期天数计算SQLite日期函数与C#日期处理的协作还书操作的逻辑比借书要多一个逾期判断。先更新借阅记录的ReturnDate再把副本状态改回在馆最后判断是否逾期。逾期判断有两种实现方式一种是把借阅记录查出来在C#里算一种是在SQL里用SQLite的日期函数直接计算。我实际项目中用了更稳妥的方式先查出借阅记录在C#里算完逾期天数再决定是否弹出提示最后统一更新。具体做法是这样public (bool Success, string Message, int OverdueDays) ReturnBook(int recordId) { using var connection DatabaseHelper.GetConnection(); connection.Open(); using var transaction connection.BeginTransaction(); try { var record connection.QueryFirstOrDefaultBorrowRecord( SELECT * FROM BorrowRecords WHERE RecordId RecordId AND ReturnDate IS NULL, new { RecordId recordId }, transaction); if (record null) throw new InvalidOperationException(借阅记录不存在或已归还); var now DateTime.Now; var dueDate DateTime.ParseExact(record.DueDate, yyyy-MM-dd HH:mm:ss, null); var overdueDays (now.Date - dueDate.Date).Days; var returnDateStr now.ToString(yyyy-MM-dd HH:mm:ss); connection.Execute( UPDATE BorrowRecords SET ReturnDate ReturnDate WHERE RecordId RecordId, new { ReturnDate returnDateStr, RecordId recordId }, transaction); connection.Execute( UPDATE BookCopies SET Status 在馆 WHERE CopyId CopyId, new { CopyId record.CopyId }, transaction); transaction.Commit(); return (true, overdueDays 0 ? $还书成功逾期{overdueDays}天 : 还书成功。, overdueDays 0 ? overdueDays : 0); } catch { transaction.Rollback(); throw; } }这里有个体现细节的点(now.Date - dueDate.Date).Days用的是Date属性不是now - dueDate。原因很好理解——借书当天还书不算逾期如果直接相减会把时间差也算进来比如借出是上午10点、还书是下午3点时间差超过一天就会误判为逾期1天。所以先取日期部分再相减才能保证边界天的正确性。类似的坑还有DateTime.ParseExact的格式字符串要和数据库里存的格式完全一致一个字母错了就是运行时异常。顺带提一个界面上的问题还书时怎么找到要还的记录我是在还书窗体上放一个文本框输入借书证号按查询后DataGridView列出该读者名下所有未归还记录选中某一条点击还书按钮。那查询SQL就是SELECT * FROM BorrowRecords WHERE ReaderIdReaderId AND ReturnDate IS NULL进一步还可以JOIN出书名和条码号显示为用户可读的信息。6.2 在DataGridView上显示关联字段数据的格式化是UI层的事借阅记录表里存的是CopyId和ReaderId但界面上不能给用户显示ID必须显示书名、条码号、读者姓名这些可读信息。于是查询SQL就要JOIN多张表。这里我把一个借阅列表的查询写成视图方便多个窗体复用CREATE VIEW IF NOT EXISTS V_BorrowList AS SELECT br.RecordId, br.CopyId, r.ReaderId, r.ReaderNo, r.Name AS ReaderName, b.Title AS BookTitle, bc.Barcode, br.BorrowDate, br.DueDate, br.ReturnDate, CASE WHEN br.ReturnDate IS NULL AND br.DueDate datetime(now,localtime) THEN 已逾期 WHEN br.ReturnDate IS NULL THEN 借出中 ELSE 已归还 END AS StatusText FROM BorrowRecords br JOIN BookCopies bc ON br.CopyId bc.CopyId JOIN Books b ON bc.BookId b.BookId JOIN Readers r ON br.ReaderId r.ReaderId;视图是SQLite支持的最方便的一点是Dapper可以直接把它当成表来查询public IEnumerabledynamic GetBorrowList(int? readerId null, bool? onlyUnreturned null) { using var connection DatabaseHelper.GetConnection(); var sql SELECT * FROM V_BorrowList WHERE 11; var parameters new DynamicParameters(); if (readerId.HasValue) { sql AND ReaderId ReaderId; parameters.Add(ReaderId, readerId.Value); } if (onlyUnreturned.HasValue onlyUnreturned.Value) { sql AND ReturnDate IS NULL; } return connection.Query(sql, parameters); }这里StatusText的生成是视图里的一个亮点用SQL直接算出当前状态展示C#那边就不用再写一堆if else来判断了。datetime(now,localtime)是SQLite拿本地时间的函数与直接存字符串比较时因DueDate存的是本地时间字符串用date(now,localtime)比较更稳妥。之所以在视图里用datetime是因为DueDate带了时间部分全用date切割后再比也没毛病。视图还有一个隐藏好处窗体加载时只要一行代码绑定DataGridView即可dataGridView1.DataSource _borrowRepository.GetBorrowList().ToList();再配合DataSource的自动列生成界面代码瞬间就简洁了。当然如果不想用视图也可以在C#里用BorrowRecord对象拼一个DTOData Transfer Object但明显视图的SQL更直观而且SQL的重用性是C#代码比不了的。6.3 借阅排行与分类统计用聚合查询练手很重要图书管理系统不只是增删改查加一两个统计功能不仅让系统显得完整也是对SQL聚合查询的一次实战训练。我加了两个统计面板第一个是热门图书排行按借阅次数从高到低排序取前10public IEnumerabledynamic GetHotBooks(int topN 10) { using var connection DatabaseHelper.GetConnection(); const string sql SELECT b.Title, b.Author, COUNT(br.RecordId) AS BorrowCount FROM BorrowRecords br JOIN BookCopies bc ON br.CopyId bc.CopyId JOIN Books b ON bc.BookId b.BookId GROUP BY b.BookId ORDER BY BorrowCount DESC LIMIT TopN; return connection.Query(sql, new { TopN topN }); }第二个是分类数量统计直接在柱状图或列表里显示每个分类的藏书量。界面里用DataGridView显示结果就行不需要复杂的图表库。统计的另一个常见需求是逾期记录列表。那就在视图基础上加WHERE条件SELECT * FROM V_BorrowList WHERE StatusText 已逾期当然这依赖视图里算好的状态如果状态判断逻辑变了改视图即可C#代码完全不用动这就是分层设计的好处。聚合查询这块如果你能熟练写出来SQL水平就算脱离了只会SELECT * FROM 单表的新手段位了。6.4 界面交互的细节打磨让读者借书时能实时看到已借数量WinForms界面的交互除了按钮事件和数据绑定之外还有很多细节决定系统好不好用。我做借书窗体时添加了一个查询读者的交互输入证号按回车窗体上就显示读者姓名、状态和当前已借未还的图书数量。已借数量的SQL就是public int GetBorrowedCount(int readerId) { using var connection DatabaseHelper.GetConnection(); const string sql SELECT COUNT(*) FROM BorrowRecords WHERE ReaderId ReaderId AND ReturnDate IS NULL; return connection.ExecuteScalarint(sql, new { ReaderId readerId }); }ExecuteScalar是Dapper返回单值的方法比QueryFirstOrDefault再转型更方便。界面上还可以顺手限制借阅数量上限比如读者最多借5本在借书按钮点击时判断GetBorrowedCount是否达到上限超了就弹窗提示。这就是把业务规则落到Service层而不是散落在UI里的活例子。类似的细节还有DataGridView的列宽和标题友好显示、双击行时自动把信息带入编辑窗体、删除操作前统一弹确认框。这些不涉及高深技术但决定了你的系统是不是能用和好用的距离。7. 真实项目跑起来的五个拦路虎我的排查经验和解决方案任何项目在编码路上都不会一帆风顺。我把开发过程中真正卡过我比较久的问题列出来每个都是我亲自排查过的按可见程度从明显到隐蔽排序。7.1 中文乱码和数据格式不一致问题首次用SQLite数据库时很多人会遇到插入中文后读出来乱码。SQLite本身是UTF-8存储的乱码原因通常不在数据库而在于连接串没指定编码或控制台/控件显示字体不对。解决方式是统一三处编码数据库连接串加UTF81;参数WinForms控件尽量使用默认字体像DataGridView的中文显示基本无问题如果还有乱码检查VS源文件编码是否被改成了GBK。格式不一致问题在日期上高发。我在数据库中统一用yyyy-MM-dd HH:mm:ss但有些地方写DST夏令时缩写我开玩笑的指不同格式字符串时少写了秒位查询时字符串比较就不对。这类问题的排查思路是写个Console小窗口把所有日期字段统一打印出来用肉眼核对格式你会发现根本不用等程序报错一眼就看出来了。另外尽量不要用DateTime.Now.ToString()不指定格式就存进库结果随系统区域设置变化换台电脑就变成另一种格式兼容性极差。7.2 SQLite库文件被占用明明关闭了连接还是报错在WinForms里连续打开关闭窗体有时会抛出database is locked异常。这是因为Dapper的某个查询可能没有正确释放连接。排查思路是——别依赖using的自动释放检查是否有静态连接对象被多个方法共用。我见过有人图省事把连接做成静态字段所有方法都用它结果一个事务没完就去做查询锁直接卡住。SQLite的锁机制是数据库级别的不像SQL Server那样行级锁只要有一个连接处于写入状态其他写入就等待甚至直接报错。我最终的方案很简单每个数据库操作都在局部作用域内创建连接方法结束立即释放。数据库操作密集时用事务批量执行减少不必要的开关次数。按这个原则改造后锁库问题再没出现过。7.3 Dapper多表JOIN映射的误区一次查询无法自动填充三个实体借阅列表视图返回的是动态类型但如果你某些场景需要强类型对象就会遇到Dapper多表JOIN的映射问题。很多人以为这样写就能自动填充connection.QueryBorrowRecord, Book, Reader, BorrowRecord(sql, (record, book, reader) {...});但要注意Dapper的分表映射要求SQL里的列顺序和实体属性对得上而且起别名也很讲究。我在这里花过不少时间最后发现最省心的方式仍然是直接用视图查询返回动态类型或者定义一个单独的DTO类。DTO是专门为展示层定义的桩类里面的属性正好对齐界面需要的列不依赖ORM映射的复杂机制。比如定义这样一个类public class BorrowListView { public int RecordId { get; set; } public string ReaderNo { get; set; } public string ReaderName { get; set; } public string BookTitle { get; set; } public string Barcode { get; set; } public string BorrowDate { get; set; } public string DueDate { get; set; } public string ReturnDate { get; set; } public string StatusText { get; set; } }然后把SQL改成SELECT RecordId, ReaderNo, ReaderName, BookTitle, Barcode, BorrowDate, DueDate, ReturnDate, StatusText FROM V_BorrowListDapper就能自动按列名映射到DTO。这个方法简单可靠也好维护。7.4 DataGridView刷新数据源后筛选条件丢失这是一个很隐蔽的界面问题。DataGridView绑定数据源后如果你在代码里去改数据源的Filter或做二次筛选经常发现界面不刷新或者筛选无效。原因是DataGridView的DataSource绑定的是List或BindingSource时行为不一样。List不会自动感知变化BindingSource才会。所以我用DataGridView时明确区分静态展示用List直接绑定需要筛选、排序、多表联动刷新用BindingSource包一层再绑定给DataGridViewvar list _borrowRepository.GetBorrowList(readerId).ToList(); bindingSource1.DataSource list; dataGridView1.DataSource bindingSource1;之后修改bindingSource1的Filter属性DataGridView会跟着变。Filter字符串的语法类似SQL如StatusText 借出中。这个细节对界面交互的流畅度影响极大我也是做了图书管理系统的第二个版本才意识到。7.5 搜索框回车事件和按钮默认事件冲突WinForms的默认行为是在窗体上按下回车会触发AcceptButton按钮的Click。如果你在文本框的KeyDown事件里写了查询逻辑回车按下时会同时触发文本框的查询和按钮的查询导致两次查询、界面闪烁。解决方式是设置KeyPress或KeyDown里判断e.KeyCode Keys.Enter后把e.Handled true或e.SuppressKeyPress true再执行查询。另外如果回车也希望执行查询而不是点按钮在窗体的AcceptButton属性中设置为主查询按钮也能统一体验。这几个小点网上资料往往不提只有实际敲过才会遇到。8. 项目还能往哪个方向扩展从练手项目到简历作品核心功能做完这个系统已经是一个拿得出手的完整项目了。但如果你想让它在简历上更亮眼或者给自己多一些挑战我按性价比从高到低排几个扩展方向角色登录与权限控制最简单的做法是加一张User表存账号密码和角色登录后给主窗体的按钮设置Enabled状态。进阶一点用自定义Attribute标记权限、反射扫描窗体这个能引出很多C#反射和特性的知识。书籍封面图片与OCR识别给书目表加封面图片字段用PictureBox展示更进一步用Tesseract OCR识别ISBN条码或拍照识别书名自动填充表单。热搜词里c#使用tesseract说明这条路不少人在走。做出来的效果很闪亮而且能学到图像处理的基本套路。借阅到期提醒启动程序时扫描所有未归还且DueDate小于当前时间的记录弹窗或列表提醒。这在业务上非常实用也是C#队列和定时器System.Windows.Forms.Timer的典型应用场景。把单机版改造成C/S架构把数据访问层换成Web API或gRPC客户端WinForms只做界面。到这一步你就是在过渡到真正的企业应用开发了之后衔接上位机、工业通讯、物联网设备热搜词里c#连接西门子OPC、c# CAN通讯那些方向也更有底。引入NLog组件做日志记录所有增删改查操作写进日志文件这是提高系统可维护性的好抓手。热搜词里c#如何用nlog说明这也是高频问题。日志组件用起来很简单但引入它会让你开始思考系统除了干活还要留下痕迹这件事。我个人的使用体会是图书管理系统最大的价值不在于图书管理这几个字而在于它逼着你把C#的各个知识点串成一条完整的生产链路。从界面到数据库从单条记录到事务一致性从硬编码SQL到分层架构这些是任何商业项目都躲不开的基础功。你把这个项目吃透了后面再接触Web开发、上位机、移动端触类旁通的速度会快很多。最后分享一个小技巧写这个项目时给每个Repository方法都加上清晰注释说明参数含义和返回结果。等以后你代码写多了回头看会发现注释不是写给别人看的是写给三个月后的自己看的。项目可以不完美但结构一定要清楚——这比功能多写俩按钮重要得多。
网站建设高端定制企业官网