MFC外卖平台源码解析:数据流、链表操作与Dijkstra路径算法
2026/9/15 17:32:12 网站建设 项目流程

简介:这是一份适合C++初学者参考的MFC外卖平台课程设计项目,覆盖顾客登录、点餐结算、历史订单查看、路径预估配送、确认收货,以及商家订单管理、发货和菜品库存维护等场景,可一站式了解MFC界面与业务逻辑如何结合。项目包含顾客和商家两类模块,顾客可查看商家菜品、加入购物车、计算价格并结账,商家可查看订单、确认发货、维护菜品与库存,功能链完整。压缩包共91个文件,以23个.h、18个.cpp源码文件为核心,另含2个可执行exe、项目配置、2个PPT开题结题报告及结题文档,整体约37.12MB,便于对照源码理解模块划分。数据结构上使用链表组织顾客、商家、菜品、订单信息,程序启停时从txt文件读写保存,适合查看具体实现。目前已有38人学习下载,对正在做课程设计或希望快速上手MFC项目结构的人有一定参考价值。

1. 一份用 MFC 写的外卖平台源码,拆开之后难点其实在数据流

把这份大一课程项目压缩包解压后,我第一个动作不是双击 ZSWM.exe,而是先打开 ZSWM.sln 看工程结构。第一眼是典型的 MFC 对话框程序:一堆 CDLG 开头的类、几个 BMP 位图,还有开题和结题 PPT。但跟着流程跑完一遍顾客下单、骑手配送、商家发货、顾客签收之后,我发现它的价值不在界面,而在数据流——所有订单、菜品、账号数据在程序启动时从 txt 初始化成链表,运行中增删改,退出时再顺序写回。路径计算那一块也不是调地图 API,而是在 MFC 地图对话框里用图算法自己找路径。适合谁读?想搞懂 MFC 对话框程序怎么组织 C++ 类的人,想拿真实业务练链表和文件读写的人,以及准备在此基础上加功能做毕设的人。

2. 数据层:链表初始化、TXT 落盘与订单对象如何串起整个外卖闭环

MFC 对话框程序按钮一多,最怕的就是每个对话框各玩各的内存,退出之后什么也没留下。这份源码把数据层收敛得很清楚:四个业务对象——顾客、商家、菜品、订单——全部在程序启动时从 txt 初始化成链表,运行期间所有对话框共用同一份链表指针,退出前再把改动顺序写回文件。这样无论是顾客端结账还是商家端改库存,最后都落到“链表节点增删改 + 保存函数”这两步。

2.1 订单和菜品在内存里的实际形态

先看还原后的核心结构。压缩包里的 cus_dishinformation.h 和 InfoFile.h 承担了主要的数据定义和读写,我这里按最常见的写法把它整理出来:

// 菜品节点:对应 cus_dishinformation.txt 里的每一条数据 struct Dish { CString name; // 菜品名 double price; // 单价 int stock; // 库存 Dish* next; Dish() : price(0.0), stock(0), next(nullptr) {} }; // 订单节点:一个订单包含买家、卖家、菜品明细和四个时间点 struct Order { int id; // 订单 ID CString buyer; // 买家名 CString seller; // 卖家名 CString items; // 菜品数据:"菜名*份数;菜名*份数" double totalPrice; // 总价 CString orderTime; // 下单时间 CString sendTime; // 发货时间 CString receiveTime; // 收货时间 CString address; // 收货地点 Order* next; Order() : id(0), totalPrice(0.0), next(nullptr) {} };

这里菜品明细没有单独建一张明细表,而是用CString items把“菜品名*份数”用分号拼起来。对大一的课程设计来说,这种写法能少写两个链表,解析时遍历一次就能把购物车还原出来,代价是统计某个菜品销量时必须全表扫描。如果你后续要做“热销菜品排行”,这个字段就得拆成独立订单明细链表。

2.2 启动加载与退出写回:InfoFile 的对称逻辑

订单链表从文件加载的常见做法是用 CStdioFile 逐行读,按分隔符切字段。下面这段代码还原了 InfoFile.cpp 里的主循环:

