简介:这是一套基于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# 项目定义,TargetFramework="net6.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) --> <TargetFramework>net6.0-windows</TargetFramework> <UseWPF>true</UseWPF> <!-- 2. SQLite 连接字符串硬编码在 Services/SqliteDataService.cs 第 28 行 --> // 原始代码: private readonly string _connectionString = "Data Source=|DataDirectory|\\data.db;"; <!-- 3. NPOI 版本锁定(避免 NuGet 自动升级到 2.7+ 导致 .NET 6 报错) --> <PackageReference Include="NPOI" Version="2.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.Entries(ObservableCollection<AccountEntry>)UI 渲染完成:
DataGrid绑定Entries,ComboBox绑定Categories,LiveCharts2.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 线程中修改,否则抛InvalidOperationException;Entries.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, Mode=TwoWay}" DisplayMemberPath="Name" Width="150"/>关键细节:
SelectedItem绑定的是SelectedCategory(Category类型),不是SelectedCategoryId(int类型),这样能直接拿到对象,避免后续查表;Category类实现了INotifyPropertyChanged,当用户编辑分类名时(通过右键菜单“编辑分类”),Name属性变更会自动刷新ComboBox显示;- 删除分类前,系统会检查
Entries表中是否存在关联记录(SELECT COUNT(*) FROM Entries WHERE CategoryId = ?),若存在则弹窗阻止删除,防止数据孤儿。
3.3 月度统计:LiveCharts2 饼图的数据源构造与动态更新逻辑
月度统计显示在MainWindow右侧PieChart中,数据源是MainViewModel.SummaryByMonth(ObservableCollection<PieSeriesModel>):
public ObservableCollection<PieSeriesModel> 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.XSSFWorkbook:
public 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>中添加:<PublishTrimmed>false</PublishTrimmed> <PublishReadyToRun>false</PublishReadyToRun> - 重新发布(右键项目 → 发布 → 选择文件夹),确保
publish目录下存在LiveCharts2.dll(约 1.2MB); - 若仍缺失,手动从
packages\LiveCharts2.2.0.0-beta.123\lib\net6.0-windows\复制LiveCharts2.dll到publish目录。
4.2 现象:新增一笔账目后,DataGrid不刷新,但数据库已写入
原因:Entries集合被ObservableCollection<AccountEntry>正确声明,但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 Include="System.Drawing.Common" Version="6.0.0" /> - 重新发布,确保
publish目录下存在System.Drawing.Common.dll。
5. 进阶技巧:把这套记账系统变成你的生产力工具——三个我每天都在用的定制化改造
5.1 加入「快捷键」支持:Alt+I 快速录入收入,Alt+E 录入支出,省掉鼠标点选
WPF 原生支持KeyBinding,无需第三方库。在MainWindow.xaml的<Window.InputBindings>中添加:
<Window.InputBindings> <KeyBinding Key="I" Modifiers="Alt" Command="{Binding AddIncomeCommand}" /> <KeyBinding Key="E" Modifiers="Alt" Command="{Binding AddExpenseCommand}" /> <KeyBinding Key="R" Modifiers="Ctrl" 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中添加账户选择 ComboBox:<ComboBox ItemsSource="{Binding Accounts}" SelectedItem="{Binding SelectedAccount, Mode=TwoWay}" DisplayMemberPath="Name" />
从那以后我每次新增一笔账,都强制走一遍「选账户→选分类→输金额→回车」的完整流程,哪怕只是记一杯咖啡。因为真正的记账不是记下来,而是让每一笔钱都有来处、有去向、有归属。希望帮到你。
本文还有配套的精品资源,点击获取