简介:这是一套基于C#与WPF技术栈构建的个人记账系统完整源码,面向具备一定.NET基础的开发者、课程设计学生及需要桌面端记账工具参考实现的技术人员。项目采用分层架构,将UI、业务逻辑、数据访问与实体模型分离,涵盖登录、账户管理、收支记录、余额统计与操作日志等模块,适合用来学习WPF桌面应用从界面到数据库的完整落地方式。压缩包共62个文件,约1.97MB,以31个cs源码文件为核心,配合7个xaml界面文件、4个csproj工程文件、8个ico图标与3个png图片资源,另含sln解决方案、mdf与ldf数据库文件及config配置,结构完整可直接编译运行。目前已有76人学习下载。读者可从中掌握XAML声明式界面、数据绑定、MVVM设计模式、ADO.NET数据库增删改查、异常处理与ClickOnce部署等关键知识点,并借鉴其分层目录组织与版本控制实践,快速搭建属于自己的记账类桌面应用。
1. 从一份 WPF 个人记账系统源码说起:它到底能解决什么
很多人第一次看到「用 C# 语言编写的的一个 WPF 个人记账系统.zip」这类标题,第一反应是「又一个练手项目」,但真正做过桌面端财务工具的人会知道,记账系统是少数能把数据绑定、本地存储、表单校验、报表导出、主题切换全部串起来的场景。它解决的不是「记一笔账」这么简单,而是让一个 C# 开发者在一套可运行的 WPF 工程里,把 MVVM 分层、SQLite 持久化、DataGrid 编辑、图表统计这几件事一次性跑通。适合谁?适合刚学完 C# 基础、想找一个能写进简历又能真正用起来的桌面项目的人,也适合做上位机、工控 HMI 的同行拿来当 WPF 数据交互的参考骨架。下面我按「先立住结构、再动手复现、最后讲坑」的顺序,把这类项目从解压到跑通、再到二次开发的路径讲清楚。
2. 拆开这个 WPF 记账系统的工程骨架:MVVM 分层与目录约定
拿到一个 WPF 记账系统的压缩包,别急着 F5。先看目录结构,基本能判断作者是不是按主流做法组织的。一个能长期维护的 WPF 记账工程,通常会把「界面」「逻辑」「数据」三块彻底分开,这也是 MVVM 模式在桌面端最核心的价值——View 只负责长什么样,ViewModel 负责状态和命令,Model 负责数据结构,数据访问单独一层。下面这套目录约定是我见过最稳、也最容易被新手接受的。
2.1 一个可维护的目录长什么样
BookkeepingApp/ ├── App.xaml / App.xaml.cs # 应用入口,全局资源、DI 容器注册 ├── Views/ # 所有 Window / UserControl │ ├── MainWindow.xaml │ ├── DashboardView.xaml # 首页统计 │ ├── TransactionView.xaml # 收支明细录入 │ └── CategoryView.xaml # 分类管理 ├── ViewModels/ │ ├── MainViewModel.cs │ ├── DashboardViewModel.cs │ ├── TransactionViewModel.cs │ └── CategoryViewModel.cs ├── Models/ │ ├── Transaction.cs # 一条收支记录 │ ├── Category.cs # 分类 │ └── Account.cs # 账户(现金/银行卡) ├── Services/ │ ├── IDataService.cs │ ├── SqliteDataService.cs # 数据访问实现 │ └── CsvExportService.cs # 导出 ├── Helpers/ │ ├── RelayCommand.cs # ICommand 实现 │ └── ObservableObject.cs # INotifyPropertyChanged 基类 └── Resources/ ├── Styles.xaml └── Icons.xaml这个结构不是摆设。Views 里只放 XAML,任何Click事件都不写业务代码;ViewModels 里持有ObservableCollection<Transaction>,通过RelayCommand响应按钮;Services 里封装 SQLite 的增删改查。这样做的直接好处是:单元测试可以只针对 ViewModel 和 Service,不需要启动界面。
2.2 Model 与 ViewModel 的边界怎么划
新手最容易犯的错,是把Transaction这种 Model 直接塞进 DataGrid 然后就地改属性。Model 应该是纯数据,最多带一点计算属性,比如:
public class Transaction { public int Id { get; set; } public DateTime Date { get; set; } public decimal Amount { get; set; } // 收入为正,支出为负 public string CategoryName { get; set; } public string Remark { get; set; } // 只读计算属性,供界面显示,不参与持久化 public string AmountDisplay => Amount >= 0 ? $"+{Amount:F2}" : $"{Amount:F2}"; }AmountDisplay这种只读属性放在 Model 里没问题,因为它不涉及业务规则变更。但像「本月结余」「按分类汇总」这类逻辑,必须放到 ViewModel 或 Service,否则 Model 会越来越臃肿,最后变成谁都不敢动的黑匣子。
2.3 数据绑定:WPF 记账系统的命脉
WPF 记账系统能不能用得顺手,八成看数据绑定写没写对。核心是INotifyPropertyChanged和ObservableCollection这两样。前者让单个属性变化能通知界面,后者让集合增删能自动刷新 DataGrid。下面是一个最小可用的 ViewModel 基类:
public class ObservableObject : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged([CallerMemberName] string name = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string name = null) { if (EqualityComparer<T>.Default.Equals(field, value)) return false; field = value; OnPropertyChanged(name); return true; } }SetProperty里先做相等判断再触发通知,这一步很关键。DataGrid 编辑时如果每次赋值都触发PropertyChanged,会出现光标跳动、输入被吞的玄学问题,很多人以为是 WPF 的 bug,其实是通知发多了。
2.4 命令绑定替代事件处理
按钮不要写Click="BtnAdd_Click",改成Command="{Binding AddCommand}"。RelayCommand的标准实现:
public class RelayCommand : ICommand { private readonly Action<object> _execute; private readonly Predicate<object> _canExecute; public RelayCommand(Action<object> execute, Predicate<object> canExecute = null) { _execute = execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute = canExecute; } public bool CanExecute(object parameter) => _canExecute?.Invoke(parameter) ?? true; public void Execute(object parameter) => _execute(parameter); public event EventHandler CanExecuteChanged { add { CommandManager.RequerySuggested += value; } remove { CommandManager.RequerySuggested -= value; } } }CommandManager.RequerySuggested会自动在界面交互后重新查询CanExecute,省去手动调用RaiseCanExecuteChanged的麻烦。参数说明:execute是必填的执行逻辑,canExecute可选,用来控制按钮是否可点,比如「金额为空时禁用保存」。
3. 用 SQLite 落地本地账本:建表、增删改查与导出
记账系统的数据必须落地到本地文件,否则关掉就没了。选 SQLite 的理由很直接:单文件、零配置、支持事务、和 C# 集成成熟。相比把数据存成 CSV 或 JSON,SQLite 在按日期范围查询、按月汇总时优势明显,而且不怕程序崩溃写坏文件。下面按建表到查询的顺序走一遍。
3.1 建表语句与字段设计
CREATE TABLE IF NOT EXISTS Category ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT NOT NULL UNIQUE, Type INTEGER NOT NULL -- 0 支出,1 收入 ); CREATE TABLE IF NOT EXISTS Account ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT NOT NULL, Balance REAL NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS [Transaction] ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Date TEXT NOT NULL, -- ISO8601 字符串,便于排序 Amount REAL NOT NULL, CategoryId INTEGER NOT NULL, AccountId INTEGER NOT NULL, Remark TEXT, FOREIGN KEY (CategoryId) REFERENCES Category(Id), FOREIGN KEY (AccountId) REFERENCES Account(Id) ); CREATE INDEX IF NOT EXISTS idx_tx_date ON [Transaction](Date);字段说明:Date用 TEXT 存 ISO8601(如2024-05-01T12:00:00),SQLite 没有原生日期类型,字符串排序即时间排序,跨平台也稳。Amount用 REAL,个人记账精度够用;如果做多币种或对精度敏感,改成 INTEGER 存「分」。Transaction是 SQL 关键字,加方括号避免语法冲突,这个坑很多人第一次建表就踩。
3.2 用参数化命令做增删改查
public void AddTransaction(Transaction tx) { using var conn = new SqliteConnection(_connectionString); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = @"INSERT INTO [Transaction] (Date, Amount, CategoryId, AccountId, Remark) VALUES (@date, @amount, @cat, @acc, @remark)"; cmd.Parameters.AddWithValue("@date", tx.Date.ToString("s")); cmd.Parameters.AddWithValue("@amount", tx.Amount); cmd.Parameters.AddWithValue("@cat", tx.CategoryId); cmd.Parameters.AddWithValue("@acc", tx.AccountId); cmd.Parameters.AddWithValue("@remark", tx.Remark ?? string.Empty); cmd.ExecuteNonQuery(); }逻辑说明:using var保证连接和命令对象在方法结束时释放,避免文件被占用导致下次打开失败。参数说明:@date用ToString("s")输出可排序格式;@remark做空值兜底,防止 NULL 写入后界面绑定报错。查询时同理,用ExecuteReader逐行读,再映射回Transaction对象。
3.3 按月汇总的查询写法
SELECT substr(Date, 1, 7) AS Month, SUM(CASE WHEN Amount > 0 THEN Amount ELSE 0 END) AS Income, SUM(CASE WHEN Amount < 0 THEN -Amount ELSE 0 END) AS Expense FROM [Transaction] GROUP BY substr(Date, 1, 7) ORDER BY Month DESC;substr(Date, 1, 7)直接截出YYYY-MM,比在 C# 里循环累加快得多。返回结果可以绑定到 Dashboard 的统计卡片,也可以喂给图表控件。
3.4 导出 CSV 的注意点
导出功能看着简单,实际有两个坑:一是中文乱码,二是文件被 Excel 占用时写入失败。写法上先写 UTF-8 BOM:
using var writer = new StreamWriter(path, false, new UTF8Encoding(true)); writer.WriteLine("日期,金额,分类,备注"); foreach (var tx in list) { writer.WriteLine($"{tx.Date:yyyy-MM-dd},{tx.Amount},{tx.CategoryName},{tx.Remark}"); }new UTF8Encoding(true)会写入 BOM,Excel 打开中文才不乱码。写入前用FileShare.Read检测文件是否被占用,被占用就提示用户先关闭,而不是直接抛异常。
4. 避坑与排查:WPF 记账系统最容易翻车的 5 个地方
这一章是我自己踩过、也帮别人排查过的真实问题,按「现象 → 原因 → 解决」写,遇到对应症状直接对号入座。
4.1 DataGrid 编辑后数据没保存
现象:在 DataGrid 里改完金额,切到别的页面再切回来,改动没了。原因:DataGrid 默认的提交时机是「单元格失去焦点」,如果直接点导航按钮,编辑状态没提交,绑定源没更新。解决:在 DataGrid 上设UpdateSourceTrigger=PropertyChanged,或在导航前调用dataGrid.CommitEdit(DataGridEditingUnit.Row, true)。更稳的做法是给列绑定加UpdateSourceTrigger=PropertyChanged,边输边同步。
4.2 SQLite 报「database is locked」
现象:程序运行中偶尔抛SQLite Error 5: database is locked。原因:多个连接同时写,或者上一个连接没释放。解决:统一用一个连接字符串并开启连接池,写操作串行化;读操作可以用Mode=ReadOnly单独开连接。关键是把using写全,别让连接对象悬空。
4.3 金额用 double 导致对不上账
现象:几笔支出加起来和显示的总计差一分钱。原因:double是二进制浮点,0.1 无法精确表示。解决:金额统一用decimal,数据库 REAL 读出后转decimal再运算;或者干脆用 INTEGER 存分。这是财务类程序的铁律,别图省事。
4.4 界面卡死,点按钮没反应
现象:导入几千条记录时界面冻结。原因:数据操作跑在 UI 线程上。解决:把耗时逻辑放进Task.Run,完成后用Dispatcher.Invoke回 UI 线程更新集合。注意ObservableCollection不能跨线程直接改,要么回 UI 线程改,要么用BindingOperations.EnableCollectionSynchronization。
4.5 发布后换台电脑打不开
现象:本机跑得好好的,拷到别人电脑提示缺少 DLL。原因:SQLite 的原生库e_sqlite3.dll没随发布带出去,或者目标机没装对应 .NET 运行时。解决:发布时选「独立部署」并指定运行时标识,或确认runtimes目录完整。用dotnet publish -r win-x64 --self-contained一次搞定。
5. 从能跑到好用:给记账系统加一层可验证的统计与主题
把系统跑通只是起点,真正决定它能不能长期用的是「数据可信」和「看着舒服」。我一般会先加一个自检入口,再考虑换肤。自检的做法是:在启动时跑一遍「期初 + 收入 - 支出 = 期末」的校验,对不上就在状态栏标红。这个习惯救过我很多次,因为记账系统最怕的不是崩溃,而是悄悄算错还看不出来。
5.1 用一条 SQL 做账目自检
SELECT (SELECT IFNULL(SUM(Amount),0) FROM [Transaction]) AS NetChange, (SELECT IFNULL(SUM(Balance),0) FROM Account) AS AccountTotal;两个值理论上应该相等(假设账户余额由交易驱动)。不相等就说明有交易没同步到账户,或者账户被手工改过。把这条查询挂在 Dashboard 的「对账」按钮上,点一下就知道账本干不干净。
5.2 主题切换的最小实现
WPF 换肤不用引入重型框架,把颜色定义成ResourceDictionary,运行时替换即可:
var dict = new ResourceDictionary { Source = new Uri("Resources/DarkTheme.xaml", UriKind.Relative) }; Application.Current.Resources.MergedDictionaries.Clear(); Application.Current.Resources.MergedDictionaries.Add(dict);前提是所有控件颜色都通过{DynamicResource}引用,而不是写死#FF0000。这一步如果一开始没做,后期改起来要动所有 XAML,血泪经验。
5.3 图表统计的选型对比
| 方案 | 上手难度 | 适合场景 | 注意点 |
|---|---|---|---|
| LiveCharts2 | 中 | 折线、饼图、动态刷新 | 版本迭代快,锁版本 |
| OxyPlot | 低 | 静态报表、导出图片 | 样式偏朴素 |
| 自绘 Canvas | 高 | 极简需求、无依赖 | 坐标换算易错 |
个人记账用 OxyPlot 就够,饼图加柱状图两页搞定,依赖少、发布体积小。如果要做实时刷新的动态图,再考虑 LiveCharts2。
5.4 我自己的习惯
每次改完数据层,我一定先跑自检 SQL 再开界面;每次加新控件,先确认它用的是DynamicResource。这两个习惯让我少熬了很多夜。记账系统这类工具,功能可以慢慢加,但账不能算错、界面不能越改越乱。希望帮到你。
本文还有配套的精品资源,点击获取