简介:这是一套基于C#与ASP.NET Web Forms开发的会议室预约管理系统完整源码,适合C#学习者用于课程设计、毕业设计或企业内部系统二次开发参考。系统覆盖会议室信息维护、在线预约、预约取消、用户及管理员后台管理等典型业务模块,代码结构清晰,便于快速理解完整业务闭环。资源包共177个文件,压缩后仅1.8MB,包含28个cs后端逻辑文件、25个aspx页面文件、8个js与8个css前端交互文件、2个数据库文件(db/mdb),以及gif、jpg等界面素材与可直接打开的sln解决方案,可结合Visual Studio加载运行。目前已有182人学习,通过adminDel、hysDel、classDel、wyyy、qxyyDel等页面文件,可对照学习会议室增删改查、冲突检测、表单权限校验等核心功能的实现思路;数据库脚本与mdb文件也一并提供,方便本地部署调试。整体来看,这份源码不仅展示了C#面向对象设计、ADO.NET数据访问、前端Ajax交互的配合方式,也为后续扩展功能或重构为MVC架构提供了不错的实践范本。
1. 会议室预约系统:先用一个真实问题把它说清楚
上个月朋友公司行政还在用纸质表格登记会议室,周五下午黄金时段被同一间房订了三次,三个部门的会议撞到一起,场面相当难看。我直接把这套C#会议室预约系统源码翻出来改配置、连库、跑通,半天时间就把这套流程搬到了他们内网,从那以后再也没有发生过“我明明订了为什么有人坐着”的内耗。这套源码不是什么云端 SaaS,而是一个经典的 C# 桌面端/Winform 工程,覆盖了会议室录入、预约申请、冲突检测、时段校验、记录管理、状态变更这些核心环节,数据层用了 SQL Server(也可以按配置换成 Access),界面层是标准的 DataGridView 加 ComboBox 联动。适合三类人:刚学完 C# 想做课设的在校生、需要内部工具但不想买商业软件的小团队、想学习经典三层架构和事务处理的进阶开发者。它是能直接跑起来、也能直接拆开的黑匣子,接下来我把架构、数据表、核心逻辑、部署和踩坑全部过一遍。
2. 拆包看结构:三层架构、数据表与预约状态机
2.1 目录长什么样,先看负责人怎么搭骨架
解压之后建议先用 Visual Studio 打开根目录下的.sln解决方案文件,别急着点运行。先看解决方案里的项目结构,这套源码的骨架如果按传统三层来对应,大致是下面这个模样,常见的目录划分如下:
MF00535-CSharpMeetingRoom/ |-- MeetingRoom.sln |-- MeetingRoom.UI // WinForm 界面层:登录窗体、主窗体、预约窗体 |-- MeetingRoom.BLL // 业务逻辑层:冲突检测、预约状态流转、合法性校验 |-- MeetingRoom.DAL // 数据访问层:SQLHelper、参数化查询、数据表操作 |-- MeetingRoom.Model // 实体类:MeetingRoomInfo、ReservationInfo、UserInfo |-- SQLScripts/ // 建库建表脚本和初始化数据 |-- app.config // 连接字符串、数据库类型配置这套结构最值得看的地方在于界面层没有直接拼接 SQL 字符串,所有数据库操作都集中在 DAL 层,界面拿到的数据是业务层返回的结果。常见做法是 UI 只负责显示和收集用户输入,BLL 负责业务规则判断,DAL 层做参数化查询,这种分层的好处在于后期如果要把窗体换成 WPF 或 Web API,业务规则和数据访问层可以原样复用。
需要留意实际解压后文件夹命名可能和上面略有出入,但换汤不换药:凡是看到Forms或UI开头的放在界面层,BLL是业务层,DAL是数据层,Model是实体模型。如果你打开后发现项目只有一个大文件夹,所有.cs混在一起也不要意外,那就说明原作者的工程没有严格分层,你需要自己按命名空间把它们归位,这一步对后续改冲突检测逻辑很重要。
2.2 数据表设计:一张预约记录如何覆盖占用和冲突
预约系统的数据表看起来简单,做起来容易翻车。核心是MeetingRoomInfo和ReservationInfo两张表,前者保存会议室的基础信息,后者保存谁在什么时候用了哪间房。下面是我比较推荐的基础结构,这套源码里的数据表也基本是按这个思路做的:
CREATE TABLE MeetingRoomInfo ( RoomId INT IDENTITY(1,1) PRIMARY KEY, RoomName NVARCHAR(50) NOT NULL, Location NVARCHAR(100), Capacity INT NOT NULL DEFAULT 10, HasProjector BIT NOT NULL DEFAULT 0, Status BIT NOT NULL DEFAULT 1 -- 1可用 0禁用 ); CREATE TABLE ReservationInfo ( ReservationId INT IDENTITY(1,1) PRIMARY KEY, RoomId INT NOT NULL, UserId INT NOT NULL, BookDate DATE NOT NULL, StartTime TIME NOT NULL, EndTime TIME NOT NULL, Subject NVARCHAR(200), Status INT NOT NULL DEFAULT 0, -- 0待审核 1已通过 2已拒绝 3已结束 4已取消 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT FK_Reservation_Room FOREIGN KEY (RoomId) REFERENCES MeetingRoomInfo(RoomId) );字段设置有几个地方決定系统好不好用。Status用数字而不用字符串,业务层直接做整数判断效率更高,而且方便以后扩展。时间段用TIME类型而不是DATETIME,预约不需要关心日期时间戳的具体秒数,日期单独用BookDate管理,这样“2026年3月27日下午两点到四点”直接写成BookDate='2026-03-27'+StartTime='14:00'+EndTime='16:00'。
关于“占用”和“冲突”的定义完全靠StartTime和EndTime的比较来算。两条预约在同一房间、同一天,而且 A 的结束时间大于 B 的开始时间、A 的开始时间小于 B 的结束时间,就会发生重叠。这个重叠条件设计成状态机之后,“已拒绝”和“已取消”的记录在冲突检测时直接跳过,而不是物理删除。保留记录的好处是后续可以查历史、做统计,也方便管理员看到谁经常取消。
2.3 预约状态机为什么不用 DELETE
我看到很多初学者在上删除功能时直接把记录从数据库里抹掉,会议室预约系统如果这样做会丢审计数据。这套源码里的状态流是0 待审核 -> 1 已通过 -> 3 已结束,或者0 待审核 -> 2 已拒绝 / 4 已取消。状态机的好处在于每一步都留有依据,之后统计会议室使用率可以按Status=3的已结束记录来算,比无差别统计有效得多。
如果你接手时需要加“会议室管理员手动释放房间”的功能,不要去 DELETE,加一个状态5 已释放就行。业务层判断逻辑只需要改IsRoomAvailable方法,把Status NOT IN (4, 5)作为可用预约的条件。这样状态机和业务规则是解耦的,不会引发数据不一致的问题。
3. 核心流程实现:预约冲突检测、时段校验与界面联动
3.1 冲突检测的写法,网上十个版本九个错
预约系统最核心的代码就是冲突检测。网上很多版本的“冲突”写法只判断了包含关系(比如 newStart 是否落在 oldStart 和 oldEnd 之间),结果漏掉了跨边界的情况。比如已有一条09:00-11:00的预约,新增一条10:30-12:00就是跨出边界;新增一条08:00-09:30是跨入边界;新增一条10:30-11:30是完全包含在内部;新增一条08:00-12:00是完全覆盖原有区间。这四种情况都算冲突,所以最稳妥的判断只有一条逻辑:start < oldEnd && end > oldStart。
public bool IsTimeConflict(int roomId, DateTime bookDate, TimeSpan start, TimeSpan end) { if (start >= end) { throw new ArgumentException("结束时间必须大于开始时间"); } string sql = @" SELECT COUNT(1) FROM ReservationInfo WHERE RoomId = @RoomId AND BookDate = @BookDate AND Status IN (0, 1) AND StartTime < @EndTime AND EndTime > @StartTime"; SqlParameter[] parameters = new SqlParameter[] { new SqlParameter("@RoomId", SqlDbType.Int) { Value = roomId }, new SqlParameter("@BookDate", SqlDbType.Date) { Value = bookDate }, new SqlParameter("@StartTime", SqlDbType.Time) { Value = start }, new SqlParameter("@EndTime", SqlDbType.Time) { Value = end } }; int count = SqlHelper.ExecuteScalar(sql, parameters); return count > 0; }这段代码的含义很直白:如果已存在一条预约,它的开始时间早于“新预约的结束时间”,而且它的结束时间晚于“新预约的开始时间”,那么两个时间段一定有重叠。四条边界情况全部被这个公式覆盖,不需要写分支判断。
需要注意两点。第一,Status IN (0, 1)让“待审核”和“已通过”一起参与冲突判断,避免出现两个待审核预约互相不知道对方存在,都通过了之后撞车的情况。第二,判断时用的是@EndTime和@StartTime参数,而不是拿数据库的字段和固定值比较,这样 SQL 执行效率没有问题,也避免了TimeSpan转换字符串带来的格式麻烦。
有些读者会问,如果预约时长超过一天怎么办,比如周五晚六点到周六早九点。这套源码的BookDate是单日字段,理论上是支持不了的。工作中我一般会加一个EndDate字段,然后改成(StartTime < EndTime OR BookDate < EndDate)这种更复杂的条件,但这套源码里没做,我建议不要为了这个需求给数据表强行打补丁,不如把“跨天预约”做成一个独立的功能模块。
3.2 界面层联动:DataGridView 按日期刷新会议室状态
界面上最常见的需求是:用户选一个日期,左侧 DataGridView 显示当天的预约情况;再选一个会议室,右侧显示该会议室的空闲时段。实现起来就是一个 ComboBox 的SelectedIndexChanged事件里触发表格刷新。
private void cmbRoom_SelectedIndexChanged(object sender, EventArgs e) { if (cmbRoom.SelectedValue == null) return; int roomId = (int)cmbRoom.SelectedValue; DateTime date = dtpBookDate.Value.Date; DataTable dt = GetReservationsByRoomAndDate(roomId, date); dgvReservations.DataSource = dt; dgvReservations.Columns["ReservationId"].Visible = false; dgvReservations.Columns["RoomId"].Visible = false; dgvReservations.Columns["StartTime"].HeaderText = "开始时间"; dgvReservations.Columns["EndTime"].HeaderText = "结束时间"; dgvReservations.Columns["Status"].HeaderText = "状态"; }这段代码的重点是GetReservationsByRoomAndDate要把Status翻译成用户能看懂的文字。在 SQL 里可以直接用CASE WHEN Status=0 THEN '待审核' ...来生成一个别名列,这样 DataGridView 绑定出来的列就是中文状态,不需要在界面里再做二次循环翻译。另一个细节是调用SelectedValue之前先判断是否为 null,否则页面初次加载时事件被触发会抛空引用,这是 WinForm 里非常经典的翻车点。
3.3 新增预约:事务里只做该做的事
插入预约记录本身很简单,难点在于多人同时操作时不能出现“两个人都在同一个时段提交成功”的情况。SQL Server 下最简单有效的方式是:在冲突检测和 INSERT 中间用事务把查询和插入包起来,加上UPDLOCK, HOLDLOCK表级锁提示,这样第二次进来的提交会等待第一个事务完成后再检查。
public bool CreateReservation(ReservationInfo reservation) { using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); using (SqlTransaction tran = conn.BeginTransaction()) { try { string checkSql = @" SELECT COUNT(1) FROM ReservationInfo WITH (UPDLOCK, HOLDLOCK) WHERE RoomId = @RoomId AND BookDate = @BookDate AND Status IN (0, 1) AND StartTime < @EndTime AND EndTime > @StartTime"; // 第一步:查冲突 int conflictCount = SqlHelper.ExecuteScalar(checkSql, tran, parameters); if (conflictCount > 0) { tran.Rollback(); return false; } // 第二步:插入,把状态写成“待审核” string insertSql = @" INSERT INTO ReservationInfo (RoomId, UserId, BookDate, StartTime, EndTime, Subject, Status) VALUES (@RoomId, @UserId, @BookDate, @StartTime, @EndTime, @Subject, 0)"; SqlHelper.ExecuteNonQuery(insertSql, tran, parameters); tran.Commit(); return true; } catch { tran.Rollback(); throw; } } } }加了WITH (UPDLOCK, HOLDLOCK)之后,第一条事务没结束之前,第二条事务的SELECT COUNT会一直等待。常见的误区是只做“先查后插”而不加锁,这种查完了别人插进去、自己再插又成功的情况,在真实使用中一定会遇到。锁粒度没必要做得太精细,会议室预约本身就属于低频写入系统,锁表完全能接受。
4. 部署与配置:改三个地方就能在自己机器上跑起来
4.1 数据库准备:先执行建表和初始化脚本
拿到源码后第一件事是把SQLScripts目录下的建表脚本执行一遍。如果源码包里没有单独的.sql文件,可以打开DbHelper.cs或者SQLHelper.cs看连接字符串指向的库名,然后手动创建同名数据库。
-- SQLScripts/01_CreateTables.sql IF DB_ID('MeetingRoomDB') IS NULL CREATE DATABASE MeetingRoomDB; GO USE MeetingRoomDB; GO -- 执行前面 2.2 节的两张建表语句 -- 插入一些默认会议室数据,方便界面跑起来就能看到东西 INSERT INTO MeetingRoomInfo (RoomName, Location, Capacity, HasProjector, Status) VALUES ('第一会议室', 'A栋301', 12, 1, 1), ('第二会议室', 'A栋302', 8, 0, 1), ('第三会议室', 'B栋201', 20, 1, 1);初始化数据不用插太多,三间会议室就够验证功能了。插入几条历史预约记录时要注意BookDate用相对日期,比如GETDATE()或最近一周内的日期,这样界面打开就能看到数据,而不是空表。
4.2 连接字符串:不要把密码写死在代码里
连接字符串通常在app.config或App.config里。打开后你看到的内容大概长这样,需要按自己本机的 SQL Server 实例修改:
<configuration> <connectionStrings> <add name="MeetingRoomDB" connectionString="Server=.;Database=MeetingRoomDB;Integrated Security=true;" providerName="System.Data.SqlClient" /> </connectionStrings> </configuration>我自己的一贯做法是连接串只写在配置文件里,代码中不允许出现Server=...;Password=...的硬编码。Server=.表示本机的默认实例,如果你的 SQL Server 是命名实例,就要写成Server=你的机器名\\SQLEXPRESS这种形式,这里是最容易被坑的。
4.3 最小可运行清单:按顺序检查这五项
跑不起来的时候不要乱猜,按下面这个顺序过一遍,绝大多数问题都能定位。以下是一种可用的检查清单,也可以作为自己的环境确认表。
| 检查项 | 常见现象 | 处理建议 |
|---|---|---|
| .NET Framework / .NET 版本 | 启动报 MissingMethodException | 用 Visual Studio Installer 安装目标框架 |
| SQL Server 实例名 | 连不上,报服务器名称找不到 | 确认 Server 后面写的实例名和数据库引擎一致 |
| 数据库是否存在 | 登录后打开窗体报对象名无效 | 先手动执行 SQL 脚本建库建表 |
| SQL Server 端口 | 远程部署时连接超时 | 本地测试用127.0.0.1验证,远程再调 TCP/IP 配置 |
| Windows 登录认证 | 用密码登录失败 | 改Integrated Security=true,在本地跑最省事 |
这套源码默认是本地单机 Windows 登录认证方式,跑通之后再考虑改 SQL Server 账号和密码登录。部署到局域网时,记得检查 Windows 防火墙是否放行了 1433 端口,这个属于 SQL Server 远程访问的基础问题,和 C# 代码关系不大,但每次我帮同事部署时都会遇到一次。
5. 避坑排查:五个最容易翻车的点与真实原因
5.1 明明没人预约,却提示时间冲突
现象:新增一条 9:00-10:00 的预约,系统提示和已有记录冲突,但当天根本没有通过状态的预约,手工查库也看不到任何记录。
原因:冲突检测只做了Status = 1(已通过)的判断,而系统里存在一条被退订或被拒绝但未删除的旧记录。比如之前有人预约了同一时段,后来又取消了,状态是 4(已取消),但有些版本的检测逻辑把所有状态都算进去了。
解决:改查询条件,只让真正占用的状态参与冲突判断。比较合理的做法是Status IN (0, 1),或者至少把 2、4 排除掉。我曾经在排查时发现一个更隐蔽的问题:自己写测试数据时把状态写成了5,不存在的枚举值被数据库接受,但业务层根本没处理,这一段全靠调试才挖出来。
5.2 会议室明明被占了,DataGridView 里却显示空
现象:数据库中有预约数据,但界面选择“第一会议室”和当天日期,表格里一行数据都不显示。
原因:界面层绑定了 DataTable 之后,列名和数据库字段大小写不匹配,或者GetReservationsByRoomAndDate方法里把BookDate参数传错了,例如用了DateTime.Now而不是界面上选择的dtpBookDate.Value.Date。
解决:先在 SQL Server Management Studio(SSMS)里直接执行那个查询语句,用EXEC或者直接SELECT看看有没有结果。如果 SQL 有结果而界面没有,检查 DataGridView 的AutoGenerateColumns是不是被改成false,同时确认没有用dgv.DataSource = null把它重置掉。
5.3 结束时间等于开始时间,怎么不认为是非法输入
现象:用户选了 14:00 到 14:00,系统照样保存成功,后续状态一路显示“已通过”,造成数据污染。
原因:前端下拉框的时间控件只精确到分钟,而业务层没有在入口做开始时间小于结束时间的校验。有的版本把StartTime < EndTime补上了,但只在校验方法里做,保存方法没有复用这个方法,于是绕过了检查。
解决:在 BLL 层的CreateReservation方法开头统一做if (start >= end) throw new ArgumentException(...),不要让 UI 层的 CheckBox 和 ComboBox 分别校验。把校验推到业务层,是一种比较保险的统一收口做法,UI 将来换掉之后规则仍然保留。
5.4 SQL Server 换成 Access 之后,TIME 类型的查询全部报错
现象:源码默认连 SQL Server,但拿到手之后把连接串改成 Access 的.accdb数据库,查询时频繁报FROM子句语法错误或参数类型不匹配。
原因:Access 的Jet/ACE引擎对TIME类型支持不友好,而且@参数语法要改成?占位符。如果对ORDER BY StartTime再加上别名或括号,很容易触发 SQL 语法兼容性问题。
解决:如果确实要用 Access,我建议不要硬改原有 SQL,而是新建一套AccessSqlBase.cs,把所有 SQL 语句里的@RoomId换成?,同时把TIME字段在 C# 代码里转成DateTime或string再比较。说句实话,工作中我更推荐直接把本地部署的数据库换成 SQL Server Express,因为功能和维护成本都更省心,Access 在并发写入上实在太痛苦了。
5.5 部署到服务器之后,客户端连不上数据库
现象:本机跑得好好的,把程序拷到另一台电脑上就提示“在建立与服务器的连接时出错”或者“对方计算机积极拒绝”。
原因:典型的连接字符串问题。服务器上的Server=.只代表数据库服务器本机,客户端电脑连不上;另一个常见原因是 SQL Server 的 TCP/IP 协议默认可能被禁用。
解决:数据库服务器上确认 TCP/IP 已启用并监听 1433,然后在客户端 app.config 里把地址改成真实的 IP,比如Server=192.168.1.100;Database=MeetingRoomDB;Integrated Security=true;。如果是跨域环境需要 SQL 认证,连接串里补User Id=sa;Password=***;。切记发布给同事用的时候,连接串一定要放到和exe同目录的.config文件里,不要编译进程序集。
6. 进阶验证:围绕四个场景做测试,再动手加一个提醒扩展
拿到这套源码之后不要直接交付,建议自己做一轮完整的测试,这是熟悉系统最好的方式。我习惯的测试清单是四个场景:正向预约、时间重叠拦截、取消后重约、会议室禁用后不可选。正向预约验证能保存成功;时间重叠拦截验证状态为 0、1 的两条记录都能挡住;取消后重约验证状态 4 的记录不再参与冲突检测;禁用会议室验证Status=0的会议室不出现在下拉框里。这四个场景覆盖了这套系统 80% 的业务逻辑,跑通之后才敢说这个系统能交给别人用。
测试通过后如果还想进阶一步,可以试着加一个“会议开始前 30 分钟弹窗提醒”的功能。在 WinForm 主窗体上放一个System.Windows.Forms.Timer,每隔一分钟扫描一次当天已经通过状态的预约,凡是在当前时间 30 分钟内开始且没有提醒过的,就弹一条提示并标记为已提醒:
private void timerRemind_Tick(object sender, EventArgs e) { string sql = @" SELECT R.Subject, R.StartTime, M.RoomName FROM ReservationInfo R INNER JOIN MeetingRoomInfo M ON R.RoomId = M.RoomId WHERE R.BookDate = @Today AND R.Status = 1 AND R.StartTime > @Now AND R.StartTime <= @RemindThreshold AND R.ReservationId NOT IN (SELECT ReservationId FROM RemindedList)"; // 对返回的每一行执行 MessageBox.Show / 或者用 NotifyIcon 做静默提醒 // 每处理完一条记录,往 RemindedList 表或内存 HashSet 里写一条标记 }这里要注意:timerRemind_Tick不要直接在事件里弹MessageBox,否则一分钟一次的扫描频率和弹窗阻塞会把界面卡死。更好的做法是把提醒信息放进一个队列,主线程用BeginInvoke异步处理,或者直接用NotifyIcon.ShowBalloonTip做右下角气泡提示。这个扩展做完之后,“预约系统”就算是真正能用了。
我自己的习惯是把这套源码拆完、改完、测试完之后,把一张“测试记录表”放到 SQLScripts 目录下,里面记录每一条测试用的预约数据和预期结果。从那以后每次接手新的预约系统,我都是强制自己先执行 SQL 脚本、再跑一遍四个场景、最后才敢动业务代码。这套方法虽然老,但在会议室这种低频高冲突场景里从来不会漏事,希望帮到你。
本文还有配套的精品资源,点击获取