☰
Qt自助点餐系统开发实战:从触摸界面到断网补单与打印对接
2026/10/14 19:05:16 网站建设 项目流程

简介:QT自助点餐系统是一份面向C++与Qt初学者、课程设计及桌面应用开发练习者的完整项目源码,围绕餐饮场景实现菜单浏览、选菜下单与支付等交互流程,帮助读者理解跨平台GUI程序的整体搭建思路。压缩包共23个文件,约23KB,包含6个cpp源文件、6个h头文件、4个ui界面文件、2个pro工程文件及qrc资源文件等,分别承担业务逻辑、类声明、界面布局与工程配置,结构紧凑便于通读。项目涉及QWidgets界面设计、MVC分层、QSql数据库连接、网络通信、事件处理、多线程、支付集成、国际化与日志记录等知识点,并附带Client与Server两端模块,可对照学习客户端与服务端协作方式。目前已有3836人学习下载,适合作为Qt综合实践与软件工程能力提升的参考案例。

1. 从一块触摸屏说起:QT 自助点餐系统到底在解决什么问题

很多人第一次接触 QT 自助点餐系统,是在商场或食堂里那台竖着放的触摸屏前——点两下选好菜,扫码付钱,出票取餐。看起来简单,但如果你是被安排去做这套系统的人,真正要回答的问题不是“界面怎么画”,而是:一台没有键盘鼠标、可能还断网的机器,怎么保证顾客从选菜到出票的整条链路不崩、不卡、不丢单。

QT 自助点餐系统的本质,是一个跑在嵌入式或桌面终端上的本地应用,用 Qt 做界面和交互,用本地数据库存菜单和订单,再通过串口、USB 或网络把订单送到后厨打印机或收银台。它要解决的核心诉求有三个:第一,顾客操作路径要短,点餐到支付最好控制在几步内;第二,断网时不能白屏,得能继续点、继续存,等网络恢复再补传;第三,界面要能适配不同尺寸的触摸屏,从 10 寸到 21 寸都得能用。

适合读这篇的人,是已经会一点 C++ 或 Python、想用 Qt 做一个能真正跑起来的点餐终端,而不是只画个界面交作业的开发者。下面我会按“先跑通最小闭环,再补支付和打印,最后处理断网和触摸适配”的顺序,把参数、代码和踩坑点都摊开讲。

2. 用 Qt 搭出点餐主界面:从菜单列表到购物车的最小闭环

2.1 为什么选 Qt Widgets 而不是 QML

做自助点餐终端,第一个选型问题就是 Qt Widgets 还是 QML。我一般会先看两个条件:屏幕分辨率是否固定、动画需求是否多。如果终端是固定 1080×1920 竖屏,菜单项以列表和网格为主,动画只是按钮按下反馈,那 Qt Widgets 更稳——控件成熟,触摸事件处理直接,调试时还能用 Qt Designer 拖布局。QML 的优势在流畅动画和自定义皮肤,但点餐系统里真正需要动画的地方很少,反而 QML 和 C++ 的数据交互会多一层胶水代码,新手容易在信号槽上翻车。

另一个现实原因是部署。Qt Widgets 编译出来的可执行文件依赖少,在 ARM 板子上跑起来更省心;QML 需要额外的 Qt Quick 运行时,如果板子内存只有 512MB,启动速度会明显变慢。所以我的默认建议是:菜单展示、购物车、订单确认这三块用 Widgets 做,如果后面要加广告轮播,再单独嵌一个 QML 组件。

2.2 菜单数据模型:用 QSqlTableModel 还是自定义 QAbstractListModel

菜单数据从哪来?常见做法是本地 SQLite 存一份,启动时读进内存。这里有两个选择:直接用 QSqlTableModel 绑定 QTableView,或者自己写一个 QAbstractListModel 再配 QListView。前者省代码,但菜单项需要显示图片、价格、辣度标签时,QSqlTableModel 的 delegate 会写得很别扭;后者灵活,但要多写几十行 rowCount、data、roleNames。

