
简介这是一份基于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 提供两类主程序框架CWinAppCFrameWndCView的文档视图结构以及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月度统计、分类汇总、校验逻辑数据层CBudgetDBSQLite 封装、表的增删改查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::vectorBillInfo outList) { CString sql LSELECT b.id, c.name, b.amount_cents, b.bill_date, b.note LFROM t_bill b LEFT JOIN t_category c ON b.cate_id c.id LWHERE b.bill_date ? AND b.bill_date ? LORDER 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返回的是intAUTOINCREMENT主键过大会溢出但家庭记账的账单量达不到这个量级可以放心用。GetMonthDayCount是工具函数用CTime先取下个月的第一天再减一天稳妥得到当月最后一天。3.3 月份筛选与统计数据CASE WHEN 一条 SQL 搞定收支汇总月份选择用CMonthCalCtrl太占空间主流做法是放一个年月CComboBox或者用CDateTimeCtrl设置显示格式为年月。筛选参数变化时重新调用LoadBills并刷新列表。月度统计作为家庭财务系统的核心数字用一个汇总函数同时取出收入合计和支出合计bool CBudgetDB::GetMonthSummary(int year, int month, __int64 income, __int64 expense) { CString sql LSELECT 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) LFROM t_bill b LEFT JOIN t_category c ON b.cate_id c.id LWHERE 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_castLPNMITEMACTIVATE(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_castLPWSTR(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 可以兼容记事本和 ExcelCStdioFile 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.cppSQLite 封装负责建表和增删改查”。这相当于给阅读者一张地图答辩时老师问到任何一个文件都能快速定位到对应代码行。整份文档控制在 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。家庭成员财务数据不是高并发场景连接保持单例即可不需要每次操作都开关数据库。本文还有配套的精品资源点击获取