
简介基于C#WPF实现的RFID学生考勤系统完整项目源码面向学习WPF桌面开发与RFID刷卡应用的学生和开发者可应用于课程设计、毕业设计或企业考勤场景。系统支持教师、学生、管理员三种角色登录具备刷卡签到、请假申请、账号注册、学生信息查询等功能学号输入既可手动键入也可通过USB读卡器自动读取新增学生时绑定卡号完成注册。压缩包共180个文件约21.78MB包含C#源文件.cs、WPF界面文件.xaml、动态库.dll、可执行程序.exe、配置文件.config及SQLite数据库.db并附PDF和CHM文档便于阅读和调试。目前已有527人浏览学习是考勤系统开发中较实用的参考资料。通过源码可掌握WPF界面布局、SQLite数据操作、角色权限控制和读卡器对接流程结构清晰且易于扩展适合在课程设计、毕业设计或企业项目中作为二次开发蓝本。1. 基于C#与WPF造一台能扛住早高峰的RFID考勤机做过学校或企业考勤项目的人都知道考勤系统的难点从来不在「读卡」这一个动作上而在数据链路RFID读写器把卡号送上串口上位机要稳定接收、解析、查库、判定状态最后在WPF界面上实时弹出一条记录。任何一个环节掉链子早高峰一场两三百次刷卡就能把界面卡死或丢数据。本文要讲的就是这条链路的完整搭建思路。我会按一套最常见的从业方案展开硬件层用串口对接RFID读写器应用层用WPF配合MVVM组织界面与逻辑数据层落库并做考勤判定最后收尾处理高频刷卡、界面刷新卡顿和断线重连。适合正在做C#上位机、用WPF做桌面管理系统的开发者和运维人员参考。即使你手里没有实物设备文中代码也可以先用虚拟串口工具模拟数据流来跑通。2. RFID读写器串口通信先让卡号稳定出现在C#程序里2.1 为什么绝大多数RFID考勤读写器都走串口市面上面向考勤场景的RFID读写器常见接口主要有三种串口RS232/RS485、USB转串口和USB HID。其中USB HID设备虽然即插即用体验好但在C#里需要额外处理HID层的通信且通常还要安装厂商提供的SDK才能拿到卡号。而串口的优势在于协议透明、无厂商锁定几乎任何一本C#串口编程书都会覆盖后期换硬件品牌时只需要改帧解析框架不必重写。所以对学生考勤这类对成本敏感的C# Windows桌面项目更稳妥的做法是选择支持串口输出卡号的RFID读头常见有R2000芯片方案、ID卡/IC卡读头并用USB转串口线接到电脑。对开发者来说串口还有一个隐含好处排障时直接用串口调试助手看数据问题出在硬件还是软件一眼就能分清。2.2 打开串口的最短代码序列先看一段最基础的C#串口初始化代码。我们的目标是程序启动后自动打开指定串口并在读卡器刷到卡时把卡号输出到控制台。这是整个考勤系统的第一块地基。using System.IO.Ports; public class RfidSerialPort { private SerialPort _port; public void Init(string portName, int baudRate) { _port new SerialPort(portName) { BaudRate baudRate, // 常见值 9600 或 115200看设备说明书 DataBits 8, StopBits StopBits.One, Parity Parity.None, ReadTimeout 500 }; _port.DataReceived OnDataReceived; // 串口有数据时触发 _port.Open(); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var sp (SerialPort)sender; string data sp.ReadExisting(); // 读取当前缓冲区全部可见字符 Console.WriteLine(收到原始数据: data); } }DataReceived事件会在串口接收缓冲区有数据时自动触发不需要我们自己起线程轮询这是C#串口编程中最常用的模型。ReadExisting()返回缓冲区里所有可读字符适合长度不固定或者可能分帧到达的数据如果读卡器一次只发一张卡号直接用它就行。这里有一个容易被忽视的参数baudRate必须与读写器侧配置一致否则收到的全是乱码。大多数低成本ID读卡器出厂默认是9600而一些支持韦根输出的读头在被设置成串口透传模式后可能是115200。建议初始化代码把波特率做成配置项放在App.config或appsettings.json里启动时读取而不是写死在类内部。2.3 帧解析ASCII帧与HEX帧的处理策略不同厂家的RFID读写器在同一波特率下的帧格式并不一致大体分两类。第一类最简单直接输出ASCII字符序列例如某款USB读卡器刷卡后返回020000140AB6这样的十六进制可见字符。这类数据直接用ReadExisting()拿到字符串再做一次规整化即可。例如把字符串转成10位统一长度string raw sp.ReadExisting().Trim(); string cardNo raw.Replace( , ) // 去掉可能存在的空格 .Replace(\r, ).Replace(\n, ); // 有些设备会在卡号前加命令头前缀比如 A0 01需要按固定偏移截取 if (cardNo.Length 14) { cardNo cardNo.Substring(cardNo.Length - 10, 10); }第二类是HEX二进制帧常见于工业级读写器。比如返回AA BB 00 0C 01 02 03 ...卡号混在字节数组里。对这种帧ReadExisting()不能直接用因为它会把十六进制字节解释成不可见字符。此时应该用Read(byte[], int, int)把数据读成字节数组再解析。private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var sp (SerialPort)sender; int bytesToRead sp.BytesToRead; byte[] buffer new byte[bytesToRead]; sp.Read(buffer, 0, bytesToRead); // 假设帧格式为: 帧头(0xAA) 长度(1字节) 卡号(4字节) 校验(1字节) if (buffer.Length 7 || buffer[0] ! 0xAA) return; int length buffer[1]; if (length ! 4) return; uint cardNum (uint)((buffer[2] 24) | (buffer[3] 16) | (buffer[4] 8) | buffer[5]); Console.WriteLine(卡号: cardNum.ToString()); }这段代码的逻辑要点在于先验证帧头与长度再从固定偏移取卡号。注意字节序可能因设备不同而相反如果你算出来的卡号是反的把位移方向调换一下即可。收尾再用CRC或累加和校验帧尾确保不是噪声数据。3. 用WPF MVVM架构搭考勤主界面把刷卡数据变成可见记录3.1 WPF项目从MVVM开始界面刷新才有边界串口事件一旦在后台线程触发它就带来一个天然矛盾DataReceived事件运行在线程池线程上而WPF的UI控件只能在UI线程操作。初学者最常见的做法是在事件里直接this.DataGrid.Items.Add(...)第一次跑通没问题刷卡频率一高界面就卡。根本原因在于没有把数据流向和UI线程边界分开。WPF MVVM模式在这里的最大价值不是「代码好看」而是它强制你建立一个数据从串口→ViewModel→View的单向流。界面只绑定ObservableCollectionT后台线程只更新数据UI通过数据通知刷新自己。这样即使刷卡速率高也不至于让UI线程在事件回调里做太多操作。我建议基于.NET 8创建一个新的WPF项目并引入CommunityToolkit.Mvvm包它提供了ObservableObject和RelayCommand比手写INotifyPropertyChanged省一半样板代码。结构大概如下/Models Student.cs AttendanceRecord.cs /Services SerialService.cs AttendanceService.cs /ViewModels MainViewModel.cs /Views MainWindow.xaml3.2 ViewModel封装串口数据刷一张卡显示一条记录ViewModel的核心逻辑是订阅串口服务解析出的卡号事件查询学生信息生成一条考勤记录并追加到集合里。下面是一个极简实现public partial class MainViewModel : ObservableObject { private readonly SerialService _serial; private readonly AttendanceService _attendance; public ObservableCollectionAttendanceRecord Records { get; } new(); [ObservableProperty] private StudentInfo currentStudent; public MainViewModel() { _serial new SerialService(); _serial.CardReaded OnCardReaded; _attendance new AttendanceService(); } private void OnCardReaded(object? sender, string cardNo) { var student _attendance.FindStudentByCard(cardNo); if (student null) return; CurrentStudent student; var record _attendance.MakeRecord(student); // 注意ObservableCollection 不能在后台线程直接 Add App.Current.Dispatcher.Invoke(() { Records.Insert(0, record); // 插在头部最新记录在最上面 }); } }App.Current.Dispatcher.Invoke把集合写入操作切回UI线程。这个写法的好处是串口线程只负责接收和解析卡号UI集合的操作统一收口在Dispatcher里换作System.Windows.Threading.Dispatcher的BeginInvoke也一样能用。当刷卡量非常大的时候可以考虑用DispatcherPriority.Background降低UI线程抢占的优先级让窗口拖拽操作优先响应。3.3 DataGrid实时刷新与考勤状态可视化主界面通常是三个区域顶部当前刷卡人信息、中部DataGrid显示全部考勤记录、底部统计图表。DataGrid绑定到Records集合后刷新是自动的不需要手动加ItemsSource ...之类的代码。列定义示例如下DataGrid x:NamegridRecords ItemsSource{Binding Records} DataGrid.Columns DataGridTextColumn Header学号 Binding{Binding StudentId} Width100/ DataGridTextColumn Header姓名 Binding{Binding StudentName} Width120/ DataGridTextColumn Header刷卡时间 Binding{Binding SwipeTime} Width160/ DataGridTemplateColumn Header状态 Width80 DataTemplate TextBlock Text{Binding Status} Foreground{Binding StatusColor}/ /DataTemplate /DataGridTemplateColumn /DataGrid.Columns /DataGrid状态字段结合考勤判定逻辑例如「正常」显示绿色、「迟到」显示橙色、「缺勤」显示红色。AttendanceRecord里可以暴露一个只读属性StatusColor用SolidColorBrush返回绑定器会自动转换。如果还想在底部加一个当日到班统计的环形图可以接入LiveCharts2这个WPF图表库把Records按状态分组后形成ISeries[]数组再绑定。它的实时更新性能对几百条记录完全够用不需要额外优化。4. 考勤判定逻辑与落库迟到、缺勤、重复刷卡怎么处理4.1 先设计数据库表再写判定代码考勤系统的表结构并不复杂常见设计是学生表和考勤记录表两张核心表。学生表保存基本信息与RFID卡号考勤记录表每刷一次卡写一行。下面是SQLite的建表语句也可以换成SQL Server或MySQL仅在连接字符串上稍有区别。CREATE TABLE student ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_no TEXT UNIQUE, student_name TEXT, card_no TEXT UNIQUE, class_name TEXT, enabled INTEGER DEFAULT 1 ); CREATE TABLE attendance_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_id INTEGER, swipe_time DATETIME, attendance_date TEXT, status TEXT, late_minutes INTEGER DEFAULT 0, FOREIGN KEY(student_id) REFERENCES student(id) );attendance_date单独存一个日期字符串yyyy-MM-dd是为了按天查询方便。late_minutes记录迟到分钟数后续做统计可以直接SUM不必每次从时间字段算。这张表建议在student_id和attendance_date上建立联合索引避免每天上万条数据后查询变慢。4.2 时间段判定早上、下午、晚自习场景上课时间通常分为多个班次比如上午第一节08:00下午第一节14:00晚自习19:00。每种班次下「迟到」「缺勤」的判定阈值可以独立配置。我一般会做一个TimeSlot配置类把班次时间内容存成json配置文件避免需要改判定逻辑时重新编译程序。判定逻辑代码大约是这样public AttendanceResult CheckAttendance(StudentInfo student, DateTime now) { var time now.TimeOfDay; var config _slotConfig.GetSlotForTime(time); if (config null) return new AttendanceResult(非考勤时段, 0); var lateThreshold config.StartTime.Add(TimeSpan.FromMinutes(config.LateMinutes)); if (time config.StartTime) { return new AttendanceResult(正常, 0); } if (time lateThreshold) { return new AttendanceResult(迟到, (int)(time - config.StartTime).TotalMinutes); } return new AttendanceResult(缺勤, 0); }这里有一个业务上的歧义点一位学生在下午上课前刷卡系统判他「正常」但如果他实际是上午缺勤下午才到怎么处理常见做法是「按当前最近一个班次判定」上面代码正是这个思路。更精细的做法需要结合「该学生当天的上一条记录」判断连续缺席情况多数场景用不到先把第一版做对更重要。4.3 重复刷卡与漏刷卡早高峰场景里同一个人可能在一分钟内被同学拿错卡刷了两次或者自己重复刷。系统不能简单再把第二次也记录为一次有效考勤。我采用以下规则同一学生同一考勤时段只保留第一条记录若第一条是「迟到」后面再刷不更新状态特殊情况比如代刷需管理员在某次打卡五分钟后单独处理。对应SQL可以做唯一约束或者代码层判重。代码层判重更灵活原因在于补卡场景需要手动允许覆盖public AttendanceResult MakeRecord(StudentInfo student) { var now DateTime.Now; var result CheckAttendance(student, now); bool exists _db.Exists(student.Id, tempDate, tempSlotName); if (exists) { return new AttendanceResult(重复刷卡, 0) { IsDuplicate true }; } _db.InsertAttendanceLog(new AttendanceLogEntity { StudentId student.Id, SwipeTime now, AttendanceDate now.ToString(yyyy-MM-dd), Status result.Status, LateMinutes result.LateMinutes }); return result; }配合前面WPF页面把IsDuplicate为True的记录标记成灰色界面和逻辑就完全对应上了。这三张表加一套判定规则就是一个能直接交给班主任用的考勤后台。5. 高频刷卡卡顿、串口断线与上线前必查的三个细节5.1 串口读数据用队列别一条一条刷新UI早高峰刷卡器的输出频率会突然从低频变成每秒钟三到五张卡如果每张卡都做一次Dispatcher.InvokeWPF窗口会明显掉帧。我一般的做法是引入ConcurrentQueuestring接收原始卡号UI侧用DispatcherTimer每200毫秒批量取一次数据并批量更新集合。这样把UI刷新频率从「每张卡一次」降为「每秒五次」肉眼无感知CPU占用却能显著下降。private readonly ConcurrentQueueAttendanceRecord _queue new(); // 串口线程只入队 _queue.Enqueue(record); // DispatcherTimer每200ms触发 private void OnUiRefreshTick(object? sender, EventArgs e) { if (_queue.IsEmpty) return; App.Current.Dispatcher.Invoke(() { while (_queue.TryDequeue(out var item)) { Records.Insert(0, item); } }); }用ConcurrentQueue的原因在于它天然支持多线程安全不需要额外加锁。这个技巧对RFID考勤和扫码枪触发场景是通用的很多C#扫码枪项目UI卡顿也是同一问题。5.2 串口被拔掉后自动重连USB转串口设备在考勤机上非常普遍而它最大的毛病是传输线松动时计算机直接丢失串口程序再调用ReadExisting()会抛出UnauthorizedAccessException。简单处理方式是捕获异常并提示重启但更符合考勤场景的是自动重连。核心逻辑用System.Timers.Timer每秒检查一次_port.IsOpen未打开则尝试重新打开。private void ReconnectTimer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e) { if (_port null || _port.IsOpen) return; try { var baud _config.BaudRate; var portName _config.PortName; _port new SerialPort(portName, baud); _port.DataReceived OnDataReceived; _port.Open(); Logger.Info($串口重连成功: {portName}); } catch (Exception ex) { Logger.Warn($串口重连失败: {ex.Message}); } }注意System.Timers.Timer的Elapsed事件跑在后台线程所以这里重新创建SerialPort没有跨线程问题。但重连成功后PortName比如COM5可能变化尤其是USB转串口设备在驱动重载后会换端口号。因此重连逻辑还应包括「循环尝试多个已知端口」。把上一次成功打开的端口和固定候选端口列表都试一遍是实践中更好用的方案。5.3 上线前用虚拟串口模拟压测在没有真实读卡器的情况下可以用com0com这类虚拟串口驱动创建一对互相连通的端口比如COM3和COM4。然后写一个小工具向COM4发送ASCII卡号数据你的WPF程序连COM3接收就能完成全流程调试。模拟数据类似02000014A1B2C3D4E5 02000014A1B2C3D4E6压测时用Thread.Sleep(50)循环发送几百条数据观察界面是否掉帧、有无卡死这比真机测试更容易暴露串口处理与UI刷新的边界问题。另外考勤记录写SQLite时建议开启PRAGMA journal_modeWAL;它能显著降低写入阻塞避免高频刷卡时数据库写失败。这三个细节检查完系统再用真实设备跑一天早高峰基本就趋于稳定了。本文还有配套的精品资源点击获取