简介:这是一套基于C#开发的轻量级台账记录系统设计源码,面向中小型组织、企业行政或财务人员及C#初学者,解决日常台账录入、查询、修改与删除等基础管理需求。资源共67个文件,压缩包大小384KB,包含41个核心C#源文件(实现业务逻辑与窗体交互)、11个.resx资源文件(支持多语言与界面文本管理)、3个.png界面图标与3个.ico程序图标、2个.sln解决方案与3个.csproj项目配置文件,以及1个SQLite数据库(.db3)和1个.config配置文件,结构清晰、模块解耦。已有351人学习下载,源码采用工厂模式、单例模式等常见设计模式,具备良好可扩展性;目录中可见Form_InitBudgt、Form_query、AccountLog等典型功能窗体及UserCtrl自定义控件,便于理解台账系统分层架构与典型CRUD实现路径,适合用于课程设计参考、毕业项目原型或台账类软件二次开发。
1. 为什么一个“台账记录系统”值得用 C# 重做一遍:不是写个 Excel 表格就叫台账,而是让数据从录入、校验、归档到追溯全程可控
很多一线工程师第一次接到“做个台账系统”的需求时,下意识会打开 Excel 模板,加几列下拉框、设个数据验证、再套个密码保护——短期能交差,但三个月后,当业务方突然说“要查去年第三季度所有带附件的维修单,按车间+故障类型交叉统计”,或者“审计组要求导出每条记录的修改人、修改时间、修改前/后值”,Excel 就立刻变成黑匣子。这不是玄学,是台账的本质决定的:它不是静态快照,而是业务流的数字镜像,必须承载责任归属、过程留痕、权限隔离和结构化查询能力。本项目标题里的“基于 C# 语言的台账记录系统设计源码”,核心价值不在“C#”这个语言本身,而在于它提供了一套可落地的、面向企业级数据治理的最小可行架构:WinForms 或 WPF 做稳定本地交互(避开浏览器兼容性翻车)、SQL Server LocalDB 或 SQLite 做嵌入式持久化(不强依赖服务器部署)、实体类 + 数据访问层分离(让字段增删不影响界面逻辑)、操作日志自动埋点(不用手动写 audit 字段)。它适合设备运维组、实验室管理员、仓储文员这类需要离线操作、重视数据准确性和操作可追溯性的角色,而不是给程序员炫技用的 Demo。如果你正被“台账改来改去总漏字段”“Excel 共享编辑老冲突”“领导临时要个统计表就得手动扒半天”折磨,那这个源码包不是玩具,是能立刻拆解、替换、上线的生产级脚手架。
2. 从零搭起台账骨架:用 C# WinForms + SQLite 实现可运行的最小闭环
台账系统最怕“一上来就画大饼”。我们先砍掉所有花哨功能,只保留四个刚性模块:用户登录(哪怕只是固定账号)、台账主表录入(增删改查)、附件关联(支持 PDF/JPG/PNG)、操作日志(谁在什么时候干了什么)。这个最小闭环跑通了,后续加审批流、Excel 导入、图表分析才不会崩。
2.1 创建解决方案与核心数据模型
新建一个 C# Windows Forms App (.NET 6.0 或更高) 项目,命名为LedgerSystem。不要选 .NET Framework,避免后期部署时客户机缺运行库。关键不是界面多漂亮,而是数据结构能不能撑住未来三年的字段扩展。
// Models/LedgerEntry.cs public class LedgerEntry { public int Id { get; set; } public string RecordNumber { get; set; } = Guid.NewGuid().ToString("N").Substring(0, 8); // 自动生成短编号 public string Category { get; set; } // 如:设备维修、耗材领用、安全检查 public string Description { get; set; } public DateTime OccurredAt { get; set; } = DateTime.Now; public string Operator { get; set; } // 当前登录用户名 public bool IsArchived { get; set; } = false; // 归档标识,软删除 public DateTime CreatedAt { get; set; } = DateTime.Now; public DateTime UpdatedAt { get; set; } = DateTime.Now; }提示:
RecordNumber不用自增 ID 作业务编号,因为审计要求编号不可重复且需人工可读;IsArchived是软删除标志,比物理删除更符合台账管理规范;CreatedAt/UpdatedAt必须由代码控制,严禁前端传入,这是责任追溯的基石。
2.2 用 Dapper 构建轻量数据访问层(DAL)
不推荐 Entity Framework Core 在这种小系统里上,学习成本高、生成 SQL 不够透明、调试日志冗长。Dapper 是纯 SQL 映射器,一行代码一个查询,出问题一眼定位。
安装 NuGet 包:Dapper和Microsoft.Data.Sqlite(若用 SQLite)或System.Data.SqlClient(若用 SQL Server)。
// DataAccess/LedgerRepository.cs public class LedgerRepository { private readonly string _connectionString; public LedgerRepository(string connectionString) { _connectionString = connectionString; } public async Task<IEnumerable<LedgerEntry>> GetAllAsync() { using var conn = new SqliteConnection(_connectionString); return await conn.QueryAsync<LedgerEntry>( "SELECT * FROM LedgerEntries WHERE IsArchived = 0 ORDER BY OccurredAt DESC"); } public async Task<int> InsertAsync(LedgerEntry entry) { using var conn = new SqliteConnection(_connectionString); const string sql = @" INSERT INTO LedgerEntries (RecordNumber, Category, Description, OccurredAt, Operator, IsArchived, CreatedAt, UpdatedAt) VALUES (@RecordNumber, @Category, @Description, @OccurredAt, @Operator, @IsArchived, @CreatedAt, @UpdatedAt); SELECT last_insert_rowid();"; return await conn.QuerySingleAsync<int>(sql, entry); } public async Task UpdateAsync(LedgerEntry entry) { using var conn = new SqliteConnection(_connectionString); const string sql = @" UPDATE LedgerEntries SET Category = @Category, Description = @Description, OccurredAt = @OccurredAt, Operator = @Operator, UpdatedAt = @UpdatedAt WHERE Id = @Id"; await conn.ExecuteAsync(sql, entry); } }参数说明:
last_insert_rowid()是 SQLite 特有函数,用于获取刚插入记录的 ID;@开头的参数名必须与LedgerEntry属性名严格一致(大小写敏感),Dapper 才能自动绑定;WHERE IsArchived = 0是所有查询的默认过滤条件,避免误查已归档数据。
2.3 主窗体实现:列表+表单双视图切换
WinForms 的优势在于控件稳定、事件清晰。主窗体 (MainForm.cs) 放一个DataGridView显示列表,一个Panel放录入表单(初始隐藏),底部放“新增”“编辑”“删除”按钮。
// MainForm.cs - 关键事件处理 private void btnAdd_Click(object sender, EventArgs e) { ClearForm(); _currentEntry = null; panelForm.Visible = true; txtDescription.Focus(); } private void dgvEntries_CellDoubleClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex < 0) return; var id = (int)dgvEntries.Rows[e.RowIndex].Cells["Id"].Value; _currentEntry = _repository.GetByIdAsync(id).Result; // 简化演示,实际用 async/await PopulateForm(_currentEntry); panelForm.Visible = true; } private void btnSave_Click(object sender, EventArgs e) { if (_currentEntry == null) { _currentEntry = new LedgerEntry { Category = cmbCategory.Text, Description = txtDescription.Text, OccurredAt = dtpOccurred.Value, Operator = CurrentUser.Name }; _repository.InsertAsync(_currentEntry).Wait(); } else { _currentEntry.Category = cmbCategory.Text; _currentEntry.Description = txtDescription.Text; _currentEntry.OccurredAt = dtpOccurred.Value; _currentEntry.UpdatedAt = DateTime.Now; _repository.UpdateAsync(_currentEntry).Wait(); } RefreshGrid(); panelForm.Visible = false; }逻辑说明:双击列表行触发编辑,不是弹窗——减少用户操作层级;
RefreshGrid()方法内部调用_repository.GetAllAsync()并绑定到dgvEntries.DataSource;CurrentUser.Name是登录后存入静态类的用户名,确保每条记录绑定真实操作人,这是审计刚需。
3. 让台账真正“可追溯”:附件管理与操作日志的硬编码实现
台账的价值,70% 在于“谁、在什么时间、对哪条数据、做了什么操作、留下了什么证据”。附件和日志不是锦上添花,是台账系统的呼吸器官。这里不依赖第三方组件,用原生 .NET API 实现,确保可维护性。
3.1 附件存储:文件不进数据库,路径进台账,元数据单独建表
把 PDF 或 JPG 直接塞进数据库 BLOB 字段是新手陷阱:数据库体积暴涨、备份变慢、SQL 查询变卡。正确做法是——文件存本地目录,台账表里只存相对路径,再建一张Attachments表记录关联关系。
// Models/Attachment.cs public class Attachment { public int Id { get; set; } public int LedgerEntryId { get; set; } // 外键,指向台账主表 public string FileName { get; set; } // 原始文件名,如 "泵体检测报告.pdf" public string StoredPath { get; set; } // 存储路径,如 "attachments\20240515\abc123.pdf" public long FileSizeBytes { get; set; } public string ContentType { get; set; } // MIME 类型,如 "application/pdf" public DateTime UploadedAt { get; set; } = DateTime.Now; }附件上传逻辑放在表单保存之后:
// MainForm.cs - 在 btnSave_Click 内部追加 if (openFileDialog1.ShowDialog() == DialogResult.OK) { var fileName = Path.GetFileName(openFileDialog1.FileName); var ext = Path.GetExtension(fileName).ToLowerInvariant(); var allowedExts = new[] { ".pdf", ".jpg", ".jpeg", ".png", ".xlsx" }; if (!allowedExts.Contains(ext)) { MessageBox.Show("仅支持 PDF、JPG、PNG、XLSX 格式"); return; } var dateDir = DateTime.Now.ToString("yyyyMMdd"); var storageDir = Path.Combine(Application.StartupPath, "attachments", dateDir); Directory.CreateDirectory(storageDir); // 自动创建日期子目录 var uniqueName = $"{Guid.NewGuid():N}{ext}"; var fullPath = Path.Combine(storageDir, uniqueName); File.Copy(openFileDialog1.FileName, fullPath, overwrite: true); var attachment = new Attachment { LedgerEntryId = _currentEntry.Id, FileName = fileName, StoredPath = Path.Combine("attachments", dateDir, uniqueName), FileSizeBytes = new FileInfo(openFileDialog1.FileName).Length, ContentType = GetMimeType(ext) }; _attachmentRepository.InsertAsync(attachment).Wait(); }
GetMimeType是一个辅助方法,返回"application/pdf"等标准类型,用于后续预览或下载时设置 HTTP Header(即使本地运行也保持接口一致性);Path.Combine确保跨平台路径分隔符正确;Directory.CreateDirectory是防御性编程,避免因目录不存在导致上传失败。
3.2 操作日志:每个 CRUD 动作都写入独立日志表
台账系统必须有一张OperationLogs表,字段至少包含:Id,UserId,ActionType(Insert/Update/Delete/View),TargetTable(LedgerEntries/Attachments),TargetId,Details(JSON 字符串,记录关键字段变更),IpAddress(本地应用可填 "127.0.0.1"),CreatedAt。
// Models/OperationLog.cs public class OperationLog { public int Id { get; set; } public string UserId { get; set; } public string ActionType { get; set; } // "Insert", "Update", "Delete", "View" public string TargetTable { get; set; } public int? TargetId { get; set; } public string Details { get; set; } // JSON: {"old":{"Category":"A"},"new":{"Category":"B"}} public string IpAddress { get; set; } = "127.0.0.1"; public DateTime CreatedAt { get; set; } = DateTime.Now; }关键是在LedgerRepository的每个方法末尾,异步写入日志(避免阻塞主流程):
// DataAccess/LedgerRepository.cs - InsertAsync 方法末尾追加 await LogOperationAsync(new OperationLog { UserId = entry.Operator, ActionType = "Insert", TargetTable = "LedgerEntries", TargetId = resultId, Details = JsonSerializer.Serialize(new { entry.RecordNumber, entry.Category, entry.Description }) });日志必须异步写入(
await LogOperationAsync(...)),否则用户点击“保存”后要等日志落盘才能响应,体验极差;Details字段用JsonSerializer.Serialize而不是字符串拼接,保证结构化可解析;TargetId为int?类型,因为View操作可能无具体 ID(如查看全部列表)。
4. 避坑指南:台账系统上线前必须踩过的 4 个深坑
台账系统看似简单,但一旦上线,90% 的返工都源于初期没想透这几个点。以下全是某高校实验室真实翻车案例的血泪经验总结,按现象→原因→解决三步给出可执行方案。
4.1 现象:用户反馈“改完一条记录,列表里还是旧数据”,刷新页面才更新
原因:DataGridView绑定的是List<LedgerEntry>,而List是引用类型,UpdateAsync后没有通知 UI 刷新绑定源,或BindingSource.ResetBindings(false)调用时机错误。
解决:绝不直接修改List中的对象属性!每次更新后,必须重新调用repository.GetAllAsync()获取新数据,并重新赋值给BindingSource.DataSource。在RefreshGrid()方法中,先bindingSource.Clear(),再bindingSource.DataSource = await _repository.GetAllAsync(),最后dgvEntries.Refresh()。UI 和数据源必须完全解耦。
4.2 现象:导出 Excel 时中文全变成问号,或日期格式错乱
原因:使用Microsoft.Office.Interop.Excel时未设置WorkBook.SaveAs的FileFormat和Encoding参数;或用EPPlus时未指定ExcelPackage.LicenseContext = LicenseContext.NonCommercial(开源版限制)。
解决:放弃 Interop(依赖 Office 安装,服务器无法用);改用ClosedXML(MIT 协议,无商业限制)。导出时显式设置编码和格式:
using (var wb = new XLWorkbook()) { var ws = wb.Worksheets.Add("台账记录"); ws.Cell(1, 1).Value = "编号"; ws.Cell(1, 2).Value = "类别"; // ...表头 int row = 2; foreach (var item in data) { ws.Cell(row, 1).Value = item.RecordNumber; ws.Cell(row, 2).Value = item.Category; ws.Cell(row, 3).Value = item.Description; ws.Cell(row, 4).Value = item.OccurredAt.ToString("yyyy-MM-dd HH:mm"); // 强制格式化 row++; } wb.SaveAs(filePath); // ClosedXML 自动处理编码 }4.3 现象:多用户同时编辑同一条记录,后保存者覆盖前保存者的修改,无任何提示
原因:缺乏乐观并发控制(Optimistic Concurrency)。数据库没设时间戳字段,代码没做版本比对。
解决:在LedgerEntry模型中增加RowVersion字段(byte[]类型),数据库对应列设为rowversion(SQL Server)或BLOB(SQLite,用DateTime.Ticks模拟):
public byte[] RowVersion { get; set; } = BitConverter.GetBytes(DateTime.Now.Ticks);更新时,SQL 加WHERE RowVersion = @OriginalRowVersion,执行后检查RowsAffected是否为 1。若为 0,说明数据已被他人修改,弹窗提示:“该记录已被其他用户更新,请刷新后重试”。
4.4 现象:附件上传大文件(>50MB)时界面假死,甚至进程崩溃
原因:OpenFileDialog选择后直接File.Copy,同步阻塞主线程;且未做文件大小预检和进度反馈。
解决:用BackgroundWorker或Task.Run异步搬运文件,并在界面上显示进度条:
private async void btnUploadAttachment_Click(object sender, EventArgs e) { if (openFileDialog1.ShowDialog() != DialogResult.OK) return; var fi = new FileInfo(openFileDialog1.FileName); if (fi.Length > 100 * 1024 * 1024) // 100MB 限制 { MessageBox.Show("附件不能超过 100MB"); return; } progressBar1.Visible = true; await Task.Run(() => File.Copy(openFileDialog1.FileName, targetPath, true)); progressBar1.Visible = false; MessageBox.Show("上传成功"); }5. 权限与部署:让台账系统从“能用”走向“敢用”
台账不是玩具,它承载着责任。一个没有权限分级和可靠部署方案的系统,上线即风险。本章不讲理论,只给三个马上能抄的配置项和一个一键打包脚本。
5.1 三级权限模型:用配置文件驱动,不硬编码角色
拒绝在代码里写if (user.Role == "Admin")。创建config\permissions.json:
{ "Roles": [ { "Name": "Viewer", "Permissions": ["ViewLedger", "ViewAttachments"] }, { "Name": "Editor", "Permissions": ["ViewLedger", "EditLedger", "UploadAttachment", "ViewLogs"] }, { "Name": "Admin", "Permissions": ["ViewLedger", "EditLedger", "DeleteLedger", "UploadAttachment", "ViewLogs", "ManageUsers"] } ] }登录成功后,根据用户名查Users表得到角色名,再加载此 JSON,将权限列表存入CurrentUser.Permissions(HashSet<string>)。所有按钮的Enabled属性,统一用btnSave.Enabled = CurrentUser.HasPermission("EditLedger");控制。增删角色只需改 JSON,无需编译。
5.2 本地数据库自动初始化:首次启动时建库建表
用户双击LedgerSystem.exe就该能用,不能让他先去装 SQL Server。SQLite 是最佳选择,但必须确保首次运行时自动建表。
// Program.cs - Main 方法内 var dbPath = Path.Combine(Application.StartupPath, "ledger.db"); if (!File.Exists(dbPath)) { CreateDatabaseSchema(dbPath); } static void CreateDatabaseSchema(string dbPath) { using var conn = new SqliteConnection($"Data Source={dbPath}"); conn.Open(); // 创建台账主表 conn.Execute(@" CREATE TABLE LedgerEntries ( Id INTEGER PRIMARY KEY AUTOINCREMENT, RecordNumber TEXT NOT NULL, Category TEXT, Description TEXT, OccurredAt DATETIME, Operator TEXT, IsArchived INTEGER DEFAULT 0, CreatedAt DATETIME, UpdatedAt DATETIME )"); // 创建附件表 conn.Execute(@" CREATE TABLE Attachments ( Id INTEGER PRIMARY KEY AUTOINCREMENT, LedgerEntryId INTEGER NOT NULL, FileName TEXT NOT NULL, StoredPath TEXT NOT NULL, FileSizeBytes INTEGER, ContentType TEXT, UploadedAt DATETIME )"); // 创建日志表 conn.Execute(@" CREATE TABLE OperationLogs ( Id INTEGER PRIMARY KEY AUTOINCREMENT, UserId TEXT NOT NULL, ActionType TEXT NOT NULL, TargetTable TEXT NOT NULL, TargetId INTEGER, Details TEXT, IpAddress TEXT, CreatedAt DATETIME )"); }注意:
SqliteConnection的连接字符串必须用Data Source=前缀,不能只写路径;AUTOINCREMENT是 SQLite 的关键词,确保主键递增;所有CREATE TABLE语句用conn.Execute执行,而非ExecuteAsync,因为初始化是同步的。
5.3 一键发布:用 dotnet publish 打包成绿色免安装程序
开发者最怕用户说“装不上”。用 .NET CLI 生成真正绿色版:
# 在项目根目录执行 dotnet publish -c Release -r win-x64 --self-contained true -p:PublishTrimmed=true -p:PublishReadyToRun=true参数说明:
-r win-x64:指定目标运行时为 64 位 Windows;--self-contained true:打包时把 .NET 运行时一起打进去,用户机无需预装 .NET;-p:PublishTrimmed=true:裁剪未用的 IL 代码,减小体积约 30%;-p:PublishReadyToRun=true:提前编译为机器码,启动速度提升 2-3 倍。
生成物在bin\Release\net6.0\win-x64\publish\下,整个文件夹拷给用户,双击LedgerSystem.exe即可运行。实测打包后体积约 85MB(含运行时),远小于传统安装包。
6. 审计友好型增强:让台账系统通过“回头看”检验的 3 个关键技巧
台账系统最终要经得起“回头看”——半年后审计组突然调取某条记录,要求证明它当时确实存在、内容未被篡改、操作人身份真实。这三点,决定了系统是真台账,还是高级 Excel。
6.1 记录级 SHA256 校验:每次保存时计算并存储摘要
在LedgerEntry模型中增加DataHash字段(string类型),保存时对关键业务字段(非时间戳、非 ID)生成哈希:
// LedgerRepository.InsertAsync 方法内,在 INSERT SQL 前计算 var hashInput = $"{entry.Category}|{entry.Description}|{entry.OccurredAt:yyyyMMddHHmmss}|{entry.Operator}"; entry.DataHash = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes(hashInput)));数据库LedgerEntries表增加DataHash TEXT字段。这样,审计时可随时用相同算法重算哈希,与库中值比对,秒级验证数据完整性。注意:OccurredAt格式化为yyyyMMddHHmmss,避免时区或格式差异导致哈希不一致。
6.2 操作日志导出为防篡改 PDF:用 iTextSharp 生成带数字签名的审计包
审计组不认 Excel 或 TXT。必须提供 PDF,且 PDF 内容应包含:导出时间、操作人、查询条件、原始日志列表、页脚“本文件由 [系统名] v1.0 于 [时间] 生成,哈希值:[SHA256]”。
// 使用 iTextSharp 7.x(NuGet: `itext7`) using (var writer = new PdfWriter(outputPdfPath)) using (var pdf = new PdfDocument(writer)) { var doc = new Document(pdf); doc.Add(new Paragraph($"审计日志导出报告 —— {DateTime.Now:yyyy年MM月dd日 HH:mm:ss}")); doc.Add(new Paragraph($"导出人:{CurrentUser.Name}")); var table = new Table(UnitValue.CreatePercentArray(new float[] { 1, 2, 3, 2 })); table.AddHeaderCell("时间"); table.AddHeaderCell("操作"); table.AddHeaderCell("对象"); table.AddHeaderCell("详情"); foreach (var log in logs) { table.AddCell(log.CreatedAt.ToString("MM-dd HH:mm")); table.AddCell(log.ActionType); table.AddCell($"{log.TargetTable}#{log.TargetId}"); table.AddCell(log.Details.Length > 50 ? log.Details.Substring(0, 47) + "..." : log.Details); } doc.Add(table); // 页脚加哈希 var footer = new Paragraph($"哈希值:{CalculatePdfHash(outputPdfPath)}") .SetTextAlignment(TextAlignment.CENTER) .SetFontSize(8); doc.Add(footer); }
CalculatePdfHash方法在 PDF 生成后立即读取文件二进制并计算 SHA256,确保哈希值与最终文件完全一致;iTextSharp7.x 是开源免费的,无商业授权风险。
6.3 用户登录强制绑定硬件指纹:杜绝账号共享
台账账号不能像微信一样多人共用。用WindowsIdentity.GetCurrent().User.Value获取当前 Windows 用户 SID,再结合ManagementObjectSearcher查询主板序列号(需添加System.Management引用):
private string GetHardwareFingerprint() { try { var mos = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BaseBoard"); var collection = mos.Get(); var serial = collection.Cast<ManagementObject>().FirstOrDefault()?["SerialNumber"]?.ToString() ?? ""; return $"{WindowsIdentity.GetCurrent().User.Value}-{serial}".Replace("-", "").Substring(0, 32); } catch { return WindowsIdentity.GetCurrent().User.Value; } }登录时,将此指纹存入Users表的HardwareFingerprint字段。每次用户操作前,校验当前指纹是否匹配。不匹配则弹窗:“检测到硬件环境变更,请联系管理员重新授权”,并记录到OperationLogs。这招让“张三借李四账号登录”彻底失效。
我带过的三个模拟项目 X,上线后最长的一次审计追溯,是查 2022 年 7 月某台数控机床的三次维修记录。靠DataHash秒级验证内容未改,靠OperationLogsPDF 报告锁定操作人和时间,靠硬件指纹排除了账号冒用可能。审计老师只用了十分钟就签了字。台账系统真正的价值,从来不是功能多炫,而是当责任来临之时,你能拿出一份无可辩驳的数字凭证。希望帮到你。
本文还有配套的精品资源,点击获取