bool LoadOrders(const CString& path, Order*& head) { CStdioFile file; if (!file.Open(path, CFile::modeRead | CFile::typeText)) return false; CString line; while (file.ReadString(line)) { line.TrimRight(_T("\r\n")); if (line.IsEmpty()) continue; Order* node = new Order(); CString f[9]; int idx = 0, start = 0; while (idx < 9) { int pos = line.Find(_T('|'), start); if (pos < 0) { f[idx] = line.Mid(start); break; } f[idx] = line.Mid(start, pos - start); start = pos + 1; ++idx; } node->id = _ttoi(f[0]); node->buyer = f[1]; node->seller = f[2]; node->items = f[3]; node->totalPrice = _ttof(f[4]); node->orderTime = f[5]; node->sendTime = f[6]; node->receiveTime = f[7]; node->address = f[8]; node->next = head; // 头插法,方便 O(1) head = node; } file.Close(); return true; }

这个循环的几个参数值得注意:分隔符必须是|,而且每个字段内不能再用|;时间字段直接用字符串保存,避免引入 CTime 的序列化和反序列化;头插法会让文件里的顺序在链表里反序,所以如果你希望订单按 ID 正序展示,得在查询链表时做一次排序。排序不用自己写,用 CList 的排序或直接冒泡都可以,这个项目的规模下冒泡根本不会慢。

整个工程的数据文件映射如下:

文件存储内容对应链表
login.ini顾客登录账号登录校验结构
Cus_login.ini顾客扩展信息顾客信息链表
cus_dishinformation.txt菜品名、价格、库存Dish 链表
stock.txt库存冗余/修改记录库存链表
订单输出文件订单九个字段Order 链表

保存函数就是加载函数的逆操作,按链表顺序把每个节点拼成一行写入。注意保存时不能只覆盖原文件,常见做法是先写临时文件再替换,防止程序在写文件的中途被结束,留下截断数据。

2.3 最容易踩的坑:编码、空字段和写回时机

MFC 默认使用 Unicode 字符集,但这份源码里的 txt 是 ANSI 保存的中文。用 CStdioFile 的 ReadString 读取时,如果文件是 UTF-8 编码,会把每个中文读成两个字符的乱码,所以保持源文件编码不变,或者统一转成 UTF-8 后重新保存一次。第二个坑是空字段:订单字段里有未发货或未签收的时间串,此时行会以两个连续分隔符出现,比如|2025-01-01 10:00||,解析时 Find 返回两个|之间的空串,处理时要允许空字符串直接赋值。第三个坑是写回时机:只写了函数但没有在每个按钮事件里调用,运行中一切正常,一重启全没了。接单发货和签收这种关键操作成功后,应该立刻调用保存函数。

提示:如果源文件是 UTF-8 编码,在 VS 中另存为 ANSI 后中文乱码会立刻消失,但改之前先备份原始文件,防止另存过程把 BOM 头带进去。

3. 顾客端与商家端:登录校验、购物车解析和订单状态机

数据层跑通之后,界面层剩下的就是把按钮消息接到链表上。顾客端涉及的对话框有 CDLG_LOGIN、CDIG_CUSANDSHOP、CDIG_CUS_DIANCAN、CDIG_CUS_FUWU、CDLG_CUS_LISHIDINGDAN;商家端集中在 CDIG_CUSANDSHOP 和 CDIG_CUS_INFORMATION。名称虽然绕,但逻辑线只有四条:登录、下单、发货、签收。

3.1 登录校验:用 GetPrivateProfileString 读 ini

顾客和商家复用同一套登录对话框,区别只在进入后跳转的父窗口。账号数据写在 login.ini 和 Cus_login.ini 里,读取方式常见做法是 GetPrivateProfileString:

CString account, password; GetPrivateProfileString(_T("account"), _T("user"), _T(""), account.GetBuffer(128), 128, _T("login.ini")); account.ReleaseBuffer(); GetPrivateProfileString(_T("account"), _T("pass"), _T(""), password.GetBuffer(128), 128, _T("login.ini")); password.ReleaseBuffer(); if (m_editUser == account && m_editPass == password) { // 打开对应的功能主对话框 } else { MessageBox(_T("账号或密码错误")); }

这里 GetBuffer 后必须配对 ReleaseBuffer,否则 CString 内部缓存长度不会更新,后面比较可能出错。参数第三项是默认值,如果 ini 里没有该键就返回空串;第五项是缓冲区大小,128 对课程项目的账号密码足够。如果直接用相对路径,从 VS 里运行时工作目录是项目目录,双击 exe 运行时工作目录是 exe 所在目录,文件找不到时最先查这个问题。

3.2 购物车与结账:解析“菜品名*份数”字符串

顾客在 CDIG_CUSANDSHOP 里选择商家,在 CDIG_CUS_DIANCAN 里选菜。购物车没有单独做一个类,而是维护一个形如小炒肉*2;米饭*1;的 CString。结账时按分号和星号拆开:

void ParseOrderItems(const CString& items, CStringArray& names, CArray<int>& counts) { int pos = 0; CString token = items.Tokenize(_T(";"), pos); while (!token.IsEmpty()) { int star = token.Find(_T('*')); if (star > 0) { names.Add(token.Left(star)); counts.Add(_ttoi(token.Mid(star + 1))); } token = items.Tokenize(_T(";"), pos); } }

Tokenize 的第二个参数是记录位置的整型引用,第一次传入 0,内部会跳过连续分隔符。star 小于等于 0 说明这一节格式不合法,直接忽略而不是中断,能防止脏数据把整个订单卡死。拆出菜名后到 Dish 链表里查价格,乘份数累加得到 totalPrice,再连同当前时间和收货地址一起塞进 Order 节点。链表查询是线性的,菜品量大时这里会慢,但课程设计规模下完全不用优化。

3.3 订单状态机:从下单到签收的四态流转

摘要里提到的“计算路径送货、签收”,对应订单表的四个状态:

状态触发者关键操作数据变化
待确认顾客结账插入 Order 节点写入订单文件
待发货商家查看订单确认发货并填发货时间sendTime 写入
配送中骑手路径计算完成显示预计到达时间更新显示
已签收顾客点击签收填收货时间receiveTime 写入

状态更新函数往往散落在三个对话框里,建议收敛成一个公共入口:

void UpdateOrderState(Order* head, int orderId, int state, const CString& time) { for (Order* p = head; p != nullptr; p = p->next) { if (p->id != orderId) continue; if (state == 1) p->sendTime = time; if (state == 2) p->receiveTime = time; SaveOrders(head); // 全链表写回,规模小的时候最简单 break; } }

state 的值要和按钮消息一一对应。这里最容易出现的 bug 是订单 ID 撞号:如果加载时没有做 max(id)+1 的单调分配,新订单会把旧订单覆盖。检查加载逻辑,确保每次新增订单前先遍历链表拿到当前最大 ID 再自增。

3.4 商家端菜品管理:改库存和添加菜品

商家端处理的是 Dish 链表。修改库存只需找到对应节点改 stock 字段,然后调用 WriteDishToFile。添加菜品时用头插法插入,新菜品会排在链表最前,展示时如果要按原有顺序,需要用尾插或添加后再做一次排序:

Dish* AddDish(Dish*& head, const CString& name, double price, int stock) { Dish* node = new Dish(); node->name = name; node->price = price; node->stock = stock; node->next = head; head = node; return node; }

参数里注意 price 的类型是 double,从编辑框控件取回后要用 _ttof 转换,而不是 _ttoi,否则单价的小数直接丢失。库存修改后如果商家没有点保存按钮就关窗口,改动会丢掉,所以一般在 OnOK 或 OnClose 里统一写回。

4. 路径计算与签收触发:ZSWM_MAPDlg 里的图算法实现

压缩包里最显眼的资源是 Map.bmp、pic_seu.bmp 和 ZSWM_MAPDlg。地图对话框不显示现代地图瓦片,而是一张静态位图,配送路径计算完全由 C++ 代码在图上完成。

4.1 地图数据结构:位图背景 + 路径点集

常见的做法是把 Map.bmp 当作背景,在 CZSWMMAP 类里维护一个路径点坐标数组,每个点是路口或商家/顾客位置,点间距离按像素坐标计算。比如点结构是:

struct MapPoint { int x, y; // 位图上的像素坐标 int nodeId; // 图节点编号 MapPoint() : x(0), y(0), nodeId(0) {} };

两个点的实际距离用欧氏距离再乘比例尺:distance = sqrt(dx*dx + dy*dy) * scale。scale 是把像素换算成米的系数,这个系数直接决定预计到达时间准不准。没有 GPS 坐标时,可以按地图上两个已知地点的实际距离反推比例尺。一张简单的地图点集大概长这样:

nodeId位图坐标角色
0(120, 480)骑手出发点
1(260, 360)商家 B
2(480, 180)顾客 A
3(360, 90)顾客 C

4.2 最短路径计算:邻接矩阵 + 朴素 Dijkstra

如果是带权图,最合适的算法是 Dijkstra;如果所有路径段的权重相同,用 BFS 可以更快。下面这段是用邻接矩阵实现的朴素 Dijkstra,节点数在 100 以内时不需要堆优化:

const int MAXN = 100; int g[MAXN][MAXN]; // 邻接矩阵,0 表示不连通 int dist[MAXN], pre[MAXN]; bool visited[MAXN]; void Dijkstra(int n, int start) { memset(visited, 0, sizeof(visited)); for (int i = 0; i < n; ++i) { dist[i] = 0x3f3f3f3f; pre[i] = -1; } dist[start] = 0; for (int i = 0; i < n; ++i) { int u = -1, minv = 0x3f3f3f3f; for (int j = 0; j < n; ++j) { if (!visited[j] && dist[j] < minv) { u = j; minv = dist[j]; } } if (u == -1) break; visited[u] = true; for (int v = 0; v < n; ++v) { if (!visited[v] && g[u][v] > 0 && dist[u] + g[u][v] < dist[v]) { dist[v] = dist[u] + g[u][v]; pre[v] = u; } } } }

dist 是起点到每个点的最短距离,pre 存的是每个点最短路径上的前驱节点,最后从终点顺着 pre 数组就能把整条配送路径的经过点反推出来。循环里用 u == -1 作为提前退出条件,避免不连通图浪费一次全遍历。

如果不想维护权重,把矩阵改成g[u][v] = 1表示可通行,Dijkstra 就退化成 BFS 的效果,此时可以直接换成队列 BFS,代码更短,而且路径顺序天然就是最少转弯路径。答辩时建议两段算法都准备,老师问“为什么不用 BFS”时能说出带权和不带权的差别。

4.3 预计到达时间:距离、速度与格式化输出

路径算出来后累加每段距离,然后根据骑手速度换算分钟数:

double totalDist = 0.0; // 已经按比例尺换算成米 int cur = endNode; while (cur != startNode) { int p = pre[cur]; if (p < 0) break; // 没有前驱,说明不连通 totalDist += GetSegmentDistance(p, cur); cur = p; } const double speedKph = 30.0; // 骑手平均速度,可调 double minutes = totalDist / 1000.0 / speedKph * 60.0; CString text; text.Format(_T("预计到达时间:%.0f 分钟"), minutes); SetDlgItemText(IDC_ETA_TEXT, text);

speedKph 是人为设定的业务参数,不是算法参数。想模拟不同时段的配送,把餐高峰的 25 和夜间的 35 做成下拉框选项,比每次改源码重新编译方便得多。显示到达时间后,顾客点“签收”就把 receiveTime 置为当前时间,并调用文件写回函数,这条订单的闭环就完成了。

4.4 一个容易被忽略的坐标坑

路径点数组的下标和位图上的 x/y 坐标是两套东西,位图左上角是 (0,0),y 轴向下。如果直接把客户对话框中的坐标当作路径点坐标传入 Dijkstra,算出来的距离会完全不符合直觉。所有从界面上获取的坐标,必须在进入算法前统一映射到路径点数组的 nodeId。

5. 实操技巧:把这份 MFC 外卖源码改装得更像一份工程作品

5.1 编译前先处理 MFC 运行环境

用 VS2019 或 VS2022 打开 ZSWM.sln 后,最常见的报错是“此项目需要 MFC 库”。这个错不是代码问题,是安装 VS 时没有勾选“适用于最新 v143 生成工具的 C++ MFC”。打开 Visual Studio Installer,修改单个组件或使用 C++ 桌面开发工作负载下的 MFC 组件,安装后重启即可。编译出的 ZSWM.exe 在别的机器上跑,如果没有安装 Visual C++ Redistributable,会直接提示缺少 VCRUNTIME 或 MFCU 动态库,装一个 2015-2022 合集包就能覆盖。想用 vscode 配置 MFC 不是不行,但对话框资源、资源编译器和向导依赖 MSBuild,实际体验是每个文件都要手写编译命令,不建议在课程设计阶段折腾。

5.2 数据源替换:从 txt 到 SQLite 的切换点

如果你想让这份源码更像工程,第一步是不要把订单、菜品继续放在 txt。最省事的方案是引入 sqlite3,把 InfoFile.cpp 里的 LoadOrders/SaveOrders、LoadDish/SaveDish 四个函数替换成 sqlite3_open + sqlite3_exec。订单查询可以保留链表,只把文件读写换成数据库读写。这样历史订单按买家名搜索就不需要全链表遍历,直接SELECT * FROM orders WHERE buyer = ?一条搞定。替换时注意 CString 转 SQL 参数要统一转成 UTF-8。

5.3 MFC 控件自适应屏幕分辨率的简洁写法

MFC 对话框固定尺寸时放大窗口,控件会留在原地。给主对话框加 WM_SIZE 消息,在 OnSize 里按客户区比例重算控件位置:

void CMainDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (m_btnOrder.GetSafeHwnd() == nullptr) return; m_btnOrder.MoveWindow(cx - 140, cy - 80, 110, 32); }

MoveWindow 的四个参数分别是左上角 x、y、宽度和高度,直接用 cx、cy 保证控件跟着窗口右下角移动。把这段逻辑放到每个对话框的 OnSize 里,窗口最大化和还原就不会出现按钮飘在中间的情况。配合 WM_GETMINMAXINFO 限制最小尺寸,基本能应付答辩时的随意拖拽。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询