我一般会选自定义 model,因为点餐菜单的字段不止名称和价格,还有图片路径、是否推荐、是否售罄、分类 ID。下面是一个最小可用的菜单 model 定义:

// menumodel.h class MenuModel : public QAbstractListModel { Q_OBJECT public: enum MenuRoles { NameRole = Qt::UserRole + 1, PriceRole, ImageRole, SoldOutRole }; explicit MenuModel(QObject *parent = nullptr); int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role) const override; QHash<int, QByteArray> roleNames() const override; void loadFromDatabase(); // 从 SQLite 读取菜单 private: struct MenuItem { QString name; double price; QString imagePath; bool soldOut; }; QVector<MenuItem> m_items; };

逻辑说明:rowCount 返回菜单项数量,data 根据 role 返回对应字段,roleNames 把角色名暴露给 QML 或 delegate。loadFromDatabase 里用 QSqlQuery 执行SELECT name, price, image, sold_out FROM menu WHERE category_id = ?,注意图片路径要存相对路径,部署时把图片目录一起拷到终端上。

参数说明:SoldOutRole 用来控制“售罄”遮罩,如果为 true,delegate 里画一层半透明灰色并禁用点击。价格用 double 存,但显示时用 QString::number(price, 'f', 2),避免 9.9 显示成 9.899999。

2.3 购物车逻辑:数量增减、总价计算和清空

购物车是点餐系统的核心状态。我见过不少新手把购物车直接塞进 QTableWidget,每次增减都重新算一遍总价,结果在触摸屏上快速连点时出现数量错乱。更稳的做法是用一个 QMap<int, int> 存“菜单 ID → 数量”,再配一个信号通知界面刷新。

// cartmanager.h class CartManager : public QObject { Q_OBJECT public: void addItem(int menuId, double price); void removeItem(int menuId); void clear(); double totalPrice() const; QList<QPair<int, int>> items() const; // menuId, count signals: void cartChanged(); private: QMap<int, int> m_counts; QMap<int, double> m_prices; // 缓存单价,避免重复查库 };

逻辑说明:addItem 时如果 m_counts 里已有该 ID,数量加一;否则插入并记录单价。totalPrice 遍历 m_counts,用 count 乘以 m_prices 里的单价累加。cartChanged 信号连接到界面的刷新槽,每次增减都触发重绘。

参数说明:单价缓存 m_prices 很关键,因为菜单价格可能在后台被改,但顾客已经加进购物车的项应该按加购时的价格算。如果要求严格,可以在下单时再查一次库做校验,但界面显示用缓存价。

2.4 触摸屏适配:按钮最小尺寸和滚动区域

触摸屏和鼠标最大的区别是手指点击精度。Qt 默认按钮在 10 寸屏上可能只有 30px 高,手指根本点不准。我一般会把所有可点击控件的最小高度设成 48px,宽度至少 80px,间距 12px 以上。如果菜单项多,用 QScrollArea 包住列表,并开启setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff),靠手指滑动滚动。

// 在 delegate 的 sizeHint 里设置 QSize MenuDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { return QSize(option.rect.width(), 96); // 每项 96px 高 }

逻辑说明:sizeHint 决定每个菜单项占用的高度,96px 在 1080p 竖屏上大约能放 10 项,手指滑动两下就能看完。如果屏幕更小,可以降到 72px,但不要再低。

参数说明:滚动区域的setWidgetResizable(true)必须开,否则列表不会随窗口缩放。另外在触摸屏上要禁用QScroller的默认惯性,改成QScroller::grabGesture(scrollArea->viewport(), QScroller::LeftMouseButtonGesture),这样手指拖拽才跟手。

3. 订单提交与支付对接:本地队列、二维码和状态机

3.1 订单本地持久化:SQLite 表结构和写入时机

订单不能只存在内存里,否则程序一崩全丢。我一般会在 SQLite 里建两张表:orders 存订单头,order_items 存明细。写入时机是顾客点击“提交订单”后立刻插入,状态先标为“待支付”,支付成功再更新为“已支付”。

CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT UNIQUE NOT NULL, total_price REAL NOT NULL, status INTEGER DEFAULT 0, -- 0待支付 1已支付 2已打印 3已取消 created_at TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE order_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL, menu_id INTEGER NOT NULL, menu_name TEXT NOT NULL, price REAL NOT NULL, count INTEGER NOT NULL, FOREIGN KEY(order_id) REFERENCES orders(id) );

逻辑说明:order_no 用时间戳加随机数生成,比如QString::number(QDateTime::currentMSecsSinceEpoch()) + QString::number(QRandomGenerator::global()->bounded(1000)),保证唯一。status 字段是状态机的核心,后面支付回调和打印回调都靠它判断。

参数说明:created_at 用 SQLite 的 datetime 函数自动填,注意时区用 localtime,否则终端时区不对会导致订单时间差 8 小时。外键约束在 SQLite 里默认关闭,需要执行PRAGMA foreign_keys = ON;。

3.2 支付二维码的生成与轮询

自助点餐的支付方式常见两种:扫顾客的付款码,或者屏幕上生成二维码让顾客扫。前者需要接扫码枪,后者需要自己生成二维码并轮询支付结果。这里讲后者,因为更通用。

生成二维码可以用 qrencode 库,Qt 里封装成 QImage 显示。轮询用 QTimer,每 2 秒查一次支付网关的订单状态。

// 生成二维码 QImage createQRCode(const QString &orderNo, double price) { // 支付链接格式由网关决定,这里用占位 QString payUrl = QString("https://pay.example.com/qr?order=%1&amount=%2") .arg(orderNo).arg(price); QRcode *qr = QRcode_encodeString(payUrl.toUtf8().constData(), 0, QR_ECLEVEL_L, QR_MODE_8, 1); QImage image(qr->width, qr->width, QImage::Format_Mono); for (int y = 0; y < qr->width; y++) { for (int x = 0; x < qr->width; x++) { image.setPixel(x, y, (qr->data[y * qr->width + x] & 1) ? 0 : 1); } } QRcode_free(qr); return image.scaled(300, 300, Qt::KeepAspectRatio, Qt::FastTransformation); }

逻辑说明:QRcode_encodeString 把支付链接编码成二维码矩阵,然后逐像素转成 QImage。注意 QR_MODE_8 支持中文参数,如果链接里只有英文和数字,用 QR_MODE_8 也没问题。

参数说明:QR_ECLEVEL_L 是最低纠错等级,二维码图案最简单,扫描成功率在屏幕上反而更高;如果屏幕反光严重,可以升到 QR_ECLEVEL_M。scaled 的 300×300 是显示尺寸,实际生成后不要放大超过 400,否则边缘模糊。

轮询部分:

QTimer *pollTimer = new QTimer(this); connect(pollTimer, &QTimer::timeout, this, [=]() { QString status = queryPayStatus(orderNo); // 发 HTTP 请求查状态 if (status == "paid") { pollTimer->stop(); updateOrderStatus(orderNo, 1); // 更新为已支付 emit paymentSuccess(orderNo); } else if (status == "expired") { pollTimer->stop(); showPayExpired(); } }); pollTimer->start(2000);

逻辑说明:每 2 秒查一次,查到 paid 就停定时器并更新本地订单状态。如果 5 分钟没支付,网关会返回 expired,界面提示重新下单。

参数说明:轮询间隔不要低于 1 秒,否则请求太频繁可能被网关限流;也不要高于 5 秒,顾客付完等太久会以为机器坏了。超时时间设 300 秒比较合理。

3.3 支付回调与订单状态机

订单状态从 0 到 3 的流转必须严格,不能从“待支付”直接跳到“已打印”。我一般会写一个状态检查函数,每次更新前先判断当前状态是否允许目标状态。

bool canTransition(int from, int to) { static const QSet<QPair<int,int>> allowed = { {0, 1}, {0, 3}, {1, 2}, {1, 3} }; return allowed.contains({from, to}); }

逻辑说明:0→1 是支付成功,0→3 是顾客取消,1→2 是打印完成,1→3 是支付后取消(退款场景)。其他跳转一律拒绝。

参数说明:这个集合可以根据业务扩展,比如加一个 2→3 表示打印失败取消。但不要允许 0→2,否则会出现没付钱就出票的漏洞。

4. 后厨打印与硬件对接:串口、USB 和网络打印的取舍

4.1 热敏打印机选型:串口还是 USB

后厨打印机常见三种接口:串口(RS232)、USB、网口。串口最稳,线长可以到 10 米,但需要终端有串口或 USB 转串口;USB 即插即用,但 Linux 下权限和驱动容易出问题;网口适合后厨离终端远的场景,但需要配 IP。

我一般优先选串口,因为 Qt 的 QSerialPort 跨平台支持好,代码简单。如果终端只有 USB,就用 USB 转串口芯片(CH340 或 CP2102),在 Linux 下确认/dev/ttyUSB0存在。

QSerialPort printer; printer.setPortName("/dev/ttyUSB0"); printer.setBaudRate(9600); printer.setDataBits(QSerialPort::Data8); printer.setParity(QSerialPort::NoParity); printer.setStopBits(QSerialPort::OneStop); if (!printer.open(QIODevice::WriteOnly)) { qWarning() << "打印机打开失败:" << printer.errorString(); return; }

逻辑说明:打开串口后,用 write 发送 ESC/POS 指令。打印内容前先发\x1B\x40初始化打印机,再发文本,最后发\x1B\x64\x03走纸三行并切纸。

参数说明:波特率 9600 是热敏打印机默认值,如果打印乱码,先检查波特率是不是 19200 或 38400。数据位 8、无校验、1 停止位是标准配置,不要改。

4.2 ESC/POS 指令封装:中文打印和排版

ESC/POS 指令是打印机的事实标准,但中文需要打印机支持 GBK 编码。Qt 的 QString 是 Unicode,写入前要转成 GBK。

QByteArray textToGbk(const QString &text) { QTextCodec *codec = QTextCodec::codecForName("GBK"); return codec->fromUnicode(text); } void printOrder(const Order &order) { QByteArray cmd; cmd.append("\x1B\x40"); // 初始化 cmd.append("\x1B\x61\x01"); // 居中 cmd.append(textToGbk("订单号:" + order.orderNo + "\n")); cmd.append("\x1B\x61\x00"); // 左对齐 for (const auto &item : order.items) { QString line = QString("%1 x%2 %3\n") .arg(item.name).arg(item.count).arg(item.price * item.count, 0, 'f', 2); cmd.append(textToGbk(line)); } cmd.append(textToGbk("合计:" + QString::number(order.total, 'f', 2) + "\n")); cmd.append("\x1B\x64\x03"); // 走纸 cmd.append("\x1D\x56\x00"); // 切纸 printer.write(cmd); }

逻辑说明:\x1B\x61\x01是居中,\x1B\x61\x00是左对齐。每行末尾加\n换行。切纸指令\x1D\x56\x00是全切,如果打印机不支持切纸,这行会被忽略。

参数说明:QTextCodec 在 Qt6 里移到了 core5compat 模块,如果用的是 Qt6,需要额外引入。或者直接用 QByteArray::fromLocal8Bit,但依赖系统 locale,不如显式指定 GBK 稳。

4.3 打印失败重试与队列

打印机可能缺纸、离线、卡纸,不能因为一次失败就丢单。我一般会做一个打印队列,每张订单打印失败后进队列,每 10 秒重试一次,最多重试 5 次。

void PrintQueue::enqueue(const Order &order) { m_queue.enqueue(order); if (!m_timer->isActive()) m_timer->start(10000); } void PrintQueue::onTimeout() { if (m_queue.isEmpty()) { m_timer->stop(); return; } Order order = m_queue.head(); if (printOrder(order)) { m_queue.dequeue(); updateOrderStatus(order.orderNo, 2); // 已打印 } else { m_retryCount++; if (m_retryCount >= 5) { m_queue.dequeue(); showPrintError(order.orderNo); // 提示人工处理 } } }

逻辑说明:队列用 QQueue 存订单,定时器每 10 秒触发一次。打印成功就出队并更新状态,失败累计重试次数,超过 5 次出队并弹窗提示。

参数说明:重试间隔 10 秒是经验值,太短会频繁占用串口,太长后厨等得急。重试次数 5 次大约覆盖 50 秒,足够处理换纸这种小故障。

5. 避坑与排查:触摸失灵、断网丢单和打印乱码

5.1 触摸屏点击没反应,但鼠标正常

现象:在终端上用手指点按钮没反应,插鼠标点同一个按钮却正常。

原因:Qt 默认把第一次触摸当成鼠标事件,但如果系统里同时存在触摸设备和鼠标设备,Qt 可能把触摸事件过滤掉了。另外,如果触摸屏驱动上报的是绝对坐标,而 Qt 没开启对应属性,也会出现点击偏移。

解决:在 main 函数里设置QCoreApplication::setAttribute(Qt::AA_SynthesizeMouseForUnhandledTouchEvents, true),确保触摸事件能转成鼠标事件。如果是坐标偏移,用QT_QPA_EGLFS_NO_LIBINPUT=1或检查 tslib 校准。更稳的做法是在终端启动脚本里加export QT_LOGGING_RULES="qt.qpa.input=true",看触摸事件有没有上报。

5.2 断网后订单提交失败,顾客已付款

现象:顾客扫码付了钱,但终端因为断网没收到回调,订单状态还是“待支付”,后厨没出票。

原因:支付回调依赖网络,断网时轮询请求超时,但顾客手机端已经扣款。

解决:本地订单在提交时就写入 SQLite,状态为“待支付”。网络恢复后,启动一个补单线程,把所有“待支付”且创建时间在 10 分钟内的订单重新查一次支付网关。如果网关返回已支付,就更新状态并补打印。同时,界面上要提示顾客“支付结果确认中,请稍候”,不要直接说失败。

5.3 打印中文变成乱码或问号

现象:小票上中文显示成“???”或方块。

原因:打印机不支持 Unicode,需要 GBK 编码,但代码里直接 write 了 QString 的 UTF-8 字节。

解决:所有中文文本在写入串口前用 QTextCodec 转 GBK。如果打印机只支持 ASCII,那就只能打拼音或英文,这种情况在选型时就要确认。另外,有些打印机需要发\x1B\x74\x00选择字符集,具体看打印机手册。

5.4 菜单图片加载慢,界面卡顿

现象:滑动菜单列表时图片一张张蹦出来,滑动不跟手。

原因:图片是原图直接加载,没有缩略图,每次 delegate 绘制都读磁盘。

解决:启动时把菜单图片统一缩放到 200×200 并缓存到内存,delegate 里直接用 QPixmap 缓存。如果图片多,用 QPixmapCache,设置缓存上限 20MB。另外,图片路径不要用网络地址,本地文件读取最快。

5.5 程序运行几天后内存涨到 1GB

现象:终端连续运行 72 小时后,内存占用从 80MB 涨到 1GB,最后被系统杀掉。

原因:订单列表、日志、图片缓存没有释放。常见的是每次刷新订单都 new 一个 model 但不 delete,或者 QNetworkReply 没调 deleteLater。

解决:用 valgrind 或 Qt 的QObject::dumpObjectTree()排查泄漏。订单列表用分页加载,不要一次查全部。日志文件按天切割,超过 7 天自动删除。QNetworkReply 在 finished 信号里必须 deleteLater。

6. 进阶技巧:用 QSS 做触摸屏主题和远程配置下发

6.1 QSS 主题:让按钮在强光下也能看清

自助点餐终端经常放在窗边或灯光直射的地方,默认灰色按钮在强光下对比度不够。用 QSS 可以统一调整颜色和圆角,而且不用改控件代码。

QPushButton { background-color: #E63946; color: #FFFFFF; border-radius: 12px; font-size: 24px; min-height: 64px; min-width: 120px; } QPushButton:pressed { background-color: #B32B36; } QPushButton:disabled { background-color: #CCCCCC; color: #888888; }

逻辑说明:主按钮用高饱和红色,按下时变深,禁用时变灰。圆角 12px 在触摸屏上视觉更柔和,但不要超过 20px,否则边缘点击区域会变小。

参数说明:min-height 64px 是手指点击的舒适高度,如果屏幕小可以降到 56px。font-size 24px 在 1080p 竖屏上大约占 3mm 高,足够看清。QSS 文件可以放在资源文件里,也可以运行时从外部加载,方便现场调整。

6.2 远程配置下发:不改程序换菜单和价格

终端部署到现场后,菜单和价格经常变。如果每次都要重新编译,运维成本太高。我一般会做一个远程配置接口:终端启动时从服务器拉一个 JSON,里面包含菜单分类、菜品、价格、图片 URL。本地 SQLite 只做缓存,服务器是权威数据源。

void ConfigLoader::fetchConfig() { QNetworkAccessManager *mgr = new QNetworkAccessManager(this); QNetworkRequest req(QUrl("https://config.example.com/menu.json")); QNetworkReply *reply = mgr->get(req); connect(reply, &QNetworkReply::finished, this, [=]() { if (reply->error() == QNetworkReply::NoError) { QByteArray data = reply->readAll(); parseAndSaveToSqlite(data); // 解析 JSON 并更新本地库 emit configUpdated(); } reply->deleteLater(); }); }

逻辑说明:用 QNetworkAccessManager 发 GET 请求,成功后解析 JSON 并写入 SQLite。界面监听 configUpdated 信号刷新菜单。如果请求失败,继续用本地缓存,不影响营业。

参数说明:JSON 里图片 URL 建议用 CDN 地址,终端首次加载后缓存到本地。配置版本号要带上,终端对比版本号决定是否更新,避免每次启动都全量拉取。请求超时设 5 秒,超时后走本地缓存。

6.3 用日志定位现场问题:分级输出和滚动文件

现场出问题时,不可能每次都远程调试。我一般会在终端上写日志文件,按天切割,保留最近 7 天。日志分三级:INFO 记录订单和支付,WARNING 记录打印失败和网络超时,ERROR 记录崩溃和数据库异常。

void customMessageHandler(QtMsgType type, const QMessageLogContext &, const QString &msg) { QString level; switch (type) { case QtDebugMsg: level = "DEBUG"; break; case QtInfoMsg: level = "INFO"; break; case QtWarningMsg: level = "WARN"; break; case QtCriticalMsg: level = "ERROR"; break; case QtFatalMsg: level = "FATAL"; break; } QString line = QString("[%1] [%2] %3\n") .arg(QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss")) .arg(level).arg(msg); QFile file("app.log"); if (file.open(QIODevice::Append)) { file.write(line.toUtf8()); file.close(); } }

逻辑说明:在 main 函数里调用qInstallMessageHandler(customMessageHandler),所有 qDebug、qInfo、qWarning 都会走这个函数。日志按追加方式写入,每天检查文件大小,超过 10MB 就重命名归档。

参数说明:日志里不要打印顾客手机号或支付密码,只打订单号和金额。文件路径用绝对路径,比如/var/log/order/app.log,避免工作目录变化导致找不到。如果终端磁盘小,保留 3 天也够用。

这套方案我从最小界面一路做到远程配置,中间翻车最多的不是代码本身,而是硬件兼容性和现场网络。我的习惯是:每加一个硬件,先写一个最小测试程序单独跑通,再合进主程序;每改一次数据库结构,先在测试终端上跑 24 小时再推现场。希望帮到你。

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

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

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

立即咨询