简介:这是一份基于MFC开发的C++家庭财务管理系统课程设计源码包,面向需要完成C++大作业、课程设计或毕业设计的计算机相关专业在校生,也适合用于项目初期演示。系统涵盖成员管理、收支条目记录、数据存储等典型财务场景,采用MFC经典框架实现,代码模块划分清晰,便于修改扩展。zip压缩包共34个文件,整体约26KB,主要包含8个C++源文件与8个头文件,配合MFS.sln、vcxproj等Visual Studio工程文件可直接打开编译,另有vb相关文件与ReadMe.txt说明文档。目前已有156人学习浏览,作者上传前对各功能均测试通过,答辩平均分达96分。配合ReadMe文档可快速了解项目结构与运行方式,运行遇问题还可私信远程教学,对想借鉴MFC界面设计、梳理财务数据管理逻辑的读者来说,是一份直观的参考示例。
1. 基于 MFC 的家庭财务管理系统大作业:从一个空对话框到能答辩的项目
“C++ 大作业 + MFC + 家庭财务管理系统”这个组合,几乎是 Windows 桌面开发课程里出现频率最高的选题。它的本质是一个典型的 CRUD 应用:能记账、能分类、能按月统计、能导出报表,如果再做一张柱状图,答辩时就有视觉亮点。MFC 的类库把 Win32 的消息循环、窗口过程和控件封装得足够薄,学生能看到窗口程序最真实的样子,又不用直面 200 行窗口回调函数。这个标题里最有价值的部分并不是“家庭财务”这四个字,而是“源代码 + 文档说明”这两个交付物,它们决定了这门课给多少分。真正能拿高分的大作业,通常不是功能最多的,而是结构最清楚的:数据库表设计合理、界面操作符合直觉、代码里有注释、文档里能看到人话。下面按照我自己的做法,从架构、数据层、界面、报表四个层面把这个系统拆开,最后给你一套可以直接开始写的落地路径。这套方案不依赖联网组件,用 Visual Studio 2015 到 2022 都能直接编译。
2. 先定架构:对话框程序、SQLite 落盘与三层模块切分
2.1 为什么大作业选 CDialogEx 而不是文档视图结构
MFC 的 AppWizard 提供两类主程序框架:CWinApp+CFrameWnd+CView的文档视图结构,以及CDialogEx主导的对话框程序。家庭财务管理系统这种工具型软件,对于文档视图结构的序列化机制依赖非常低,反而会被它的多文档、分割窗口、菜单路由搞得很繁琐。我通常会选择基于对话框的应用,主窗口一个CFinanceDlg继承自CDialogEx,配合CWinApp派生类完成程序入口。
// FinanceApp.h class CFinanceApp : public CWinApp { public: virtual BOOL InitInstance(); // 程序入口初始化 }; // FinanceApp.cpp BOOL CFinanceApp::InitInstance() { CWinApp::InitInstance(); CFinanceDlg dlg; // 主对话框栈上构造 m_pMainWnd = &dlg; INT_PTR nResponse = dlg.DoModal(); // 模态循环启动 return FALSE; // 退出后直接释放 }这里的逻辑说明:InitInstance是 MFC 程序的入口等价物,DoModal启动窗口消息循环。栈上构造CFinanceDlg的好处是对话框销毁后内存自动回收,不会出现new出来忘记delete的常见扣分点。return FALSE表示不进入 MFC 的Run循环,因为DoModal内部已经自带消息循环。
2.2 数据层选型:SQLite 以源码方式编译进工程
家庭财务系统的数据量很小,Excel 表格就够存,但大作业的考核点在文件格式和数据处理。不使用 Access 数据库,因为它依赖驱动;不用自定义二进制文件,因为它写起来像交作业而不是做软件。SQLite 是目前 Windows 桌面工具型软件最常见的嵌入式数据库选择,它把整个数据库存放在单个.db文件,备份就是复制文件。
SQLite 接入 VS 工程最稳的方案,是把sqlite3.c和sqlite3.h两个文件直接加入工程编译,不配置任何第三方库目录。需要注意sqlite3.c会定义大量 C 函数,如果工程开了/TP强制 C++ 编译,需要做一步处理。在sqlite3.h最上面加一句:
// sqlite3.h 顶部:让 C API 用 C 方式编译 #ifdef __cplusplus extern "C" { #endif否则链接阶段会报一堆LNK2019: unresolved external symbol sqlite3_open之类的错误。数据层封装一个CBudgetDB类,管理连接、初始化建表和增删改查,所有sqlite3_stmt的释放都放在同一个类的方法里,用 RAII 的思路保证句柄不泄漏。
2.3 数据库表结构:金额用整数分存储
表结构设计是这个项目最值得提前写清楚的部分,答辩时老师看到这三张表,基本就知道设计能力达标。分类表t_category包含收入和支出两个类型,账单表t_bill作为核心业务表,最后留一张t_config存程序级配置。
CREATE TABLE IF NOT EXISTS t_category ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, -- 分类名,如餐饮、工资 type INTEGER NOT NULL DEFAULT 0 -- 0=支出 1=收入 ); CREATE TABLE IF NOT EXISTS t_bill ( id INTEGER PRIMARY KEY AUTOINCREMENT, cate_id INTEGER NOT NULL, -- 关联 t_category.id amount_cents INTEGER NOT NULL, -- 金额,单位:分 bill_date TEXT NOT NULL, -- 账单日期,格式 2025-07-01 note TEXT, -- 备注 create_time TEXT NOT NULL -- 录入时间 ); CREATE INDEX IF NOT EXISTS idx_bill_date ON t_bill(bill_date);金额字段不设计成REAL浮点数,这一点相当关键。REAL存19.9可能变成19.899999,月末汇总时出现几分钱对不上账的尴尬。用amount_cents表示“分”,界面显示时做一次除法,统计求和直接做整数加法,永不失真。bill_date用文本格式YYYY-MM-DD存储,保证排序和筛选语句不用写函数做格式转换。
2.4 三层模块切分:不做分层的大作业走不远
一个 2000 行代码全部堆在主对话框类里的 MFC 程序,维护起来是灾难。家庭财务系统按照界面层、业务层、数据层三层切分就够用,类是全部自定义的:
| 层 | 类名 | 职责 |
|---|---|---|
| 界面层 | CFinanceDlg | 主窗口、按钮事件、列表刷新 |
| 界面层 | CBillEditDlg | 新增/编辑账单的模态子窗口 |
| 业务层 | CBudgetService | 月度统计、分类汇总、校验逻辑 |
| 数据层 | CBudgetDB | SQLite 封装、表的增删改查 |
CFinanceDlg不直接调用sqlite3_*函数,统一通过CBudgetService中转。CBudgetService也不知道界面上有文本框,只接收CString和整数参数,返回结果集。这样做的直接好处是,答辩时老师问“如果要把界面换成 Qt,要改哪些代码”,可以回答只需要重写界面层,业务层原封不动。这一个回答就能拉开与大作业平均水平差距。
3. 核心功能落地:CListCtrl 流水展示、月份筛选与账单编辑
3.1 用 CListCtrl 装载数据:InsertItem 配合 SetItemText
主界面的账单流水列表用CListCtrl,视图类型设置为报告模式,外观属性勾选整行选择和网格线。初始化列表列头和加载数据的代码放在同一个函数里,因为刷新和初始化只差一个清空操作。
void CFinanceDlg::InitListCtrl() { m_listBill.DeleteAllItems(); // 清空旧数据,防止重复刷新 DWORD style = m_listBill.GetExtendedStyle(); style |= LVS_EX_FULLROWSELECT; // 整行选中 style |= LVS_EX_GRIDLINES; // 显示网格 m_listBill.SetExtendedStyle(style); m_listBill.InsertColumn(0, L"日期", LVCFMT_LEFT, 90); m_listBill.InsertColumn(1, L"分类", LVCFMT_LEFT, 80); m_listBill.InsertColumn(2, L"金额", LVCFMT_RIGHT, 90); m_listBill.InsertColumn(3, L"备注", LVCFMT_LEFT, 200); }逻辑说明:DeleteAllItems只删行数据不删列头,所以重复调用不会产生重复列。LVS_EX_FULLROWSELECT是用户最在意的体验点,没有这个扩展样式时只能点击第一列才能选中整行,很多作业漏掉这个配置会导致列表操作非常别扭。列宽按内容频次分配,备注列留宽一些,CListCtrl在报告模式下拖拽表头分隔线调整列宽是自动生效的,不需要额外代码。
3.2 数据绑定与刷新:从 SQL 结果集到列表项
加载数据时,SQL 采用账单表左连接分类表,一次查询把分类名称带出来,避免在循环里再去查一次分类表。这里使用sqlite3_prepare_v2的预编译方式而不是拼接字符串,养成参数绑定的习惯,后面接文本框做模糊搜索时不需要二次返工。
bool CBudgetDB::LoadBills(int year, int month, std::vector<BillInfo>& outList) { CString sql = L"SELECT b.id, c.name, b.amount_cents, b.bill_date, b.note " L"FROM t_bill b LEFT JOIN t_category c ON b.cate_id = c.id " L"WHERE b.bill_date >= ? AND b.bill_date <= ? " L"ORDER BY b.bill_date DESC, b.id DESC"; sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(m_db, CT2A(sql), -1, &stmt, nullptr) != SQLITE_OK) return false; CString startDay, endDay; startDay.Format(L"%04d-%02d-01", year, month); int lastDay = GetMonthDayCount(year, month); endDay.Format(L"%04d-%02d-%02d", year, month, lastDay); sqlite3_bind_text(stmt, 1, CT2A(startDay), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, CT2A(endDay), -1, SQLITE_TRANSIENT); while (sqlite3_step(stmt) == SQLITE_ROW) { BillInfo info; info.id = sqlite3_column_int(stmt, 0); info.cateName = CString(CA2T((const char*)sqlite3_column_text(stmt, 1))); info.amountCents = sqlite3_column_int(stmt, 2); info.billDate = CString(CA2T((const char*)sqlite3_column_text(stmt, 3))); info.note = CString(CA2T((const char*)sqlite3_column_text(stmt, 4))); outList.push_back(info); } sqlite3_finalize(stmt); // 释放句柄 return true; }这里的参数说明:sqlite3_bind_text的最后一个参数传SQLITE_TRANSIENT表示 SQLite 会在内部复制字符串内容,如果传SQLITE_STATIC则要求传入的字符串在语句执行期间一直有效,MFC 的CString在作用域结束后就释放了,传SQLITE_STATIC容易踩到野指针。sqlite3_column_int返回的是int,AUTOINCREMENT主键过大会溢出,但家庭记账的账单量达不到这个量级,可以放心用。GetMonthDayCount是工具函数,用CTime先取下个月的第一天再减一天,稳妥得到当月最后一天。
3.3 月份筛选与统计数据:CASE WHEN 一条 SQL 搞定收支汇总
月份选择用CMonthCalCtrl太占空间,主流做法是放一个年月CComboBox,或者用CDateTimeCtrl设置显示格式为年月。筛选参数变化时重新调用LoadBills并刷新列表。月度统计作为家庭财务系统的核心数字,用一个汇总函数同时取出收入合计和支出合计:
bool CBudgetDB::GetMonthSummary(int year, int month, __int64& income, __int64& expense) { CString sql = L"SELECT " L" SUM(CASE WHEN c.type = 1 THEN b.amount_cents ELSE 0 END), " L" SUM(CASE WHEN c.type = 0 THEN b.amount_cents ELSE 0 END) " L"FROM t_bill b LEFT JOIN t_category c ON b.cate_id = c.id " L"WHERE b.bill_date >= ? AND b.bill_date <= ?"; sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(m_db, CT2A(sql), -1, &stmt, nullptr) != SQLITE_OK) return false; CString startDay, endDay; startDay.Format(L"%04d-%02d-01", year, month); endDay.Format(L"%04d-%02d-31", year, month); sqlite3_bind_text(stmt, 1, CT2A(startDay), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, CT2A(endDay), -1, SQLITE_TRANSIENT); bool ok = false; if (sqlite3_step(stmt) == SQLITE_ROW) { income = sqlite3_column_int64(stmt, 0); expense = sqlite3_column_int64(stmt, 1); ok = true; } sqlite3_finalize(stmt); return ok; }说明:SUM在没有任何匹配行时返回NULL,这时sqlite3_column_int64返回 0,所以统计结果为空月份时不会显示乱码。CASE WHEN表达式是标准 SQL 写法,不依赖 SQLite 特有的扩展语法,换成 MySQL 或者 SQL Server 依然通用。日期筛选这里统一用格式化的月末日期,SQLite 文本比较在YYYY-MM-DD格式下就是字典序时间序,不需要调用strftime。
3.4 双击编辑账单:NMITEMACTIVATE 的响应
列表双击某一行时弹出编辑对话框,这是桌面程序的标准交互。CListCtrl的通知消息有LVN_ITEMCHANGED(勾选或状态变化)和NM_DBLCLK(双击),可以只用NMITEMACTIVATE一个消息,它同时包含鼠标信息和行号:
void CFinanceDlg::OnNMDblclkListBill(NMHDR* pNMHDR, LRESULT* pResult) { LPNMITEMACTIVATE pItem = reinterpret_cast<LPNMITEMACTIVATE>(pNMHDR); if (pItem->iItem < 0) { // 双击空白区域 *pResult = 0; return; } // 从列表拿当前行的 id __int64 billId = (__int64)m_listBill.GetItemData(pItem->iItem); CBillEditDlg dlg(Context, billId); if (dlg.DoModal() == IDOK) { RefreshBillList(); // 编辑完成后刷新主列表 } *pResult = 0; }SetItemData在加载列表时把账单的数据库主键绑定到列表行上,GetItemData拿回来,这样双击触发时不需要再去匹配界面显示的文本,从数据库唯一主键直接加载到编辑对话框。这个设计避免了一个常见问题:用户改名或列表排序后,行号对应的账单对象变了。
3.5 编辑对话框的封装:一个类管全部单据录入
新增和编辑共用一个CBillEditDlg,区别只在构造函数传入的账单 id,传 0 表示新增。对话框里有分类下拉框、日期选择、金额编辑框、备注多行文本框。分类下拉框在OnInitDialog中从数据库加载,格式为“分类名(收入/支出)”,并把分类 id 存到SetItemData。金额编辑框限制只能输入数字和小数点:
void CBillEditDlg::OnOk() { CString amountText; GetDlgItemText(IDC_EDIT_AMOUNT, amountText); amountText.Trim(); if (amountText.IsEmpty()) { AfxMessageBox(L"请输入金额"); return; } // 将元转换为分:先移位再做整数转换 __int64 amountCents = _ttoi64(amountText) * 100; if (amountCents <= 0) { AfxMessageBox(L"金额必须大于 0"); return; } // 校验分类已选择、日期合法 int cateId = (int)m_comboCategory.GetItemData(m_comboCategory.GetCurSel()); if (cateId <= 0) { AfxMessageBox(L"请选择分类"); return; } // 数据合法,调用业务层保存 ... }说明:金额输入先_ttoi64再用整型存分,不在对话框里引入double。对小数部分的处理,工业级做法是拆成整数部分和小数部分分别计算,大作业里_ttoi64乘以 100 的精度在这个场景下足够,但用户输入小数时会被截断,注释里写清楚即可。点确定时校验失败用AfxMessageBox提示并return,不让对话框关闭,这是一个最基本的输入验证思路。
4. 报表与展示:自绘柱状图,给 CListCtrl 和字符集收尾
4.1 用 CPaintDC 自绘月度收支柱状图
统计图是家庭财务管理系统答辩时的加分项,而 MFC 自带控件里没有图表控件,常见方案有两条路:买第三方图表库,或者自己用CPaintDC画。大作业建议走自绘路线,代码量不大,还能回答老师的追问。做法是定制一个继承自CStatic的控件类CBillChartCtrl,重写OnPaint,外部调用SetData把 12 个月的收入和支出传进去。
void CBillChartCtrl::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(&rcClient); dc.FillSolidRect(&rcClient, RGB(255, 255, 255)); // 白底 // 计算数据最大值,用于纵向缩放 __int64 maxVal = 1; for (int i = 0; i < 12; i++) { maxVal = max(maxVal, m_income[i]); maxVal = max(maxVal, m_expense[i]); } int baseY = rcClient.bottom - 30; // 横轴基线 int barWidth = (rcClient.Width() - 20) / 24; // 每个柱宽,收入支出各占一半 int gap = 4; // 柱间空隙 for (int i = 0; i < 12; i++) { int x0 = rcClient.left + 10 + i * barWidth * 2; // 支出柱 int hExp = (int)((rcClient.Height() - 60) * (double)m_expense[i] / maxVal); CRect rcExp(x0, baseY - hExp, x0 + barWidth - gap, baseY); dc.FillSolidRect(&rcExp, RGB(220, 80, 80)); // 红色系 // 收入柱 int hInc = (int)((rcClient.Height() - 60) * (double)m_income[i] / maxVal); CRect rcInc(x0 + barWidth, baseY - hInc, x0 + barWidth * 2 - gap, baseY); dc.FillSolidRect(&rcInc, RGB(80, 160, 80)); // 绿色系 } }这段代码的说明:CPaintDC的构造函数内部调用BeginPaint,析构时自动EndPaint,所以不要在OnPaint里手动调BeginPaint。分配给收入柱和支出柱的矩形按照月份索引连续排列,barWidth * 2保证一对柱子总是挨在一起。等比缩放使用double先除再乘,避免整数除法直接截断成 0。控件刷新时机也很讲究,外部修改数据后调用Invalidate(FALSE),FALSE表示不擦除背景,配合上层的白色填充可以避免闪烁感。
4.2 CListCtrl 插入列时 LVCFMT_LEFT 不起作用的排查
一个常见的 MFC 排错现场:InsertColumn(0, L"日期", LVCFMT_LEFT, 90)执行后,第一列的文字没有按预期左对齐,看起来像居中或右边空了一截。这个问题的本质是报告视图下第一列有个特殊的表头样式,InsertColumn需要同时指定LVCFMT_LEFT到pszText,但有时 MFC 内部把第一个列的格式按照默认LVCFMT_LEFT处理,换了写法才生效:
LVCOLUMN col; ZeroMemory(&col, sizeof(col)); col.mask = LVCF_TEXT | LVCF_WIDTH | LVCF_FMT; col.fmt = LVCFMT_LEFT; col.cx = 90; col.pszText = const_cast<LPWSTR>(L"日期"); m_listBill.InsertColumn(0, &col);排查优先级是:先看插入列的索引位置是否重复,再看mask是否包含LVCF_FMT。如果只设置了文本和宽度,格式字段会被 MFC 当成无符号值处理,表现出来就是对齐方式时对时不对。这个调试思路适合任何LVCOLUMN链路,不只是标题栏对齐这种小问题。
4.3 导出明细:用 CStdioFile 写出 UTF-8 带 BOM 的文本
家庭财务管理系统要有报表导出功能才能算完整。导出到 Excel 需要装 Office 组件,答辩现场环境不可控,我会优先导出.csv或纯文本报告。CStdioFile默认使用 ANSI 编码写文件,在其他语言环境的机器上打开中文会乱码,改成写 UTF-8 带 BOM 可以兼容记事本和 Excel:
CStdioFile file; if (!file.Open(m_path, CFile::modeCreate | CFile::modeWrite)) { AfxMessageBox(L"文件创建失败,请检查路径权限"); return; } BYTE utf8Bom[] = {0xEF, 0xBB, 0xBF}; file.Write(utf8Bom, 3); CString line; line = L"月份,分类,金额(元),日期,备注\r\n"; file.WriteString(line); for (size_t i = 0; i < vBills.size(); i++) { CString amountText; amountText.Format(L"%.2f", vBills[i].amountCents / 100.0); line.Format(L"%s,%s,%s,%s,%s\r\n", vBills[i].billDate, vBills[i].cateName, amountText, vBills[i].billDate, vBills[i].note); CStringA utf8Line = CW2A(line, CP_UTF8); file.Write(utf8Line, utf8Line.GetLength()); } file.Close();写入 BOM 后,Excel 双击打开 CSV 文件会识别为 UTF-8 编码,中文不再乱码。CStringA utf8Line = CW2A(line, CP_UTF8)把宽字符转到 UTF-8 字节序列再写文件,这句是整个导出功能的核心,值得在文档里单独解释。如果对方程序员用 VSCode 打开这个 CSV,编码识别也正常。
5. 文档说明、答辩演示与三个必踩的坑
5.1 文档说明怎么组织:需求设计测试三位一体
标题里明确要求“文档说明”,很多同学交的是把代码复制到 Word 里,这是最可惜的扣分项。足够支撑答辩的技术文档由三部分组成。需求分析写清单式说明:用户角色为“家庭成员”,核心功能按模块列出记账、查询、统计、导出四大模块。概要设计放类图、数据库表结构、核心流程时序说明。测试部分写三个典型测试用例:新增收入后月度统计是否更新、跨月查询边界是否正确、导出文件用 Excel 打开是否无乱码。文档首页附环境说明:Visual Studio 版本、Windows SDK 版本、字符集设置,这个信息量对老师复现项目很重要。
文档里还要放一份源代码文件清单,表格形式列出每个类的文件路径和职责简述,例如“CBudgetDB.cpp:SQLite 封装,负责建表和增删改查”。这相当于给阅读者一张地图,答辩时老师问到任何一个文件,都能快速定位到对应代码行。整份文档控制在 15 页以内,配 3 到 4 张界面截图,不要使用截图工具的大面积水印。
5.2 答辩演示脚本:按“新增、筛选、统计、报表”的顺序走一遍
演示环节 90% 的翻车原因是现场数据被删了,或者现场录入时打错数字。提前准备一份演示用的数据库文件,里面包含两个月的 15 条账单记录,收入和支出各占一半。演示顺序按照日常使用习惯:启动程序,先展示主界面整体布局;新增一条“2025-07-15 超市采购 168.50”,保存后列表自动刷新,月统计数字变化;切换月份,展示列表和柱状图同步更新;切回有数据的月份,展示统计区域收入大于支出的情况;点击导出,用系统记事本打开生成的 CSV 展示内容。
演示过程中不要讲代码,只讲功能和操作。讲到柱状图时切换窗口,把收入支出柱状图的颜色标识和统计口径讲清楚。老师如果追问底层实现,再切回代码,这时优先展示数据库初始化函数和LoadBills的参数绑定代码,这两个函数一眼就能看出设计水平。
5.3 三个必须提前处理的编译与调试隐患
第一个坑是错误提示fatal error C1189: #error: Building MFC application with /MD[d] ... requires MFC shared library。MFC 工程的运行库设置必须配Use MFC in a Shared DLL,在项目属性页“配置属性 -> 高级 -> MFC 的使用”里切换,使用静态库时须选择匹配的动态运行库。编译时若提示Cannot open include file: 'afxwin.h',说明安装 VS 时未勾选“适用于 v143 生成工具的 C++ MFC”,安装程序里找到“使用 C++ 的桌面开发”工作负载,右侧勾选 MFC 组件后修复即可。
第二个坑是调试时提示“当前不会命中断点,还没有为该文档加载任何符号”。通常是因为Debug配置和x86/x64平台选择了不同位数的 MFC 运行库,或者误操作在Release配置下调式。菜单“调试 -> 选项 -> 符号”里勾选 Microsoft 符号服务器,并关闭“仅我的代码”,这一步能解决多数外部库符号缺失问题。
第三个坑与字符集相关:工程默认字符集是 Unicode,数据库里的中文通过CT2A转 ANSI 写入时出现问号。解决方法是所有跨 MFC 和 SQLite 边界的字符串统一使用CW2A/CA2W且显式指定CP_UTF8,并把 SQLite 数据库文件路径写成宽字符CString再转UTF-8传给sqlite3_open_v2。偏好设置里统一记录“入库前转 UTF-8,取出来转宽字符”,这个约定写进文档说明,后续维护者不会踩复用坑。
源码中的原生sqlite3_open只接受const char*,写宏也可以用sqlite3_open_v2(m_dbPathUtf8, &m_db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nullptr),第二个参数指向已存在的句柄,注意如果传入旧连接要先sqlite3_close。家庭成员财务数据不是高并发场景,连接保持单例即可,不需要每次操作都开关数据库。
本文还有配套的精品资源,点击获取