简介:面向计算机相关专业毕业设计、课程设计与期末大作业的完整实战项目,基于C++和Qt实现在线点餐系统,客户端与服务端代码齐全,曾作为大四毕业设计并取得98.5分的高分评价。压缩包共201个文件,约27.28MB,包含39个cpp源码、23个h头文件、9个ui界面文件、qrc资源与pro工程文件,以及png截图、exe可执行程序和编译中间产物等,结构清晰。目前已有107人学习使用,适合需要参考优秀毕设结构或快速搭建课设演示的学生。源码覆盖点餐主界面、网络通信、文件收发等模块,可借助qrc资源与ui文件快速理解界面布局,通过cpp与h文件掌握业务逻辑,也可直接运行exe辅助验证功能。
1. 从菜单点单到TCP双端通信:这个C++/Qt在线点餐系统值得拆一遍
带“在线”二字的点餐系统,很多毕业设计只是把数据库和界面做在一个程序里,换台机器就露馅。真正能拿高分的设计,会把客户端和服务端彻底拆开:客户端负责菜单展示、购物车、下单交互,服务端负责连接管理、请求路由、订单持久化。这样一个基于C++和Qt的在线点餐系统,正好把TCP网络编程、Qt信号槽、SQLite存储串成一条完整链路,也是答辩评委最容易追问“你怎么保证并发下单不丢单”的地方。评审分98.5分并不来自界面好看,而是通信层和存储层的设计在逻辑上站得住。对正在准备毕业设计、课程设计或期末大作业的计算机专业学生来说,这套东西比再刷一百道Qt控件题更有价值。
2. 客户端与服务端拆开之后:基于QTCPSocket和QTcpServer的通信架构
在线点餐系统里,客户端不能直接读数据库,否则每个客户端都要持有数据库账号,服务端的权限控制也会形同虚设。所以常见的做法是:客户端通过QTcpSocket向服务端发命令,服务端用QTcpServer监听、解析、返回结果。这个模型把“读菜单”和“下订单”都变成了一次网络请求,后续要扩展会员、桌台、打印小票,都只需要改服务端,不需要动客户端界面。把通信协议先定好,后面的界面代码只是怎么调用这套协议而已。
2.1 客户端与服务端各自的职责边界
先看两个模块要承担什么。客户端程序包含主窗口类mainscreen,负责菜单表格、购物车列表、金额汇总和下单按钮;另外要有一个负责收发网络报文的类,项目文件里出现的sendfile和recvfile就是这个角色。服务端则包含监听连接、解析请求、读写数据库的完整后端逻辑。
| 模块 | 核心职责 | 主要技术点 |
|---|---|---|
| 客户端 | 菜单展示、购物车、下单、订单状态展示 | QTabWidget、QStandardItemModel、QTcpSocket |
| 服务端 | 监听连接、解析命令、菜单管理、订单落库 | QTcpServer、QThread、QSqlDatabase、QSqlQuery |
这个表格划分清楚后,开发顺序就很明确了:先做协议,再写服务端收发包,最后写客户端界面。如果先堆界面,再回头补网络层,界面代码会到处夹着socket读写,后期非常难维护。我一般会把recvfile、sendfile单独抽成类,不放进mainscreen里,这样界面逻辑和网络逻辑互不污染。
2.2 自定义报文帧:先解决粘包和半包
QTcpSocket的readyRead信号只告诉你“缓冲区里有数据”,但不会告诉你数据是什么时候到齐的。经典的坑是:客户端连续发送两条菜单请求,服务端一次readAll读到两个请求拼在一起;或者一条大请求被拆成多个TCP小包到达,服务端读一半就尝试解析JSON,然后崩溃。解决办法是自己定义消息帧,在字节流上切出一个个完整报文。
2字节魔数 + 4字节载荷长度 + 载荷 "OD" + payload.len() + JSON文本魔数用于快速对齐和丢弃坏数据,长度字段用于判断完整帧是否到达,载荷用紧凑JSON格式承载业务数据。下面是我常用的一套构造帧代码。
// common/protocol.h #include <QByteArray> #include <QJsonDocument> #include <QJsonObject> #define FRAME_MAGIC "OD" #define FRAME_HEADER_SIZE 6 QByteArray buildFrame(const QJsonObject &payload) { QByteArray json = QJsonDocument(payload).toJson(QJsonDocument::Compact); QByteArray frame; frame.append(FRAME_MAGIC, 2); // 2字节魔数 // 用大端序写4字节长度,避免小端机解析错位 quint32 len = static_cast<quint32>(json.size()); frame.append(static_cast<char>((len >> 24) & 0xFF)); frame.append(static_cast<char>((len >> 16) & 0xFF)); frame.append(static_cast<char>((len >> 8) & 0xFF)); frame.append(static_cast<char>(len & 0xFF)); frame.append(json); // 最后拼上JSON载荷 return frame; }这里有一个容易忽略的细节:长度字段按大端序写入,接收方也按大端序拼回,这样客户端用Windows的MSVC编译、服务端用Linux交叉编译环境跑,也不会出现字节序不一致的问题。如果直接在内存里写int再memcpy,在不同平台上读出来的长度可能是乱的。
服务端接收时不能简单readAll然后解析,要把剩余数据保存在一个成员缓冲区里,每次readyRead都追加进去,再循环检查是否凑够一帧。
// server/recvfile.cpp void RecvFile::onReadyRead() { m_buffer.append(m_socket->readAll()); // 追加本次到达的数据 while (m_buffer.size() >= FRAME_HEADER_SIZE) { if (m_buffer.left(2) != FRAME_MAGIC) { // 帧头错乱,向后找下一个魔数,防止一条坏帧拖垮后续数据 int pos = m_buffer.indexOf(FRAME_MAGIC, 2); if (pos > 0 && pos + FRAME_HEADER_SIZE <= m_buffer.size()) { m_buffer.remove(0, pos); continue; } m_buffer.clear(); return; } // 从4字节长度字段中恢复载荷大小 quint32 len = 0; for (int i = 2; i < FRAME_HEADER_SIZE; ++i) { len = (len << 8) | static_cast<unsigned char>(m_buffer.at(i)); } if (m_buffer.size() < FRAME_HEADER_SIZE + static_cast<int>(len)) { return; // 半包未到齐,等待下一个readyRead } QByteArray body = m_buffer.mid(FRAME_HEADER_SIZE, len); m_buffer.remove(0, FRAME_HEADER_SIZE + len); parseRequest(QJsonDocument::fromJson(body).object()); } }这个循环的关键在于每次处理完一帧马上remove掉,缓冲区就不会无限膨胀。一次readAll可能包含几个完整帧和半包,交给while循环一个个切,半包留在m_buffer里继续拼。如果JSON解析失败,最好在parseRequest里加一个try或者QJsonParseError判断,不要把异常抛到事件循环里。
2.3 用JSON承载菜单与订单数据
协议帧只是运输工具,帧里的业务内容还要有约定。我在这套系统里用的是精简JSON,字段越少越好,便于答辩时解释。客户端请求服务端菜单时,发送这样一条命令:
{ "cmd": "get_menu", "table_no": 3 }服务端返回菜单列表,code为0表示成功:
{ "cmd": "menu_list", "code": 0, "items": [ { "id": 1, "name": "宫保鸡丁", "price": 28.0, "category": "热菜" }, { "id": 2, "name": "米饭", "price": 2.0, "category": "主食" } ] }| 字段 | 类型 | 说明 |
|---|---|---|
| cmd | string | 指令名,服务端按它路由到不同处理函数 |
| code | int | 0成功,非0为错误码 |
| table_no | int | 桌号,用于区分哪个桌位下单 |
| items | array | 菜品数组,客户端下单时回传id和数量 |
| total | double | 订单金额,服务端应该以菜品单价重新计算 |
菜品图片不建议用base64直接塞进JSON,会显著增大报文,网络差时UI会卡住。常见做法是把图片作为静态资源放在服务端可访问的目录下,JSON里只存相对路径,客户端再用QNetworkAccessManager去拉取。如果只是课程设计,也可以先把图片放进qrc资源里,客户端本地显示,服务端只管菜单名称和价格。
这里的参数约定不是拍脑袋定的。cmd设计成字符串,扩展新功能时不需要改协议帧格式,只加一个case分支,比如后续要支持“催菜”“取消订单”,新增两个cmd就行。把字段说明写清楚,然后客户端和服务端共用同一个头文件定义命令常量和帧格式,能避免两端拼错命令名的低级问题。
3. 客户端高频交互背后的Qt Widgets实操:菜单加载、购物车与下单
客户端的界面结构,很多人一上来就建一堆QPushButton然后手动摆放,结果窗口放大缩小就乱。更稳的做法是用QTabWidget做主框架,菜单页和购物车页各自独立,再用QHBoxLayout、QVBoxLayout组合固定。主窗口命名为mainscreen,这也是源码里出现moc_mainscreen.cpp的原因——mainscreen类里声明了Q_OBJECT宏,需要moc编译生成元对象支持。
3.1 用QTabWidget搭建主窗口骨架
主窗口顶部放一个tab栏:第一个Tab是菜单列表,第二个Tab是购物车,底部用一个QLabel显示当前服务端连接状态和总金额。这样一个界面只需要一次网络连接,不需要每个页面都建一个socket。在mainscreen构造函数里要显式处理socket断线重连的逻辑,否则服务端重启后客户端还停在已连接状态。
我一般会在构造函数末尾加这样一段:
// MainScreen构造函数尾部 m_socket = new QTcpSocket(this); connect(m_socket, &QTcpSocket::connected, this, [this]() { m_statusLabel->setText("已连接服务端"); }); connect(m_socket, &QTcpSocket::disconnected, this, [this]() { m_statusLabel->setText("连接已断开"); m_orderPending = false; // 清理下单锁定状态 });这里把m_orderPending一并重置,是为了避免断线后服务端没收到ack,客户端这边状态一直锁住,用户没法再次下单。这个标志位在下面下单流程里还会用到,先记住它。
3.2 菜单表格用QStandardItemModel延迟构建
菜单页如果直接在QTableWidget里逐行addItem,几百个菜品时界面会明显卡顿。更合适的做法是让QTableView配一个QStandardItemModel,服务端返回菜单JSON后一次性填充model,再交给view显示。这样数据与视图分离,后续做搜索、按分类过滤也只用操作model,不动一行表格代码。
// MainScreen::loadMenu void MainScreen::loadMenu(QTableView *view, const QJsonArray &items) { QStandardItemModel *model = new QStandardItemModel(items.size(), 3, this); model->setHeaderData(0, Qt::Horizontal, "菜品名称"); model->setHeaderData(1, Qt::Horizontal, "价格"); model->setHeaderData(2, Qt::Horizontal, "桌位/数量"); for (int i = 0; i < items.size(); ++i) { QJsonObject obj = items.at(i).toObject(); model->setItem(i, 0, new QStandardItem(obj["name"].toString())); model->setItem(i, 1, new QStandardItem( QString::number(obj["price"].toDouble(), 'f', 2))); model->setItem(i, 2, new QStandardItem(QString::number(obj["id"].toInt()))); } view->setModel(model); view->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); }填充完成后,第2列隐藏菜品真实id,第1列显示菜品名称,点击行时通过model的data方法读取id。这样不会把id直接暴露给用户,也方便后面购物车统计时用id做key。如果后续要给菜单页加搜索框,只需要把QSortFilterProxyModel套在这个model上,按名称列过滤。
3.3 购物车累计与下单状态锁
购物车功能用两个QHash就够了:一个存菜品id到单价,一个存菜品id到数量。新增菜品时先看数量哈希里有没有这个id,有则数量加1,没有则插入。刷新购物车列表时,先清空列表,再按哈希重建。
void MainScreen::addToCart(const QString &dishId, double price) { int count = m_cartCount.value(dishId, 0); if (count == 0) { m_cartPrice[dishId] = price; // 第一次加入才记录单价 } m_cartCount[dishId] = count + 1; m_cartList->clear(); double total = 0.0; for (auto it = m_cartCount.begin(); it != m_cartCount.end(); ++it) { double p = m_cartPrice.value(it.key(), 0.0); total += p * it.value(); m_cartList->addItem(QString("%1 x%2 ¥%3") .arg(it.key()).arg(it.value()).arg(p * it.value(), 0, 'f', 2)); } m_totalLabel->setText(QString("合计:¥%1").arg(total, 0, 'f', 2)); }这里把菜品id转成字符串作为key,而不是直接用int,是因为QHash<QString,...>在多个界面的信号槽传递时不容易出现隐式转换问题。刷新购物车用clear后再addItem,在菜品数量几十个时完全够用;如果将来要把购物车做成复杂表格,再换成QStandardItemModel按行更新。这个步骤里没有写死任何菜品名,所有数据都来自上一节loadMenu填充的菜单model,所以客户端改动菜单只需要服务端改数据库,客户端不用重新编译。
下单发送是客户端最容易被扣分的地方。点击下单按钮后,socket写入数据,但如果用户手快点了三次,服务端就会收到三份重复订单。解决方法是加一个m_orderPending标志位:发送时置true,收到ack后置false。
void MainScreen::sendOrder() { if (m_orderPending) { m_statusLabel->setText("订单发送中,请勿重复点击"); return; } if (m_socket == nullptr || m_socket->state() != QAbstractSocket::ConnectedState) { m_statusLabel->setText("未连接服务端,请先检查网络"); return; } QJsonArray items; for (auto it = m_cartCount.begin(); it != m_cartCount.end(); ++it) { QJsonObject item; item["dish_id"] = it.key().toInt(); item["count"] = it.value(); items.append(item); } QJsonObject obj; obj["cmd"] = "create_order"; obj["table_no"] = m_tableNo; obj["items"] = items; obj["total"] = m_totalLabel->text().remove("合计:¥").toDouble(); m_socket->write(buildFrame(obj)); m_orderPending = true; }在下单请求里,total这个字段只是给服务端做参考,服务端还应该根据menu表里的真实单价重新计算一遍,防止客户端篡改金额。这是个值得在答辩时讲出来的安全点。
客户端收到服务端返回的order_ack后,用QTimer单次触发清空购物车并恢复m_orderPending。如果用户下单后想查看订单状态,还可以在第二个Tab下放一个QProgressBar,把订单状态映射为进度值:待处理0、制作中50、已完成100,服务端主动推送status变化时更新进度条。这就是一个很直观的Qt自定义进度条应用,比用文字弹窗高级得多。
4. 服务端多线程连接与SQLite订单持久化:从newConnection到写库
服务端要同时服务多张桌子的客户端,如果所有连接都在主线程处理,一个客户端的数据量稍微大一点,其他客户端就会被阻塞。所以在QTcpServer的newConnection信号里逐个处理是不够的,必须把每个socket分发到独立线程,或者使用线程池。这个项目源码里的recvfile类被moc编译生成moc_recvfile.cpp,正说明它是一个QObject子类,可以被moveToThread到工作线程。
4.1 重写incomingConnection分发连接
QTcpServer有一个虚函数incomingConnection,会在新连接到达时把socketDescriptor传进来。默认实现是在服务器所在线程创建QTcpSocket,但我们需要在子线程里管理收包,所以重写它。
// server/orderserver.cpp void OrderServer::incomingConnection(qintptr socketDescriptor) { QThread *thread = new QThread(this); RecvFile *worker = new RecvFile(socketDescriptor); worker->moveToThread(thread); connect(thread, &QThread::started, worker, &RecvFile::initSocket); connect(worker, &RecvFile::finished, thread, &QThread::quit); connect(worker, &RecvFile::finished, worker, &RecvFile::deleteLater); connect(thread, &QThread::finished, thread, &QThread::deleteLater); thread->start(); }这里的关键是顺序:worker先moveToThread,再connect线程started信号到worker的initSocket槽,这样initSocket是在子线程中执行的。如果直接在OrderServer构造函数里new RecvFile再moveToThread,但initSocket在构造时执行,socket还是属于主线程对象,连接信号会跑到主线程,前面做的线程模型就全白费了。每个连接一个线程,在食堂这种最多几十个终端的场景完全够用,将来要支撑上千并发再从QThread换成线程池,接口不用变。
4.2 QSQLite连接名与订单表设计
服务端存菜单和订单,用数据库是必不可少的。SQLite对这种单机服务端最省事,不需要安装数据库服务,只需要在.pro里加上QT += sql。
CREATE TABLE IF NOT EXISTS menu ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, price REAL NOT NULL, category TEXT ); CREATE TABLE IF NOT EXISTS orders ( order_id INTEGER PRIMARY KEY AUTOINCREMENT, table_no INTEGER NOT NULL, items TEXT NOT NULL, total REAL NOT NULL, status INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now','localtime')) );orders表的items字段存的是一整段JSON数组文本,比如[{"dish_id":1,"count":2}]。这样就不需要再建一张订单明细表,课程设计层面够用,查订单时直接解析JSON文本即可。
在Qt里操作SQLite有一个非常常见的坑:QSqlDatabase::addDatabase默认连接名是同一个,如果多个线程各自调用addDatabase而不指定连接名,后一个线程会得到“duplicate connection name”错误。每个线程初始化数据库时,必须用独立连接名。
// server/dbworker.cpp bool DbWorker::initDb(const QString &dbPath) { QString connName = QString("conn_%1") .arg(reinterpret_cast<quintptr>(QThread::currentThread())); QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", connName); db.setDatabaseName(dbPath); if (!db.open()) { return false; } QSqlQuery query(db); return query.exec("CREATE TABLE IF NOT EXISTS orders (...);"); }连接名里带上当前线程地址,保证每个线程拿自己的连接对象。注意请勿在函数里用局部QSqlDatabase把连接释放掉后再让另一个线程用,连接必须保存在线程生命周期内,否则会出现“database is locked”或者“connection is not open”的随机错误。
4.3 请求路由与订单落库
服务端收到完整帧后,把载荷解析成QJsonObject,然后根据cmd字段分发到不同处理逻辑。这段代码看似简单,却是整个资源项目的核心拼图。
void RecvFile::parseRequest(const QJsonObject &req) { QString cmd = req.value("cmd").toString(); QJsonObject resp; resp["code"] = 0; if (cmd == "get_menu") { resp["cmd"] = "menu_list"; resp["items"] = DbWorker::loadMenu(); // 查询菜单 } else if (cmd == "create_order") { QJsonArray items = req.value("items").toArray(); double serverTotal = DbWorker::calcTotal(items); // 服务端重新计算金额 bool ok = DbWorker::saveOrder( req.value("table_no").toInt(), QString::fromUtf8(QJsonDocument(items).toJson()), serverTotal); resp["cmd"] = "order_ack"; resp["code"] = ok ? 0 : 1; if (ok) { resp["order_id"] = DbWorker::lastInsertId(); } } else { resp["code"] = 2; resp["msg"] = "unsupported cmd"; } m_socket->write(buildFrame(resp)); // 回包给客户端 }saveOrder内部应该用事务包裹插入和更新操作,避免写入一半时服务端崩溃造成脏数据:
bool DbWorker::saveOrder(int tableNo, const QString &itemsJson, double total) { QSqlDatabase db = QSqlDatabase::database(currentConnName()); QSqlQuery query(db); db.transaction(); query.prepare("INSERT INTO orders (table_no, items, total) " "VALUES (?, ?, ?)"); query.addBindValue(tableNo); query.addBindValue(itemsJson); query.addBindValue(total); bool ok = query.exec(); if (ok) { db.commit(); } else { db.rollback(); } return ok; }prepare配addBindValue的写法比直接拼SQL字符串安全,菜品名里带单引号、中文引号都不会破坏语句结构。服务端calcTotal必须在service层重新根据menu表单价计算,而不是信任客户端传过来的total,这一点在答辩时最好主动提出来,能明显拉开与其他学生的差距。
订单状态更新的场景,服务端还可以在status改变时主动向客户端推送一条JSON帧,比如{"cmd":"order_status","order_id":12,"status":1}。客户端收到后更新对应Tab里的进度条和文字。这样点餐系统就不仅仅是“下单后不管”,而是有了实时跟踪能力。
5. 从.pro.user到qrc_images.cpp:这套Qt项目构建与部署的三个关键细节
源码包里有几个很容易被人忽视的文件:QTProject.pro.user、moc_recvfile.cpp、qrc_images.cpp。它们不是手写代码,但理解它们的生成逻辑,能少踩一堆Qt开发的环境坑。
5.1 .pro.user是本机配置,不是项目配置
.pro.user文件由Qt Creator打开.pro时自动生成,里面记录了当前机器上选择的编译器、Qt版本路径、构建目录。换一台电脑打开同一个项目,如果不删除这个文件,很可能提示找不到qmake或MSVC套件。真正的项目配置在.pro文件里,.pro.user只是本机私有的“钥匙串”。提交源码前,把.pro.user加入.gitignore。
5.2 moc和qrc_images.cpp从哪里来
moc_recvfile.cpp、moc_mainscreen.cpp是元对象编译器moc根据头文件里的Q_OBJECT宏自动生成的。如果编译报错找不到moc文件,先检查对应头文件是否真的写了Q_OBJECT,以及该头文件是否已加入HEADERS变量。qrc_images.cpp则由qrc资源文件编译而来,qrc里引用图片时不要写绝对路径,用相对于qrc文件的相对路径,例如<file>images/logo.png</file>,代码里访问时写":/images/logo.png"。
5.3 发布运行时依赖
客户端发布时单纯拷贝exe是跑不起来的,Windows下最常见的报错是“could not find platform plugin windows”或者提示QT_QPA_PLATFORM_PLUGIN_PATH配置错误。需要在Qt命令行环境里执行:
windeployqt release/OrderClient.exe这个工具会把plugins、qwindows.dll、平台裁剪相关的dll自动拷到exe旁边。如果还是报platform plugin路径问题,把Qt安装目录下plugins/platforms整个目录复制到发布目录,再设置环境变量QT_QPA_PLATFORM_PLUGIN_PATH指向该目录。
上面几个细节都属于“平时不起眼、答辩时机器一换就翻车”的点。我的习惯是清掉build目录和.pro.user后重新构建一遍,能通过才算交付。qrc里的图片资源也建议按模块分目录,UI图、菜品占位图、窗口图标分开管理,这样客户端发布时资源裁剪和国际化替换都更容易定位。
本文还有配套的精品资源,点击获取