简介:这是一份基于C#与SQL Server的图书管理系统课程设计完整源码包,面向正在完成期末大作业或希望掌握数据库应用开发的学生。资源共187个文件,以C#源文件(cs)、窗体资源文件(resx)、SQL数据库文件(mdf/ldf)及项目配置文件为主,压缩包仅2.62MB,便于快速下载与部署。cs文件涵盖Windows Forms界面、事件处理与数据访问逻辑,resx与resources保存界面布局及资源,mdf/ldf为可直接附加的数据库,另含项目工程与运行配置,结构完整。已有692人学习使用。项目采用VS开发环境,通过ADO.NET实现图书信息增删改查,包含Books等数据表设计,业务逻辑与数据访问分层清晰,并附有db.cs数据库连接示例及运行配置说明。使用时结合自身SQL Server调整连接字符串、附加数据库后即可运行,适合作为课程设计模板或C#入门进阶的参考项目。
1. 课程设计里的图书管理系统,到底要先做什么才能不翻车
你从老师或网上拿到一个“课程设计-基于C#的图书管理系统(源码+数据库).zip”,解压后看到几个.cs文件、一个bin文件夹和一两张数据库脚本。双击LibraryManagement.sln,按了F5,结果不是报“找不到服务器”就是“无法连接数据库”。这门课程设计的本质,是用C# WinForms搭一个前台界面,配合SQL数据库完成图书、读者和借阅管理,覆盖增删改查和借还书流程。它不算大,但足够把数据库设计、事务和界面绑定这几块基础功一起考掉。这篇文章按“数据库先行,界面随后,业务兜底”的顺序,把整套实现的每一步都拆给你看,顺带把最容易在答辩演示时翻车的坑提前踩平。
2. 从数据模型到C#项目结构:把数据库脚本先立起来
一个图书管理系统如果一上来就画窗体,十有八九做到一半要推倒重来。原因很简单:界面是水,数据模型是渠,没有渠,水只能漫。这部分先把数据库结构和连接串定下来,之后再写代码才会顺。
2.1 技术栈选择:C# WinForms搭配哪种数据库最省事
课程设计通常不允许你用控制台或者WPF,WinForms是最常见的选择,因为拖控件就能出界面,老师看着也像那么回事。数据库方面主要有两个流派:SQL Server和MySQL。
我一般建议用SQL Server Express LocalDB。理由是Visual Studio一般自带LocalDB,连接串用AttachDbFilename指向一个.mdf文件就行,不需要额外安装服务;如果老师要求用独立数据库,再换成SQLEXPRESS实例。MySQL也可以,但要装服务、装驱动,对换电脑演示来说不稳定,课程设计场景下反而是给自己加活。所以下面统一以SQL Server为例子,脚本和代码都是System.Data.SqlClient。
2.2 建库建表:图书、读者、借阅三张表的最小模型
图书管理系统的核心不是“管理图书”,而是“借还书”。所以三张表不能少:Books保存图书和库存,Readers保存读者信息,BorrowRecords保存借阅流水。库存不要用TotalCopies做加减,要单独放一个可借数量字段,否则借还书时你得去扫描历史记录算剩余量,那是给自己挖坑。
CREATE DATABASE LibraryDB; GO USE LibraryDB; GO CREATE TABLE Books ( BookID INT IDENTITY(1,1) PRIMARY KEY, Title NVARCHAR(200) NOT NULL, Author NVARCHAR(100) NOT NULL, Publisher NVARCHAR(100) NULL, PublishYear INT NULL, TotalCopies INT NOT NULL DEFAULT 1, AvailableCopies INT NOT NULL DEFAULT 1, CreatedAt DATETIME DEFAULT GETDATE() ); CREATE TABLE Readers ( ReaderID INT IDENTITY(1,1) PRIMARY KEY, ReaderName NVARCHAR(50) NOT NULL, Phone NVARCHAR(20) NULL, RegistrationDate DATETIME DEFAULT GETDATE() ); CREATE TABLE BorrowRecords ( BorrowID INT IDENTITY(1,1) PRIMARY KEY, ReaderID INT NOT NULL, BookID INT NOT NULL, BorrowDate DATETIME DEFAULT GETDATE(), DueDate DATETIME NOT NULL, ReturnDate DATETIME NULL, FineAmount DECIMAL(10,2) DEFAULT 0, CONSTRAINT FK_Borrow_Reader FOREIGN KEY (ReaderID) REFERENCES Readers(ReaderID), CONSTRAINT FK_Borrow_Book FOREIGN KEY (BookID) REFERENCES Books(BookID) );这里所有文本字段都用NVARCHAR而不是VARCHAR,因为VARCHAR存中文在SQL Server里受排序规则影响,很容易在显示时变成乱码。Identity自增主键省去手动编号的麻烦。外键约束保证不会出现“某条借阅记录指向不存在的书”这种脏数据。AvailableCopies默认值设成1,但实际插入图书时最好显式给值。
你可能觉得三张表太简单,但课程设计评分看重的是完整演示流程:能加图书、加读者、借书、还书、查记录,这就是基本盘。等这五条跑通,还有余力再考虑加“图书分类表”“管理员表”“罚款明细表”。
2.3 连接串与App.config:让换电脑不再改代码
很多学生的代码里直接写死server=localhost;uid=sa;pwd=123456,这就是换电脑演示翻车的头号原因。正确做法是把连接串放到App.config配置文件里,这样别人拿到源码时改一行就行,不用动C#代码。
<?xml version="1.0" encoding="utf-8" ?> <configuration> <connectionStrings> <add name="LibraryDb" connectionString="Data Source=(LocalDB)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\LibraryDB.mdf;Integrated Security=True;MultipleActiveResultSets=True;" providerName="System.Data.SqlClient" /> </connectionStrings> <startup useLegacyV2RuntimeActivationPolicy="true"> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" /> </startup> </configuration>注意|DataDirectory|会指向Visual Studio设置的项目目录,所以.mdf文件放项目根目录就行。上面还加了MultipleActiveResultSets=True,后面做借书操作时,如果在同一个连接上先查数据再更新,没有这个参数可能会报“已有打开的连接”。
读取连接串时用ConfigurationManager,需要在项目引用里手动加System.Configuration.dll,新手经常找半天这个坑。后面写的DbHelper类就是从这个配置里取连接串。
3. 用C#把增删改查封装成通用数据访问层
数据库建好了,现在要写C#代码。但千万别把SqlConnection和SqlCommand直接撒在窗体的按钮事件里。你当然能跑通,但笛卡尔积式的粘贴复制会害死你。先封装一个数据访问层,之后每个界面都调它。
3.1 为什么需要DAL层而不是把SqlConnection写在窗体里
课程设计代码里最常见的问题,是“查询图书”的逻辑写在Form1,查询读者的逻辑写在Form2,两者各写一套连接对象和命令对象。结果就是后期改连接串要改十个地方,出错了你在窗体代码里翻半天。
我给的建议是:新建一个DbHelper.cs,专门管连接和命令。窗体只负责传SQL和参数,拿DataTable或int结果去显示。这样做的好处有三点:第一,连接串只在DbHelper里读一次;第二,SQL参数化统一在一个地方完成,不容易漏掉参数;第三,换数据库驱动时只改一个类。
3.2 DbHelper:一个支持增删改查的小工具类
这个类用using释放资源,避免连接泄漏。方法只有三个:ExecuteNonQuery执行增删改,ExecuteScalar返回单个值,ExecuteReader返回数据流。把它当成一个安卓充电头,什么设备来了都能插。
using System; using System.Configuration; using System.Data; using System.Data.SqlClient; public static class DbHelper { private static readonly string connStr = ConfigurationManager.ConnectionStrings["LibraryDb"].ConnectionString; public static int ExecuteNonQuery(string sql, SqlParameter[] parameters = null) { using (var conn = new SqlConnection(connStr)) using (var cmd = new SqlCommand(sql, conn)) { if (parameters != null) { cmd.Parameters.AddRange(parameters); } conn.Open(); return cmd.ExecuteNonQuery(); } } public static object ExecuteScalar(string sql, SqlParameter[] parameters = null) { using (var conn = new SqlConnection(connStr)) using (var cmd = new SqlCommand(sql, conn)) { if (parameters != null) { cmd.Parameters.AddRange(parameters); } conn.Open(); return cmd.ExecuteScalar(); } } public static SqlDataReader ExecuteReader(string sql, SqlParameter[] parameters = null) { SqlConnection conn = new SqlConnection(connStr); SqlCommand cmd = new SqlCommand(sql, conn); if (parameters != null) { cmd.Parameters.AddRange(parameters); } conn.Open(); return cmd.ExecuteReader(CommandBehavior.CloseConnection); } }ExecuteReader返回的SqlDataReader用了CommandBehavior.CloseConnection,意味着当调用方执行reader.Close()时,对应的连接也会跟着关闭,这样不用在窗体里再记一个连接对象。ExecuteScalar常用于查“是否存在”和“库存还剩多少”。使用时注意,查询数值列返回的是object,要转成对应类型。
参数化是这里最重要的一点。永远不要拼字符串,比如写"WHERE BookID = " + txtBookId.Text。那样只要输入一个1 or 1=1,演示现场就成了SQL注入翻车现场。上面的代码接收SqlParameter[]数组,调用时用AddWithValue或显式new SqlParameter都行,但下面会告诉你AddWithValue其实也有坑。
3.3 图书管理窗体:用DataGridView和BindingSource绑定数据
封装好了DbHelper,下面就到了窗体和数据打交道的一步。图书管理页面要做的是把图书列表显示出来,并能对单行做修改和删除。用DataGridView绑定一个DataTable是最省心的方式。
private void LoadBooks() { string sql = @"SELECT BookID AS 编号, Title AS 书名, Author AS 作者, Publisher AS 出版社, PublishYear AS 出版年份, TotalCopies AS 总库存, AvailableCopies AS 可借数量 FROM Books ORDER BY BookID"; using (SqlDataReader reader = DbHelper.ExecuteReader(sql)) { DataTable table = new DataTable(); table.Load(reader); BindingSource bindingSource = new BindingSource(); bindingSource.DataSource = table; dataGridView1.DataSource = bindingSource; } }table.Load(reader)会把SqlDataReader的数据一次性填进DataTable,之后你就可以关闭reader。BindingSource是个中间层,当DataGridView排序或筛选时,底层的DataTable不会被弄脏。这里的SQL直接把列起中文别名,这样界面上不用额外映射,老师看起来也直观。
如果你使用DataGridView的自动生成列,记得把不需要的列设置为Visible=false,比如BookID。修改和删除时,你从当前行取原始ID值,别用显示出来的“编号”因为它是自增列,但也可以作为标识。下面这段是删除按钮的写法:
private void btnDelete_Click(object sender, EventArgs e) { if (dataGridView1.CurrentRow == null) return; int bookId = Convert.ToInt32(dataGridView1.CurrentRow.Cells["编号"].Value); string sql = @"DELETE FROM Books WHERE BookID = @BookID"; SqlParameter[] parameters = { new SqlParameter("@BookID", bookId) }; int rows = DbHelper.ExecuteNonQuery(sql, parameters); if (rows > 0) { LoadBooks(); } }这里必须先把BookID隐藏列取出来。你要是用Cells["书名"].Value去删,那等于是按名字删,重名学生一多就误删。整个窗体改动的思路就是:改DataGridView单元格、拼接UPDATE语句、调用DbHelper。
4. 借书还书流程:最容易被老师追问的业务逻辑
课程设计答辩时,老师一般不看你查图书列表,他一定会点“借书”和“还书”,然后追问“数据怎么保证一致性”。这一章是整篇的硬骨头。
4.1 借书流程:库存检查与事务,一个都不能少
借书这个动作至少涉及三件事:检查读者存在、检查这本书还有没有库存、插入借阅记录的同时把可借库存减一。这三步必须在一个事务里,否则你先减库存后插记录,插记录失败时库存就少了。
下面这段代码用DbHelper.ConnectionString重新打开连接,再用BeginTransaction包住所有步骤。
public void BorrowBook(int readerId, int bookId, int daysToBorrow = 30) { string checkReaderSql = "SELECT COUNT(1) FROM Readers WHERE ReaderID=@ReaderID"; string checkBookSql = "SELECT AvailableCopies FROM Books WHERE BookID=@BookID"; string borrowSql = @"INSERT INTO BorrowRecords(ReaderID, BookID, BorrowDate, DueDate) VALUES(@ReaderID, @BookID, @BorrowDate, @DueDate)"; string updateStockSql = @"UPDATE Books SET AvailableCopies=AvailableCopies-1 WHERE BookID=@BookID"; using (SqlConnection conn = new SqlConnection(DbHelper.ConnectionString)) { conn.Open(); SqlTransaction tx = conn.BeginTransaction(); try { // 1. 检查读者 SqlCommand cmd = new SqlCommand(checkReaderSql, conn, tx); cmd.Parameters.AddWithValue("@ReaderID", readerId); int readerCount = (int)cmd.ExecuteScalar(); if (readerCount == 0) throw new Exception("读者不存在"); // 2. 检查库存 cmd = new SqlCommand(checkBookSql, conn, tx); cmd.Parameters.AddWithValue("@BookID", bookId); int available = Convert.ToInt32(cmd.ExecuteScalar()); if (available <= 0) throw new Exception("库存不足"); // 3. 插入借阅记录 cmd = new SqlCommand(borrowSql, conn, tx); cmd.Parameters.AddWithValue("@ReaderID", readerId); cmd.Parameters.AddWithValue("@BookID", bookId); cmd.Parameters.AddWithValue("@BorrowDate", DateTime.Now); cmd.Parameters.AddWithValue("@DueDate", DateTime.Now.AddDays(daysToBorrow)); cmd.ExecuteNonQuery(); // 4. 减库存 cmd = new SqlCommand(updateStockSql, conn, tx); cmd.Parameters.AddWithValue("@BookID", bookId); cmd.ExecuteNonQuery(); tx.Commit(); } catch { tx.Rollback(); throw; } } }这段代码里有两个地方要特别说明。第一,cmd每次都被重新赋值,但都指定了conn和tx,这样事务上下文是一致的。第二,DateTime.Now.AddDays默认带时间,但如果你只想按天算,建议用DateTime.Now.Date,否则日期显示到时分秒,老师看着会觉得你故意的。
还有AddWithValue这个老朋友:它在传日期和时间时会根据数据库列类型推断,但推断经常出错。课程设计里应付排查问题不大,但我个人还是建议写cmd.Parameters.Add("@DueDate", SqlDbType.DateTime)来显式声明类型。你现在偷的懒,会在某个奇怪的时间点变成玄学bug。
4.2 还书流程:日期取整与罚款金额计算
还书要做的事比借书还多一步:更新归还日期、计算逾期罚款、把可借库存加回去。罚款逻辑通常是“超过截止日期按天算,每天0.5元”。
public decimal ReturnBook(int borrowId) { string selectSql = @"SELECT BookID, DueDate, ReturnDate FROM BorrowRecords WHERE BorrowID=@BorrowID"; string updateSql = @"UPDATE BorrowRecords SET ReturnDate=@ReturnDate, FineAmount=@FineAmount WHERE BorrowID=@BorrowID"; string restoreSql = @"UPDATE Books SET AvailableCopies=AvailableCopies+1 WHERE BookID=@BookID"; using (SqlConnection conn = new SqlConnection(DbHelper.ConnectionString)) { conn.Open(); SqlTransaction tx = conn.BeginTransaction(); try { int bookId; DateTime dueDate; SqlCommand cmd = new SqlCommand(selectSql, conn, tx); cmd.Parameters.AddWithValue("@BorrowID", borrowId); using (SqlDataReader reader = cmd.ExecuteReader()) { if (!reader.Read()) throw new Exception("借阅记录不存在"); if (reader["ReturnDate"] != DBNull.Value) throw new Exception("这本书已经归还了"); bookId = (int)reader["BookID"]; dueDate = (DateTime)reader["DueDate"]; } DateTime returnDate = DateTime.Now.Date; int overdueDays = (returnDate - dueDate).Days; decimal fine = overdueDays > 0 ? overdueDays * 0.5m : 0m; cmd = new SqlCommand(updateSql, conn, tx); cmd.Parameters.AddWithValue("@ReturnDate", returnDate); cmd.Parameters.AddWithValue("@FineAmount", fine); cmd.Parameters.AddWithValue("@BorrowID", borrowId); cmd.ExecuteNonQuery(); cmd = new SqlCommand(restoreSql, conn, tx); cmd.Parameters.AddWithValue("@BookID", bookId); cmd.ExecuteNonQuery(); tx.Commit(); return fine; } catch { tx.Rollback(); throw; } } }这里的关键是(returnDate - dueDate).Days,两个DateTime相减得到TimeSpan,Days是整天数。如果dueDate带着时间,returnDate也带了时间,你会在边界上差一天。所以还书日期强制取.Date,借书时的截止日期也建议在借书方法里写DateTime.Now.Date.AddDays(30)。
罚款的计算是业务规则,老师如果要求不罚款或按周罚款,你自己改系数就行。不要在数据库里存“逾期天数”这种派生字段,因为计算逻辑一变,历史数据就废了。
4.3 用GridView展示借阅记录并处理外键显示
借阅记录表里只有ReaderID和BookID,界面不能直接显示这两个数字,要关联图书表显示书名、关联读者表显示姓名。SQL用JOIN搞定,不要用子查询:
string sql = @"SELECT b.BorrowID AS 借阅编号, r.ReaderName AS 读者姓名, bk.Title AS 书名, b.BorrowDate AS 借书日期, b.DueDate AS 应还日期, b.ReturnDate AS 还书日期, b.FineAmount AS 罚款 FROM BorrowRecords b INNER JOIN Readers r ON b.ReaderID = r.ReaderID INNER JOIN Books bk ON b.BookID = bk.BookID ORDER BY b.BorrowID";这里我用INNER JOIN,只显示有效读者和有效图书的记录。如果你用了“软删除”方案,这种查询可能还涉及过滤,但课程设计阶段不用过度设计。DataGridView会自动生成这些列,但日期列建议你设置DefaultCellStyle.Format = "yyyy-MM-dd",不然显示出来是一长串2025-04-01 00:00:00,难看。
5. 课程设计避坑指南:五个让演示现场翻车的细节
这一章不是理论,是我和不少同学踩过坑之后的血泪经验。每一条都是先讲现象,再给原因,最后是解决。
5.1 现象:本机跑得好,换电脑就连不上数据库
答辩现场借了老师的电脑,运行程序弹出“在建立与服务器的连接时出错”。原因通常是连接串里的Data Source指向了你自己电脑的服务器实例名或(LocalDB)路径,而目标电脑上根本没这个实例。还有一个隐藏原因:.mdf文件被你放到了bin\Debug之外,|DataDirectory|定位不到数据库。
解决:把数据库文件复制到项目根目录,确保App.config中的连接串使用|DataDirectory|,同时把.mdf文件在“解决方案资源管理器”里的“复制到输出目录”改成“如果较新则复制”。这样每次生成,bin目录里都会带一份数据库。如果还是不行,就在生成后手动把数据库附加到目标电脑的SQL Server实例,并改连接串为服务器实例名。答辩前我一般会做一次“拆箱演练”:把项目复制到另一个目录,重新编译运行,看能不能起来。
5.2 现象:图书标题显示成“???”或乱码
这个坑在SQL Server里很典型。原因分三种:一是表字段用了VARCHAR而不是NVARCHAR,插入中文时因为排序规则和代码页不一致变成问号;二是插入数据时SQL字符串本身编码不对,比如你在C#代码里拼"INSERT INTO Books Title='红楼梦'",其实已经错了;三是在连接串里少了Character Set相关设置(这是MySQL的坑,SQL Server上不常见)。
解决:建表字段一律用NVARCHAR;插入语句最好参数化,避免字符串常量在编译时被错误编码;SQL脚本文件保存时编码选UTF-8 with BOM,用Visual Studio打开保存一下就行。如果已经存了乱码,只能删掉重插。所以设计表结构时就要做对,不要靠显示层补救。
5.3 现象:删除读者时提示“与外键约束冲突”
图书管理系统的借阅记录关联了读者表。你在读者管理界面选了一个有借阅记录的人点删除,数据库自然不让你删,因为BorrowRecords里的外键还指着它。
原因:你没有处理关联数据,违反了引用完整性约束。
解决:常见做法是“软删除”,给Readers表加一个IsDeleted BIT DEFAULT 0字段,删除时执行UPDATE Readers SET IsDeleted=1 WHERE ReaderID=@ReaderID,查询时加上WHERE IsDeleted=0。这样做既避免了外键冲突,又保留了借阅历史。另一个思路是删除前先检查该读者有没有未归还记录,有就提示“该读者有未还书籍,不能删除”,这比软删除更符合图书馆业务。我一般推荐两个方法结合:先提示未还书,再执行软删除。
5.4 现象:还书后计算逾期天数,总比预想的少一天或多一天
比如今天正好是截止日期,还书时系统却算你逾期1天。原因是DueDate是DATETIME,存的是2025-04-30 00:00:00,而今天的日期是2025-04-30 10:35:00,两者相减得到不足一天,但Days属性会取整。实际上同一天不应罚款。
解决:在还书方法里把DateTime.Now.Date与dueDate.Date相减。你还可以在数据库生成脚本中给DueDate设为date类型而不是datetime,SQL Server支持date类型,只存日期,不存时间。这样C#里读到的是DateTime类型,但只要比较前都.Date,就稳定了。这也是我要把“日期取整”当作单独一条列出来的原因,因为它在借书和还书两处都有坑。
5.5 现象:DataGridView里改了单元格,点保存没反应或数据丢
新手最容易犯的错误是把DataGridView当成表格控件,以为编辑完就自动写数据库。实际上它只是内存里显示数据,要保存必须自己写UPDATE语句。
解决:绑定CellEndEdit事件,在该事件里取当前行的各字段值,调用DbHelper.ExecuteNonQuery执行UPDATE。也可以用数据适配器(SqlDataAdapter)自动生成更新语句,但课程设计里不建议,因为自动生成的UPDATE语句会包含所有列,当你需要只更新某几个列时,反而难控制。更简单的做法:点击“保存”按钮时遍历DataTable的Rows,对状态为Modified的行逐条更新。注意,如果你的DataGridView里有些列是关联查询出来的别名,比如“读者姓名”,这些列不能直接更新,你得把它们设为只读。
6. 答辩前的验证清单:让数据自己说话
做完功能只是第一步,你还要能证明它“对啊”。我自己的习惯是准备两份东西:一份是干净的数据初始化脚本,一份是手动的场景测试脚本。
先准备一个InitData.sql,在里面插入两个读者、三本图书,其中一本故意设置AvailableCopies=0,方便演示“库存不足”的场景。答辩时先运行这个脚本,保证每次演示起点一致。
INSERT INTO Readers (ReaderName, Phone) VALUES (N'张三', N'13800138000'); INSERT INTO Readers (ReaderName, Phone) VALUES (N'李四', N'13900139000'); INSERT INTO Books (Title, Author, Publisher, PublishYear, TotalCopies, AvailableCopies) VALUES (N'C#高级编程', N'Simon', N'人民邮电出版社', 2020, 3, 3); INSERT INTO Books (Title, Author, Publisher, PublishYear, TotalCopies, AvailableCopies) VALUES (N'CLR via C#', N'Jeffrey Richter', N'清华大学出版社', 2023, 1, 0);然后按这个顺序走一遍:查图书列表 -> 给张三借一本C#高级编程-> 再看列表库存变成2 -> 还书 -> 库存恢复成3 -> 尝试借CLR via C#,系统提示库存不足。整个过程录下来,比任何口头解释都有效。
另一个技巧是给DataGridView加一行“当前选中读者/图书”的状态提示,比如在借书按钮里先判断dataGridView1.CurrentRow是否为空,再取BookID。这不算高级功能,但能让老师感觉你考虑到了边界情况。借书成功的弹窗里,把应还日期和罚款规则一起显示出来,也是一种“业务闭环”的证明。
最后,我想说一句:课程设计评分不看你用了多少酷炫控件,而看你数据是不是一致、边界是不是想清楚了。我当年就是把连接串写死在代码里,结果演示到一半数据库连不上,脸都绿了。所以一定要在交源码前做一次从零开始的解压、还原、编译、运行。希望帮到你。
本文还有配套的精品资源,点击获取