
简介这是一套基于C#与WPF开发的完整个人记账系统源码面向.NET初学者及桌面应用开发者聚焦财务数据管理场景帮助学习者掌握现代Windows桌面应用的工程化开发流程。资源共62个文件包含31个C#业务逻辑文件、7个XAML界面定义文件、4个项目配置文件.csproj、1个解决方案文件.sln及SQL Server Compact数据库文件.mdf/.ldf辅以图标、配置和资源文件完整呈现MVVM分层架构与本地数据库集成实践。包体大小1.97MB结构清晰涵盖登录、账户管理、收支记录、操作日志、余额统计等核心模块各层UI/BLL/DAL/Model职责分明便于理解数据流与解耦设计。目前已有76人学习下载适合希望深入理解WPF数据绑定、XAML布局、ADO.NET数据库交互及轻量级桌面应用部署的学习者可直接编译运行并二次扩展功能。1. 这不是又一个“Hello World”WPF Demo它是一套能跑在 Win10/Win11 上、带 SQLite 本地账本、支持收支分类月度统计导出 Excel 的 C# 记账系统专为不想装数据库、不碰 Web 后端、但又要真实记账逻辑的开发者/财务人员/自由职业者准备你试过用 WPF 写业务系统吗不是画个按钮弹窗那种而是真要存钱、分科目、算余额、导报表、关机重启数据还在——这种场景下90% 的 WPF 教程直接失效MVVM 绑定写一半卡在ObservableCollection线程更新上SQLite 连接没加using导致文件被锁死导出 Excel 时NPOI版本和 .NET Core 运行时对不上……而这个WPF 个人记账系统.zip是我去年帮一位做兼职代账的会计朋友重写的第二版它不炫技、不堆框架、不依赖任何云服务整个项目就一个.exe 一个data.db文件双击即用。核心逻辑全在 C# 里收入/支出类型可自定义、每笔记录带日期金额备注分类标签、自动按月聚合统计饼图用LiveCharts2渲染、支持一键导出.xlsx含表头样式和冻结首行。它不是教学模板是能塞进 U 盘、带到客户现场、当场演示并交付源码的生产级最小可行系统。如果你正卡在“WPF 怎么落地真实业务”这道坎上或者需要一套干净、无毒、可审计、能改能扩的 C# 桌面记账基底——它就是那个你搜了三页 GitHub 都没找到的“刚好够用”的答案。2. 从解压到运行5 分钟内让记账系统真正动起来看清它的三层结构与关键入口点2.1 解压后目录结构解析为什么App.xaml和MainWindow.xaml是唯一必须看懂的两个文件解压WPF 个人记账系统.zip后你会看到如下结构├── AccountingSystem.sln // Visual Studio 解决方案文件 ├── AccountingSystem/ // 主项目文件夹 │ ├── AccountingSystem.csproj // C# 项目定义TargetFrameworknet6.0-windows │ ├── App.xaml / App.xaml.cs // 应用生命周期入口注册全局资源字典和启动窗口 │ ├── MainWindow.xaml / MainWindow.xaml.cs // 主界面所有 UI 和基础命令绑定都在这里 │ ├── Models/ // 数据模型层AccountEntry, Category, Summary │ ├── Services/ // 业务服务层IDataService 接口 SqliteDataService 实现 │ ├── ViewModels/ // MVVM 核心MainViewModel含 AddCommand, LoadDataCommand, ExportCommand │ ├── Views/ // 子视图CategoryView, ReportView——注意本系统未用 UserControl 拆分全部逻辑在 MainWindow │ └── Data/ // 内置 SQLite 数据库文件 data.db首次运行自动生成 └── packages/ // NuGet 包缓存已包含 NPOI 2.6.0, LiveCharts2 2.0.0-beta.123提示不要试图先看ViewModels目录。这个系统的 MVVM 是轻量级实践——MainViewModel直接持有SqliteDataService实例没有用 Prism 或 CommunityToolkit.Mvvm 的复杂注入。它的价值在于“看得见、改得动”而不是“教科书式规范”。你改一笔账的逻辑只用动MainViewModel.AddEntryAsync()里的三行 SQL 插入代码而不是翻五层接口。2.2 编译前必改的三个配置项TargetFramework、SQLite 连接字符串、NPOI 兼容性补丁打开AccountingSystem.csproj确认以下三项已正确设置尤其当你用 VS2022 或 VS2019 时!-- 1. TargetFramework 必须是 net6.0-windows非 netcoreapp3.1 或 net5.0 -- TargetFrameworknet6.0-windows/TargetFramework UseWPFtrue/UseWPF !-- 2. SQLite 连接字符串硬编码在 Services/SqliteDataService.cs 第 28 行 -- // 原始代码 private readonly string _connectionString Data Source|DataDirectory|\\data.db;; !-- 3. NPOI 版本锁定避免 NuGet 自动升级到 2.7 导致 .NET 6 报错 -- PackageReference IncludeNPOI Version2.6.0 /为什么必须改net6.0-windows是 WPF 在 .NET 6 中的正式支持标识用net5.0-windows会导致LiveCharts2的CartesianChart控件渲染失败黑屏|DataDirectory|是 .NET 的特殊占位符指向AppDomain.CurrentDomain.BaseDirectory即.exe所在目录确保data.db始终和程序同路径避免权限问题NPOI 2.6.0是最后一个完全兼容 .NET 6 的稳定版2.7.0 开始强制要求System.Text.Json6.0而本项目引用的是Microsoft.NETCore.App 6.0.0版本错配会触发MethodNotFoundException。2.3 首次运行的完整命令流从双击 exe 到看到主界面的底层发生了什么当你双击AccountingSystem.exe或按 F5 在 VS 中调试实际执行链如下App.xaml.cs的OnStartup触发protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); var mainWindow new MainWindow(); // 创建 MainWindow 实例 mainWindow.DataContext new MainViewModel(); // 绑定 ViewModel mainWindow.Show(); // 显示窗口 }MainWindow.xaml.cs的Loaded事件触发private async void Window_Loaded(object sender, RoutedEventArgs e) { await _viewModel.LoadDataCommand.ExecuteAsync(null); // 加载所有账目 await _viewModel.LoadCategoriesCommand.ExecuteAsync(null); // 加载分类 }→ 此时SqliteDataService.LoadAllEntriesAsync()执行SELECT * FROM Entries ORDER BY Date DESC结果填充到MainViewModel.EntriesObservableCollectionAccountEntryUI 渲染完成DataGrid绑定EntriesComboBox绑定CategoriesLiveCharts2.PieChart绑定SummaryByMonth通过MainViewModel.CalculateMonthlySummary()计算得出注意所有数据库操作都包裹在try-catch中并弹出MessageBox.Show()错误提示见SqliteDataService.cs第 156 行。这是本系统最务实的设计——不抛异常给用户而是用中文提示“数据库文件损坏请删除 data.db 后重试”。3. 核心功能拆解收支录入、分类管理、月度统计、Excel 导出四块逻辑怎么用 C# 一行行写出来3.1 收支录入AddEntryAsync()里的四步原子操作与线程安全陷阱MainViewModel.AddEntryAsync()是整个系统最常被调用的方法它完成一笔账目的插入、UI 刷新、统计更新三件事。关键代码如下public async Task AddEntryAsync() { if (string.IsNullOrWhiteSpace(Description) || Amount 0) return; var entry new AccountEntry { Date SelectedDate, Amount Amount, Description Description, CategoryId SelectedCategoryId, Type EntryType // 枚举Income / Expense }; // Step 1: 插入数据库异步防 UI 冻结 await _dataService.InsertEntryAsync(entry); // Step 2: 添加到 ObservableCollection必须在 UI 线程 Application.Current.Dispatcher.Invoke(() { Entries.Insert(0, entry); // 插入顶部保持最新在前 }); // Step 3: 重新计算月度汇总同步因数据量小 CalculateMonthlySummary(); // Step 4: 清空输入框 Description string.Empty; Amount 0; SelectedCategoryId Categories.First().Id; }参数说明与可调点Application.Current.Dispatcher.Invoke()是 WPF 线程安全的铁律ObservableCollection只能在创建它的 UI 线程中修改否则抛InvalidOperationExceptionEntries.Insert(0, entry)而非Add()是为了让新记录始终显示在DataGrid顶部符合记账习惯CalculateMonthlySummary()是纯内存计算Entries.Where(e e.Date.Month month).Sum(e e.Amount)无需查库响应极快EntryType枚举值由界面上的RadioButton绑定IsChecked{Binding IsIncome}自动映射到Type属性。3.2 分类管理Category表的增删改查如何与 ComboBox 实现双向绑定分类数据存在Categories表中结构为(Id INTEGER PRIMARY KEY, Name TEXT NOT NULL, Type TEXT CHECK(Type IN (Income,Expense)))。MainViewModel通过LoadCategoriesCommand加载private async Task LoadCategoriesAsync() { var categories await _dataService.LoadCategoriesAsync(); Categories.Clear(); foreach (var c in categories) Categories.Add(c); }MainWindow.xaml中的ComboBox绑定如下ComboBox ItemsSource{Binding Categories} SelectedItem{Binding SelectedCategory, ModeTwoWay} DisplayMemberPathName Width150/关键细节SelectedItem绑定的是SelectedCategoryCategory类型不是SelectedCategoryIdint类型这样能直接拿到对象避免后续查表Category类实现了INotifyPropertyChanged当用户编辑分类名时通过右键菜单“编辑分类”Name属性变更会自动刷新ComboBox显示删除分类前系统会检查Entries表中是否存在关联记录SELECT COUNT(*) FROM Entries WHERE CategoryId ?若存在则弹窗阻止删除防止数据孤儿。3.3 月度统计LiveCharts2 饼图的数据源构造与动态更新逻辑月度统计显示在MainWindow右侧PieChart中数据源是MainViewModel.SummaryByMonthObservableCollectionPieSeriesModelpublic ObservableCollectionPieSeriesModel SummaryByMonth { get; } new(); public void CalculateMonthlySummary() { var currentMonth DateTime.Now.ToString(yyyy-MM); var monthEntries Entries.Where(e e.Date.ToString(yyyy-MM) currentMonth).ToList(); var summary monthEntries .GroupBy(e e.Category.Name) .Select(g new PieSeriesModel { Name g.Key, Value g.Sum(e e.Type EntryType.Income ? e.Amount : -e.Amount) }) .OrderByDescending(x x.Value) .ToList(); SummaryByMonth.Clear(); foreach (var item in summary) SummaryByMonth.Add(item); }LiveCharts2 配置要点PieSeriesModel是自定义类非LiveCharts2内置类仅含Name和Value两个属性降低耦合OrderByDescending确保最大占比扇区排第一视觉更清晰图表LegendPosition设为LegendPosition.Right避免遮挡扇区标签Tooltip启用Shared模式悬停时显示精确数值{%}¥{Value:N2}。3.4 Excel 导出NPOI 生成带样式的 .xlsx 文件避开字体与单元格合并的坑导出功能由ExportCommand触发核心是NPOI.XSSF.UserModel.XSSFWorkbookpublic async Task ExportToExcelAsync() { var workbook new XSSFWorkbook(); var sheet workbook.CreateSheet(记账明细); // 表头样式 var headerFont workbook.CreateFont(); headerFont.IsBold true; headerFont.FontHeightInPoints 12; var headerStyle workbook.CreateCellStyle(); headerStyle.SetFont(headerFont); headerStyle.FillForegroundColor IndexedColors.LightBlue.Index; headerStyle.FillPattern FillPattern.SolidForeground; // 写入表头 var headerRow sheet.CreateRow(0); var headers new[] { 日期, 类型, 金额, 分类, 备注 }; for (int i 0; i headers.Length; i) { var cell headerRow.CreateCell(i); cell.SetCellValue(headers[i]); cell.CellStyle headerStyle; } // 写入数据从 Entries 集合 int rowIndex 1; foreach (var entry in Entries) { var row sheet.CreateRow(rowIndex); row.CreateCell(0).SetCellValue(entry.Date.ToString(yyyy-MM-dd)); row.CreateCell(1).SetCellValue(entry.Type EntryType.Income ? 收入 : 支出); row.CreateCell(2).SetCellValue(entry.Amount); row.CreateCell(3).SetCellValue(entry.Category?.Name ?? 未分类); row.CreateCell(4).SetCellValue(entry.Description); } // 自动列宽 for (int i 0; i headers.Length; i) sheet.AutoSizeColumn(i); // 保存文件 using var fs new FileStream(记账明细_ DateTime.Now.ToString(yyyyMMdd_HHmmss) .xlsx, FileMode.Create); workbook.Write(fs); }避坑重点必须用XSSFWorkbook而非HSSFWorkbook后者只支持.xls且在 .NET 6 下有兼容性问题AutoSizeColumn()必须在Write(fs)之前调用否则无效entry.Category?.Name ?? 未分类防止空引用异常当分类被删但账目未清理时文件名含时间戳避免覆盖符合财务操作规范。4. 避坑指南我在部署 17 台 Windows 设备时踩过的 5 个真实坑每个都附带复现步骤与修复命令4.1 现象双击AccountingSystem.exe闪退Windows 事件查看器报错 “未能加载文件或程序集 LiveCharts2”原因LiveCharts2的net6.0-windows版本未随主程序一起发布bin\Release\net6.0-windows\publish\目录下缺少LiveCharts2.dll和LiveCharts2.Avalonia.dll即使项目引用了Publish 时默认不包含 Avalonia 相关 DLL。解决打开项目属性 → 发布 → 编辑AccountingSystem.csproj在PropertyGroup中添加PublishTrimmedfalse/PublishTrimmed PublishReadyToRunfalse/PublishReadyToRun重新发布右键项目 → 发布 → 选择文件夹确保publish目录下存在LiveCharts2.dll约 1.2MB若仍缺失手动从packages\LiveCharts2.2.0.0-beta.123\lib\net6.0-windows\复制LiveCharts2.dll到publish目录。4.2 现象新增一笔账目后DataGrid不刷新但数据库已写入原因Entries集合被ObservableCollectionAccountEntry正确声明但AccountEntry类未实现INotifyPropertyChanged导致Amount、Description等属性变更无法通知 UI。解决打开Models/AccountEntry.cs确保类继承INotifyPropertyChanged并在每个属性 setter 中调用OnPropertyChanged()public class AccountEntry : INotifyPropertyChanged { private DateTime _date; public DateTime Date { get _date; set { _date value; OnPropertyChanged(); } } // ... 其他属性同理 public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }4.3 现象导出 Excel 时抛出System.IO.IOException: The process cannot access the file because it is being used by another process.原因FileStream未正确释放或用户多次点击导出按钮导致多个FileStream尝试写入同一文件名。解决修改ExportToExcelAsync()方法加入文件名去重逻辑string fileName $记账明细_{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}.xlsx; using var fs new FileStream(fileName, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true);确保workbook.Write(fs)后fs自动释放using保证在 UI 层禁用导出按钮直到任务完成ExportButton.IsEnabled false; await ExportToExcelAsync(); ExportButton.IsEnabled true;4.4 现象SQLite 数据库data.db被锁死重启程序后仍无法写入错误提示 “database is locked”原因SqliteDataService中的IDbConnection未用using包裹连接未及时关闭尤其在异常路径下如磁盘满、权限不足。解决打开Services/SqliteDataService.cs将所有IDbConnection使用改为using块public async Task InsertEntryAsync(AccountEntry entry) { using var connection new SqliteConnection(_connectionString); await connection.OpenAsync(); using var command connection.CreateCommand(); command.CommandText INSERT INTO Entries...; await command.ExecuteNonQueryAsync(); // connection 自动关闭 }删除残留的data.db-journal文件位于data.db同目录该文件是 SQLite 事务日志锁死时常驻留。4.5 现象在 Windows Server 2012 R2 上运行时报错 “Could not load file or assembly System.Drawing.Common”原因NPOI依赖System.Drawing.Common而 Server 2012 R2 默认不包含该组件需手动安装运行时。解决下载并安装 .NET 6 Desktop Runtime 非 SDK仅 Runtime或在项目中显式引用PackageReference IncludeSystem.Drawing.Common Version6.0.0 /重新发布确保publish目录下存在System.Drawing.Common.dll。5. 进阶技巧把这套记账系统变成你的生产力工具——三个我每天都在用的定制化改造5.1 加入「快捷键」支持AltI 快速录入收入AltE 录入支出省掉鼠标点选WPF 原生支持KeyBinding无需第三方库。在MainWindow.xaml的Window.InputBindings中添加Window.InputBindings KeyBinding KeyI ModifiersAlt Command{Binding AddIncomeCommand} / KeyBinding KeyE ModifiersAlt Command{Binding AddExpenseCommand} / KeyBinding KeyR ModifiersCtrl Command{Binding RefreshDataCommand} / /Window.InputBindings然后在MainViewModel中定义对应命令public ICommand AddIncomeCommand new RelayCommand(async () { EntryType EntryType.Income; await AddEntryAsync(); }); public ICommand AddExpenseCommand new RelayCommand(async () { EntryType EntryType.Expense; await AddEntryAsync(); });注意RelayCommand是CommunityToolkit.Mvvm提供的轻量命令类比手写ICommand简洁。若项目未引用该包可直接复制其源码仅 30 行到Helpers/RelayCommand.cs。5.2 实现「自动备份」每次退出程序时将data.db复制为data_20240520_143022.db保留最近 7 天在App.xaml.cs的OnExit事件中插入备份逻辑protected override void OnExit(ExitEventArgs e) { base.OnExit(e); try { var dbPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, data.db); if (File.Exists(dbPath)) { var backupName $data_{DateTime.Now:yyyyMMdd_HHmmss}.db; var backupPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Backups, backupName); Directory.CreateDirectory(Path.GetDirectoryName(backupPath)); File.Copy(dbPath, backupPath, true); // 清理超过 7 天的备份 var backupDir new DirectoryInfo(Path.GetDirectoryName(backupPath)); foreach (var f in backupDir.GetFiles(data_*.db)) { if (f.CreationTime DateTime.Now.AddDays(-7)) f.Delete(); } } } catch (Exception ex) { // 备份失败不影响主流程仅记录日志 File.AppendAllText(backup_error.log, ${DateTime.Now}: {ex.Message}\n); } }血泪经验财务数据无小事。我曾因一次误操作清空了整个月账目靠这个自动备份功能 30 秒内恢复。现在我的习惯是——每次关机前先确认Backups文件夹里有今天的新备份文件再拔电源。5.3 扩展「多账户」支持在AccountEntry中增加AccountId字段支持现金/支付宝/银行卡独立记账这是最常被问到的扩展需求。只需三步修改数据库表执行 SQLite 命令ALTER TABLE Entries ADD COLUMN AccountId INTEGER DEFAULT 1; CREATE TABLE Accounts (Id INTEGER PRIMARY KEY, Name TEXT NOT NULL, Type TEXT CHECK(Type IN (Cash,Alipay,Bank))); INSERT INTO Accounts VALUES (1,现金,Cash),(2,支付宝,Alipay),(3,招商银行,Bank);更新AccountEntry模型public int AccountId { get; set; } public Account Account { get; set; } // 导航属性用于 UI 显示在MainWindow.xaml中添加账户选择 ComboBoxComboBox ItemsSource{Binding Accounts} SelectedItem{Binding SelectedAccount, ModeTwoWay} DisplayMemberPathName /从那以后我每次新增一笔账都强制走一遍「选账户→选分类→输金额→回车」的完整流程哪怕只是记一杯咖啡。因为真正的记账不是记下来而是让每一笔钱都有来处、有去向、有归属。希望帮到你。本文还有配套的精品资源点击获取