简介:基于C++的外卖管理系统是一份用于课程设计或期末项目的完整源码包,面向具备一定C++基础的学生与开发者,帮助理解面向对象编程、文件/控制台交互及餐饮订单业务流程。包内共44个文件,含8个cpp源文件、6个头文件、9个exe可运行程序及项目工程文件,压缩包约6.37MB,源码与编译产物齐全,可直接运行或二次修改。系统实现餐厅管理员登录、菜品增删改查与模糊/价格范围查询、顾客注册登录与下单/改单/取消/确认收货,并支持订单评价和出单管理,覆盖外卖场景主要功能。已有281人学习下载,适合作为课程设计参考、期末答辩演示或C++综合练习。资源附带README说明与项目布局文件,便于快速梳理结构、定位各模块代码。
1. 这份C++外卖管理系统源码,拆开能学到什么
第一次把这份基于C++的外卖管理系统源码包解开时,我先看了 Debug 目录里的 exe 能不能直接跑,再把 DeliverySys.cpp 拖进编辑器过了一遍。它的体量不大,却把外卖场景里最关键的几个环节都覆盖了:管理者维护菜品、顾客注册登录、下单后先待出单再确认收货,每个状态之间还有明确的权限边界。和纯增删改查的“学生管理系统”相比,它多了角色区分和订单生命周期管理,这对想搞懂 C++ 结构体、STL 容器、字符串处理和文件持久化怎么配合的人来说,是很好的切入点。适合把 C++ 语法学完但还没做过完整项目的读者,也适合准备 C++ 面试时想拿一个能讲清楚的需求的人。
2. 数据建模与存储:先定实体再写代码
任何一个管理类程序,第一步都是想清楚数据怎么组织。很多初学的人一上来就写菜单和输入输出,写到一半发现订单里要装多个菜品,又开始回头拆结构体。正确的顺序是先完成对象建模,再考虑用什么容器存、怎么持久化,最后才是界面和交互。
2.1 把现实对象翻译成结构体和类
这个项目里最小的数据单位是菜品,核心是订单。订单又包含一项或多项菜品,因此不能把菜品直接塞进订单字段里,而要拆出订单项。我按常规做法整理成下面的定义:
struct Dish { int id; // 菜品编号 std::string name; // 菜名 double price; // 单价 int stock; // 剩余数量 }; struct OrderItem { int dish_id; // 下单时的菜品编号 int quantity; // 购买数量 double price; // 下单时的单价快照 }; struct Order { int order_id; // 订单号 std::string customer; // 订餐人 std::string phone; // 联系电话 std::vector<OrderItem> items; // 菜品列表 double total; // 合计价格 std::string order_time; // 点餐时间 std::string confirm_time; // 确认收货时间 int status; // 0=待出单 1=已出单 2=已确认 };为什么会把 price 同时放在 Dish 和 OrderItem 里?因为菜品价格可能被管理者修改,订单里必须保存顾客下单那一刻的单价快照。如果不做快照,订单出单时重新读菜品价格,历史订单金额就会被改动,后面核对账目时会对不上。这是实际业务里容易忽略的细节。
结构体和类在这个场景里没有绝对界限。项目源码里用的是结构体,核心原因是这些对象主要用来承载数据,行为逻辑都写在操作函数里。如果你愿意,也可以把 Order 封装成类,把“修改订单”“取消订单”“确认收货”写成成员函数。不过成员函数一多,类的职责会慢慢膨胀,我一般偏向让结构体只存状态,行为交给独立的服务函数,这样单测也好写。
2.2 容器选型:vector、map 与链表怎么挑
订单里的菜品列表一定是用vector<OrderItem>,这没有悬念。但“全局存什么容器”值得琢磨。常见做法是:
std::vector<Dish> dish_list; // 菜品列表 std::vector<Order> order_list; // 订单列表 std::map<std::string, std::string> accounts; // 账号 -> 密码vector 连续内存、支持随机访问,遍历时对 CPU 缓存友好。菜品数量和订单数量在个人项目里不会超过几十万,线性遍历完全可以接受。map 用于账号查找,键是用户名,值是密码,登录时用find就是 O(log n),比每次遍历 vector 直观得多。
有些教材会强调链表适合频繁插入删除,但那是理论上的。实际在这个系统里,修改订单用的是订单号定位,删除用的是按索引或按迭代器移除,vector 在序列中间插入删除确实要搬移元素,但订单删除大多从尾部或任意位置且频率不高,性能差距可以忽略。在 C++ 面试里,“vector 和 list 怎么选”是高频题。你可以借这个项目这样回答:如果容器规模小、遍历为主、随机访问多,选 vector;如果元素很大、需要频繁在头部或中间插入删除,再考虑 list。这里的订单和菜品都符合前者。
2.3 文件持久化:按行拆分还是二进制快照
程序退出后数据要能留到下回启动,最稳的方案是每次数据变更后立刻写文件,启动时一次性读入。写入格式我用管道符|分隔字段,订单项之间用逗号,订单之间用横线---分隔:
void save_orders(const std::vector<Order>& order_list, const std::string& file) { std::ofstream ofs(file, std::ios::out | std::ios::trunc); if (!ofs) return; for (const auto& o : order_list) { ofs << o.order_id << "|" << o.customer << "|" << o.phone << "|" << o.total << "|" << o.status << "|" << o.order_time << "\n"; for (const auto& item : o.items) { ofs << item.dish_id << "," << item.quantity << "," << item.price << "\n"; } ofs << "---\n"; } }读取时先用getline按行读,遇到---说明一条订单结束;订单行有 6 个字段,后面跟随的每一行都是订单项:
std::vector<std::string> split(const std::string& s, char delim) { std::vector<std::string> parts; std::string cur; std::istringstream ss(s); while (std::getline(ss, cur, delim)) { parts.push_back(cur); } return parts; } std::vector<Order> load_orders(const std::string& file) { std::vector<Order> orders; std::ifstream ifs(file); if (!ifs) return orders; Order cur; std::string line; while (std::getline(ifs, line)) { if (line == "---") { orders.push_back(cur); cur = Order{}; } else { std::vector<std::string> parts = split(line, '|'); if (parts.size() == 6) { cur.order_id = std::stoi(parts[0]); cur.customer = parts[1]; cur.phone = parts[2]; cur.total = std::stod(parts[3]); cur.status = std::stoi(parts[4]); cur.order_time = parts[5]; } else { std::vector<std::string> item = split(line, ','); if (item.size() == 3) { OrderItem oi; oi.dish_id = std::stoi(item[0]); oi.quantity = std::stoi(item[1]); oi.price = std::stod(item[2]); cur.items.push_back(oi); } } } } return orders; }s是 std::stoi的缩写,表示 string to int;std::stod表示 string to double。注意这两个函数在遇到空字符串时会抛异常,所以我在拆分后判断了 parts.size(),确保格式异常的行不会让整个程序崩溃。文本格式可读性好,出错时能直接打开文件定位;缺点是字段里如果出现|或,,解析就会错位。处理办法是做转义,或者在程序里限制输入不得包含这些字符,课程设计阶段用后者就够了。
提示:文件路径尽量用相对路径并固定工作目录,否则 Debug 目录下启动的 exe 会找不到数据文件,程序却不报错,只是显示空数据。
3. 角色、菜单与订单状态机:核心流程怎么跑通
数据模型定好后,接下来是流程。外卖系统的难点不在单个函数,而在角色权限和订单状态之间的约束关系。这里我按“入口判断 → 数据操作 → 状态迁移”的顺序展开。
3.1 登录注册的校验逻辑
项目里有两个角色:餐厅管理员和顾客。管理员账号可以预置在文件里,顾客账号由注册产生。注册时最容易被忽略的是账号重复校验:
bool register_user(const std::string& name, const std::string& pwd) { if (accounts.find(name) != accounts.end()) { std::cout << "用户名已存在" << std::endl; return false; } if (name.empty() || pwd.size() < 6) { std::cout << "用户名不能为空,密码至少 6 位" << std::endl; return false; } accounts[name] = pwd; save_accounts("accounts.txt"); return true; } bool login(const std::string& name, const std::string& pwd) { auto it = accounts.find(name); if (it == accounts.end()) return false; return it->second == pwd; }accounts.find(name)返回的迭代器指向的是键值对,it->second取密码。这里有个细节:map 的operator[]在键不存在时会自动插入一个空值,所以在登录这种只读判断里应该用find,不要用[],否则会污染账号表。密码明文存文件在演示项目里问题不大,但如果你想往简历上写,至少要做一次简单的哈希,比如保存时把密码做一次移位或异或。管理员角色可以用一个单独的admin.txt判断,或者在账号文件里加一个角色字段,读取时区分。
3.2 菜品管理的联动约束
菜品信息的添加、修改、删除是管理员菜单的核心。添加菜品时要注意编号不能重复,删除菜品时要注意“是否还有未出单的订单引用这个菜品”。如果不做检查,会出现订单里的菜品编号根本查不到的情况:
bool delete_dish(int id, std::vector<Dish>& dish_list, const std::vector<Order>& order_list) { for (const auto& o : order_list) { if (o.status == 0) { // 待出单 for (const auto& item : o.items) { if (item.dish_id == id) { std::cout << "该菜品仍有待出单订单,不能删除" << std::endl; return false; } } } } dish_list.erase(std::remove_if(dish_list.begin(), dish_list.end(), [id](const Dish& d) { return d.id == id; }), dish_list.end()); return true; }erase和remove_if配合是 C++ 里移除满足条件元素的惯用法。remove_if只负责把符合条件的元素移到容器末尾并把逻辑大小前移,真正释放元素要靠erase。很多初学者只写一个remove_if后遍历,发现元素还在,就是没搞懂这两个函数的分工。
修改菜品时,除了改名称和价格,还要考虑库存变化。顾客下单时检查过库存,出单时还要再检查一次,因为从下单到出单之间可能有别的订单先把库存买光。所以管理员在修改菜品数量时,如果新库存小于当前待出单订单中该菜品的总需求量,应该给出警告,而不是直接覆盖。
3.3 订单状态流转的条件边界
订单状态是这个项目里最值得讲的部分。我把它看成一个小型状态机:
| 状态 | 含义 | 谁可操作 | 允许的迁移 |
|---|---|---|---|
| 0 | 待出单 | 顾客、管理员 | 顾客可修改/取消,管理员可出单 |
| 1 | 已出单 | 顾客、管理员 | 顾客可确认收货 |
| 2 | 已确认 | 管理员 | 查看,不可再变更 |
对应到代码里,每个操作前先做状态判断:
void customer_action(Order& o, int action) { if (o.status == 0) { if (action == 1) modify_order(o); // 修改订单 if (action == 2) cancel_order(o); // 取消订单 } else if (o.status == 1 && action == 3) { confirm_receipt(o); // 确认收货 } else { std::cout << "当前状态不允许该操作" << std::endl; } }这个 if-else 看起来很直白,但它保证了“没出单之前顾客可以改和退,出单后只能确认”。真正会写歪的地方在管理员侧:管理员查看“未出单订单”时,如果订单很多,要按下单时间排序,订单里的order_time是字符串,如果格式统一为YYYY-MM-DD HH:MM:SS,可以直接按字典序比较;如果格式是MM/DD/YYYY,字典序就会出错。出单操作本身要做的额外事情是扣减菜品库存,扣减后要重新计算订单总价,保证和历史快照一致。
修改订单和取消订单的界限也要写清楚:顾客修改自己的订单时,只能修改“菜品列表”和“份数”,不能直接篡改单价。正确的做法是修改后按当前菜品单价重新生成 OrderItem 快照,再累加 total:
void modify_order(Order& o, const std::vector<Dish>& dish_list) { o.items.clear(); double total = 0.0; int dish_id; int qty; // 逐项输入,重新校验库存 std::cout << "输入菜品编号和数量,输入 0 结束" << std::endl; while (std::cin >> dish_id) { if (dish_id == 0) break; std::cin >> qty; for (const auto& d : dish_list) { if (d.id == dish_id && d.stock >= qty) { OrderItem item; item.dish_id = d.id; item.quantity = qty; item.price = d.price; o.items.push_back(item); total += d.price * qty; break; } } } o.total = total; o.order_time = current_time_str(); // 每次修改刷新时间 }这里的current_time_str()是常用封装,内部用std::chrono::system_clock::now()取当前时间转成格式化字符串。注意修改订单会刷新下单时间,这在业务上是合理的,因为“按下单时间顺序查看未出单订单”时,修改过的订单会被排到新位置,避免一直在队首堵住出单流程。
4. 查询与排序进阶:模糊匹配、价格区间与稳定排序
管理员的菜品列表、顾客的点餐界面,都需要支持按名称模糊查询、按价格范围查询、按价格升降序显示。这几点统计学得会,但写起来经常出边界问题。
4.1 按菜品名称模糊查询的正确姿势
模糊查询的核心是把“用户输入的关键字”和“菜品名”做包含匹配。C++ 里最直接的是std::string::find:
std::vector<Dish> fuzzy_search(const std::vector<Dish>& dish_list, const std::string& keyword) { std::vector<Dish> result; for (const auto& d : dish_list) { if (d.name.find(keyword) != std::string::npos) { result.push_back(d); } } return result; }find返回子串第一次出现的位置,查不到时返回npos。这里有两个坑。第一,npos是一个极大的无符号数,如果用>= 0或== -1判断,在部分编译器上会出警告或不相等。标准写法就是!= std::string::npos。第二,find区分大小写。用户搜“黄焖鸡”没问题,但搜“huangmenji”就查不到。如果要支持不区分大小写,常见做法是先把菜名和关键字都转成小写再查找:
std::string to_lower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); }); return s; }注意tolower的参数要显式转成unsigned char,否则遇到扩展 ASCII 字符时是未定义行为。这是一个很细的 C++ 考点,很多人栽在中文菜名上,因为中文字符的每个字节都大于 127,直接用char会变成负数,tolower的行为就不可控了。
4.2 价格区间查询与比较器设计
价格范围查询可以写成带上下限的函数。如果未指定下限,就设成 0;未指定上限,就设成一个很大的数。也可以定义一个PriceRange结构体,让调用更清晰:
struct PriceRange { double min_price; double max_price; }; std::vector<Dish> filter_by_price(const std::vector<Dish>& dish_list, const PriceRange& range) { std::vector<Dish> result; for (const auto& d : dish_list) { if (d.price >= range.min_price && d.price <= range.max_price) { result.push_back(d); } } return result; }实际使用时,组合查询可以先模糊过滤再按价格范围过滤:
std::vector<Dish> result = filter_by_price( fuzzy_search(dish_list, keyword), PriceRange{10.0, 30.0});这样写的好处是每一步都能独立测试,后续想加“按分类过滤”只需要再包一层。
排序部分,升序降序可以用同一个比较器加一个方向标志位,也可以用两个 lambda。我习惯用下面这种“严格弱序”写法:
std::sort(dish_list.begin(), dish_list.end(), [](const Dish& a, const Dish& b) { if (a.price != b.price) return a.price < b.price; return a.id < b.id; // 价格相同时按编号排序 });这个比较器的逻辑是:先比价格,价格相同再比编号。如果你写的是return a.price <= b.price,std::sort在严格弱序不成立时可能崩溃或产生未定义行为。因为当两个元素相等时,a <= b和b <= a都为真,这破坏了等价类的排他性。降序时把<换成>,并且保证相等时返回false即可。
升序降序交替使用、且不需要保持元素相对顺序时,用std::sort没问题;如果要求价格相同的菜按原始顺序保留,就要改用std::stable_sort。这个细节在 C++ 面试里常被问到,答出“稳定排序保留等价元素相对顺序”会加分。
4.3 让主菜单循环可扩展
管理菜单和顾客菜单通常长这样:显示选项、读入选择、调用对应函数、回到循环。很多人会写一大串 if-else 嵌套。更好的做法是把每个操作封装成函数,用函数指针表分发:
using CommandFn = void(*)(); CommandFn admin_menu[] = { add_dish, modify_dish, delete_dish, list_dishes, view_orders, ship_order }; int choice; std::cin >> choice; if (choice >= 1 && choice <= 6) { admin_menu[choice - 1](); }函数指针表把“菜单项”和“函数地址”一一对应,新增一个功能时,往数组里加一个函数名就行,不用动 switch 结构。需要注意数组下标越界检查,这比单独写六个 case 更考验功底。
菜单循环里另一个常见问题是输入失败。用户输入非数字时,cin 会进入错误状态,后面所有>>都会直接失效,程序表现为“按什么都没反应”:
std::cin >> choice; if (std::cin.fail()) { std::cin.clear(); // 恢复流状态 std::cin.ignore(1024, '\n'); // 清空缓冲区残留字符 std::cout << "输入无效,请重新输入" << std::endl; continue; }clear恢复流状态,ignore清掉缓冲区里的残留字符。如果你用的是std::getline读整行再解析,就不会遇到这个问题,但要自己写字符串转数字的解析,两种风格各有利弊。
5. 评价、扩展与验证:把这个系统打磨到能演示
前面的核心功能跑通后,评价模块和细节增强是拉开差距的地方。
5.1 评价数据和订单解耦
题目要求“顾客对订单中的菜品信息进行评价”,如果你的设计里只允许对已确认收货的订单评价,代码会简单很多,但要注意数据归属:
struct Review { int order_id; int dish_id; int rating; // 1-5 星 std::string comment; }; std::vector<Review> review_list;评价表用order_id + dish_id做自然键,避免一个订单对同一个菜品重复评价。管理员查看评价时,按dish_id聚合,就能统计每个菜品的平均分:
double average_rating(int dish_id, const std::vector<Review>& reviews) { int sum = 0; int count = 0; for (const auto& r : reviews) { if (r.dish_id == dish_id) { sum += r.rating; ++count; } } return count ? static_cast<double>(sum) / count : 0.0; }顾客评价前先判断订单状态必须为 2(已确认),否则提示“还没确认收货,不能评价”。这里的static_cast<double>是显式类型转换,避免整数除法把sum / count截断成 0,这在统计平均分时是很容易踩的坑。
5.2 加一个按时间倒序的订单列表
管理员的“查看订单”如果只按插入顺序显示,订单一多就不好用。订单时间order_time如果固定为YYYY-MM-DD HH:MM:SS格式,倒序排序可以直接用字符串比较:
std::sort(order_list.begin(), order_list.end(), [](const Order& a, const Order& b) { return a.order_time > b.order_time; });注意order_time必须统一格式。点餐时间在代码里如果存的是time(nullptr)得到的秒数,那就要按整数比较;如果显示时格式化为可读字符串,存文件时就不要混存数字。我的建议是统一用字符串,因为文件内容直接可读,排错时不用再换算时间戳。
5.3 验证与排错技巧
| 验证场景 | 预期结果 | 常见错误 |
|---|---|---|
| 注册重复账号 | 提示用户名已存在 | 没查重直接覆盖 |
| 顾客删除待出单订单 | 订单消失 | 没判断状态把已出单订单也删了 |
| 删除仍有订单的菜品 | 禁止删除 | 删了之后历史订单菜品查不到 |
| 输入非数字菜单选项 | 重新提示 | cin进入错误状态导致死循环 |
编译报错:stod找不到 | 检查是否包含<string> | 没有包含必要头文件 |
我在验证这类项目时,会先写一组最小用例:注册两个账号,管理员添加三个菜品,其中两个价格相同来测稳定排序;顾客下一单,修改一次,再下一单,取消一单;管理员出单,另一个顾客确认收货,最后看保存的文件内容和重新启动后的恢复情况。如果Project.exe直接在 Debug 目录里跑,注意工作目录和accounts.txt的相对路径,路径不对会导致启动时读不到文件,程序却不会报错,只是显示空数据。
用 Dev-C++ 打开Project.dev后,如果中文乱码,把编辑器编码切成 GBK 或 UTF-8 再重新编译;如果在 VSCode 里配置 C/C++ 环境自己拉工程,记得在tasks.json里加上-std=c++11或更高标准,否则范围 for 和 Lambda 会直接编译失败。验证完这些路径,这份源码才算真正被你跑通,而不只是解压后看一眼 exe。
本文还有配套的精品资源,点击获取