非遗手作的成品管理,听起来不像什么技术难题,真做起来才发现,这里面的坑一点不比写复杂业务系统少。成品有类别、有工艺、有传承人、有价格、有存放位置、有在库/已售/借展状态,还要管图片、管流转记录。传统记账方式只能记录文字,时间一长,图片找不到、状态对不上、谁借走的一件作品完全无据可查。所以我用了大约三周时间,基于 Qt C++ 和 SQLite 做了一套单机版非遗手作成品管理系统,能管商品信息、管入库出库状态、管图片和检索,还能导出报表。这篇文章把我从数据建模到界面实现、再到打包上线的过程完整写出来,尤其是哪些地方容易返工、哪些坑我替你先踩了,希望能给要做类似桌面管理系统的朋友一个参考。
1. 这个项目是怎么冒出来的:非遗手作成品的账目到底乱在哪
1.1 管理现状的真实痛点
在动手写代码之前,我先花了两天时间去梳理实际使用场景。去了几个手作工作室和展销场所看了一圈,问题比我想象的突出:
- 成品台账靠纸质表格或者电子表格,记录了名称、价格和卖出日期,但图片是单独存在手机里的,要对上号全靠人脑记忆
- 作品状态不统一,有的放在展销架上、有的在仓库、有的被借去参展、有的已经售出但还没交货,没有一个统一的字段能表达
- 传承人和工艺类别五花八门,同一个类别在不同人嘴里叫法都不一样,比如同一种织绣工艺,有人叫"绣花"有人叫"刺绣",分类一乱,后续统计就全乱了
- 缺少流转记录,作品从仓库到展厅、从展厅到客户手里,中间经手人是谁、哪天转的,完全没有痕迹
这些问题的本质,是"成品"这个对象没有一个结构化的数据模型在支撑。纸质的、图片式的、Excel式的管理方式,都停留在"记录"层面,没有上升到"跟踪"和"检索"层面。所以我的第一个决定不是选技术栈,而是先把数据模型设计出来,哪怕后边界面丑一点,数据不乱,系统就成功了一大半。
1.2 为什么选择 Qt C++ 而不是 Web 方案
这个需求摆到面前时,有人可能会想,现在都什么年代了,为什么不做成 Web 系统,手机浏览器直接访问多方便。我认真考虑过这个问题,最终选 Qt C++ 有其实际原因:
当时的使用环境是工作室和展销现场,网络条件并不稳定,有时候在展会现场根本没有可用网络。做 Web 系统意味着要部署服务器、要处理公网访问、要维护账号体系,对一个小团队来说运维成本偏高。而 Qt 做的桌面程序,安装在一台本地电脑上就能跑,数据落在本地 SQLite 文件里,完全不受网络影响,开机即用,也方便定期备份。
Qt C++ 本身在桌面 GUI 领域优势很直接:模态对话框、原生文件对话框、拖拽支持、系统托盘这些能力都是现成的,写业务界面效率比你想的高很多。尤其是它提供的 Model/View 框架,对表格、列表、树这种数据密集型界面支持非常完善,天然适合做管理类系统。再加上信号槽机制,界面上一个按钮点击、一行选择变化,都能非常自然地驱动数据更新。
还有一点是部署形态。Qt 的应用可以打包成单个目录甚至在 Linux 下直接做成 AppImage,拷贝到其他电脑就能运行,不需要目标机器预装任何运行时。对于非遗工作室这种可能只有一台公用电脑的场景,这种"绿色软件"形态是最省心的。
2. 系统骨架与数据库建模:先把"成品"这个核心对象定清楚
2.1 整体架构与 Qt 模块选型
这个系统我定位为单机桌面应用,所以整体架构保持简洁,没有引入复杂的框架。我在 Qt 6 下使用 Qt Widgets 界面方案,用到的核心模块有:
- Qt Core:字符串处理、文件路径、信号槽这些基础能力
- Qt Widgets:主窗口、表格、表单对话框的界面实现
- Qt Sql:连接 SQLite、执行查询、使用 TableModel
- Qt Gui:图片加载、委托绘制相关的绘图能力
模块上刻意没有引入 Qt Network 和 Qt WebEngine,因为单机系统用不到网络请求,引入反而会让最终绿色包体积膨胀不少。界面布局我采用了传统的 QMainWindow 加中央 QWidget 的方案,顶部放工具栏按钮,左侧放筛选面板,中间放成品表格,右边放一个可折叠的预览面板,用于展示当前选中成品的图片和详细信息。
整体代码组织分了下面几个部分:
- 数据层:DatabaseManager 负责数据库初始化和连接
- 模型层:基于 QSqlTableModel 派生了一个产品模型,再包一层自定义的过滤代理模型
- 界面层:MainWindow、ProductEditDialog、PreviewPanel
- 工具层:ImageUtils(图片缩略图生成)、ExportUtils(CSV导出)、BackupUtils(数据库备份)
2.2 数据库表设计:主表、分类表和操作流水表
数据库我选 SQLite,理由之前说了,单机场景下它就是最合适的,不需要单独安装服务,一个文件就是整个数据库,备份复制都方便。建表时我设计了三个表:
产品主表 products:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PRIMARY KEY AUTOINCREMENT | 主键 |
| name | TEXT NOT NULL | 成品名称 |
| category | TEXT | 所属类别 |
| craft | TEXT | 工艺名称 |
| creator | TEXT | 作者/传承人 |
| description | TEXT | 作品描述 |
| price | REAL | 价格 |
| status | INTEGER DEFAULT 1 | 状态:0未知、1在库、2已售、3借展 |
| location | TEXT | 存放位置 |
| image_path | TEXT | 图片文件路径 |
| created_at | TEXT | 创建时间 |
分类表 categories 用一个简单的 ID 加名称表来管理类别,方便下拉选择时统一叫法,避免"刺绣"和"绣花"这种一物多名的问题。
操作流水表 operation_logs 记录了每一件成品的状态变化,包括操作时间、操作类型、备注。这张表在排查"那件作品去哪了"这类问题时就是救命稻草。
建表语句中我把 status 设计成了整数而不是字符串。原因是字符串状态在界面上可以显示中文,但在筛选、统计、排序时处理起来非常别扭,而且容易写错字。用整数加枚举映射,数据的稳定性和可扩展性都好得多。
2.3 连接与初始化:SQLite 的连接写法
初始化部分我封装了一个 DatabaseManager 类。连接 SQLite 的标准写法如下:
bool DatabaseManager::initializeDatabase(const QString &dbPath) { QSqlDatabase db = QSqlDatabase::contains("main_connection") ? QSqlDatabase::database("main_connection") : QSqlDatabase::addDatabase("QSQLITE", "main_connection"); db.setDatabaseName(dbPath); if (!db.open()) { return false; } QSqlQuery query(db); query.exec("CREATE TABLE IF NOT EXISTS products (...);"); query.exec("CREATE TABLE IF NOT EXISTS categories (...);"); query.exec("CREATE TABLE IF NOT EXISTS operation_logs (...);"); return true; }新写项目时我特别建议使用命名连接。在比较复杂的系统里,一个程序可能同时连接多个数据库,如果不显式给连接起名,Qt 会使用默认连接"qt_sql_default_connection",多个代码块之间容易互相干扰,排查起来很头疼。
还有一个小细节:SQLite 在写库时默认是事务自动提交的,批量导入数据时逐条 insert 会很慢。我导入初始数据时把插入语句包在事务里,速度提升非常明显。
db.transaction(); for (const auto &record : productRecords) { // 执行 INSERT } db.commit();3. 界面落地:从表格模型到筛选搜索的完整实现
3.1 基于 QSqlTableModel 的成品表格
界面的核心是一张成品表格。如果你直接用 QTableView 配合 QSqlTableModel,基本上几十行代码就能把表里的数据铺出来。我的做法是这样:
m_productModel = new QSqlTableModel(this, db); m_productModel->setTable("products"); m_productModel->setEditStrategy(QSqlTableModel::OnManualSubmit); m_productModel->select(); m_tableView = new QTableView(this); m_tableView->setModel(m_productModel); m_tableView->setSelectionBehavior(QAbstractItemView::SelectRows); m_tableView->setSelectionMode(QAbstractItemView::SingleSelection);这里 setEditStrategy 设置成 OnManualSubmit 很关键。默认的 OnRowChange 会在用户切换行时自动提交数据库,这个行为很危险,比如界面上某一行数据被用户不小心改了一个字符,切到下一行时数据库就被悄悄更新了,后来想查谁改的都不知道。改成手动提交后,界面上所有编辑都只停留在缓存里,只有点击"保存全部修改"按钮时才会统一写库,等于加了一道保险。
表格本身的显示效果也需要处理几处:
- 列宽根据内容类型分配,名称列给足拉伸空间,价格列和状态列保持固定宽度
- 关闭竖直表头,开启隔行变色(AlternatingRowColors),数据多的时候视觉上不容易看串行
- 隐藏 id 列,但保留列索引以便后续获取选中行数据
3.2 搜索过滤:QSortFilterProxyModel 的自定义子类
有了表格之后,下一步就是要能搜。Qt 官方的 QSortFilterProxyModel 默认会对第一列做字符串匹配,这显然不够用。我的搜索需求其实很简单:用户在一个搜索框里输入任意关键字,系统去成品名称、工艺、作者、描述这几个字段里做包含匹配,不区分大小写。
实现方式很直接,写一个子类重写 filterAcceptsRow:
class ProductFilterModel : public QSortFilterProxyModel { Q_OBJECT public: explicit ProductFilterModel(QObject *parent = nullptr); protected: bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; public: QString m_keyword; }; bool ProductFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const { if (m_keyword.isEmpty()) { return true; } QSqlTableModel *sourceModel = qobject_cast<QSqlTableModel *>(sourceModel()); if (!sourceModel) { return true; } const QString name = sourceModel->record(sourceRow).value("name").toString(); const QString craft = sourceModel->record(sourceRow).value("craft").toString(); const QString creator = sourceModel->record(sourceRow).value("creator").toString(); const QString desc = sourceModel->record(sourceRow).value("description").toString(); return name.contains(m_keyword, Qt::CaseInsensitive) || craft.contains(m_keyword, Qt::CaseInsensitive) || creator.contains(m_keyword, Qt::CaseInsensitive) || desc.contains(m_keyword, Qt::CaseInsensitive); }搜索框的 textChanged 信号连接到过滤模型的 setKeyword 槽函数,然后调用 invalidateFilter 让界面刷新。
我在这个环节犯过一个低级错误:一开始我在构造函数里把搜索框和过滤模型连好,但忘记在界面上把 QTableView 的模型从原始模型切换成代理模型,结果表格还是显示全部数据,搜索框像摆设。这个顺序问题在搭建初期很容易漏,排查时可以先确认 view 的 model 到底挂的是哪一个。
3.3 图片与状态列的委托绘制
表格里的图片列和状态列用默认的文本显示太简陋了。我在表格里加了一列"图片",这一列不显示路径字符串,而是显示一张缩略图。这里用到 Qt 的委托机制,通过继承 QStyledItemDelegate 并重写 paint 方法。
class ThumbnailDelegate : public QStyledItemDelegate { public: void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override { const QString path = index.data(Qt::DisplayRole).toString(); if (path.isEmpty()) { QStyledItemDelegate::paint(painter, option, index); return; } painter->save(); if (option.state & QStyle::State_Selected) { painter->fillRect(option.rect, option.palette.highlight()); } else { painter->fillRect(option.rect, option.palette.base()); } const int size = qMin(option.rect.width(), option.rect.height()) - 4; QPixmap thumb = ImageUtils::loadThumbnail(path, size); if (!thumb.isNull()) { const int x = option.rect.x() + (option.rect.width() - thumb.width()) / 2; const int y = option.rect.y() + (option.rect.height() - thumb.height()) / 2; painter->drawPixmap(x, y, thumb); } painter->restore(); } };缩略图核心在 ImageUtils::loadThumbnail。这个函数用 QPixmap 加载图片后做一个等比缩放,并把缩放结果缓存进一个 QHash,避免每次重绘都重新读磁盘文件。如果不加缓存,表格滚动时画面会明显卡顿,因为每次 paint 都要做一次磁盘 IO 和缩放运算。
QPixmap ImageUtils::loadThumbnail(const QString &path, int size) { static QHash<QString, QPixmap> cache; if (cache.contains(path)) { return cache.value(path); } QPixmap original; if (!original.load(path)) { return QPixmap(); } QPixmap scaled = original.scaled(size, size, Qt::KeepAspectRatio, Qt::SmoothTransformation); cache.insert(path, scaled); return scaled; }状态列同理,我用一个小委托根据整数状态绘制不同颜色的文本标签:在库显示绿色、已售显示灰色、借展显示蓝色、未知显示橙色。信息在视觉上一眼就能区分开,这个对线下盘点场景帮助特别大。
4. 一个"更像真实产品"的界面:表单、库存流转与异常提醒
4.1 新增/编辑对话框的设计
成品表单我单独做了一个 ProductEditDialog,没有直接在表格里编辑。原因是一个成品的属性很多,在表格里一行根本显示不完整,尤其是作品描述这种长文本,而且照片选择这种操作必须在对话框里完成。
对话框里的表单控件布局用的是表单布局,从上到下依次是:
- 名称:QLineEdit,必填
- 类别:QComboBox,数据从 categories 表动态加载
- 工艺名称:QLineEdit
- 作者/传承人:QLineEdit
- 价格:QDoubleSpinBox,范围设 0 到 999999,小数位 2 位
- 状态:QComboBox,固定选项映射到整数状态
- 存放位置:QLineEdit
- 作品描述:QTextEdit
- 作品图片:图片选择区,包含一个"选择图片"按钮和一个预览标签
图片选择按钮的逻辑是调用 QFileDialog 打开图片文件,确认后把文件复制到系统指定的 images 目录下,而不是直接保存原路径。这一步非常重要,因为用户可能从任意目录选图,如果数据库记录的是 D 盘某路径,下次把库文件拷贝到别的电脑时图片就全丢了。统一复制到应用目录的 images 子目录里,系统就自包含了。
对话框关闭时校验名称不能为空、价格不能为负数。我提交时没有用 QDialog::accept,而是先做一遍校验,校验通过再调用 accept,这样能避免误操作关闭窗口导致数据丢失。
4.2 状态变更与库存流水
系统里设计的三个核心业务动作是:入库、售出、借展归还。每个动作本质上是把 products 表的 status 字段改成对应值,同时在 operation_logs 表里写一条流水。我把这个逻辑封装成一个单独的函数:
bool ProductManager::changeStatus(int productId, int newStatus, const QString ¬e) { QSqlDatabase db = QSqlDatabase::database("main_connection"); db.transaction(); QSqlQuery query(db); query.prepare("UPDATE products SET status = :status WHERE id = :id"); query.bindValue(":status", newStatus); query.bindValue(":id", productId); if (!query.exec()) { db.rollback(); return false; } QSqlQuery logQuery(db); logQuery.prepare("INSERT INTO operation_logs(product_id, old_status, new_status, note, operate_time) " "VALUES(:pid, :old_status, :new_status, :note, datetime('now','localtime'))"); logQuery.bindValue(":pid", productId); logQuery.bindValue(":old_status", oldStatus); logQuery.bindValue(":new_status", newStatus); logQuery.bindValue(":note", note); if (!logQuery.exec()) { db.rollback(); return false; } db.commit(); return true; }用事务把状态更新和流水写入包在一起,保证两个操作要么同时成功要么同时失败,不会出现状态改了流水没记的情况。这个设计是我在梳理需求时从存货盘点中的一个实际事故里学到的教训:曾经出现一件作品界面显示已售,但后台完全查不到销售时间,原因就是只改状态没记流水。
4.3 列表刷新的时序问题
界面上每次状态变更后,都要刷新表格数据,但直接调用 model->select() 有一个时序问题:如果刚 commit 完事务立即 select,在某些情况下界面不会立刻更新,或者更新的数据还是旧值。我排查过这个现象,原因是操作日志的写入和产品状态的修改虽然在同一个事务里,但 select() 重新查的是整个表,如果代理模型的过滤条件还带着旧状态,改动后反而被过滤掉了。
我的解决思路是:状态变更完成后,先刷新过滤模型的关键字,再调 select,再调 invalidateFilter。刷新顺序固定为:
m_productModel->select(); m_filterModel->invalidateFilter(); m_tableView->selectionModel()->clearSelection();这样既能保证数据是最新的,也能避免刷新后表格还残留之前的选中行,导致用户误以为操作没生效。这个经验说白了就是:刷新不是简单调一个函数,时序和联动条件都要考虑到。
5. 实际开发中绕不开的坑:中文编码、图片路径与打包部署
5.1 Windows 下中文数据库路径与 GBK 问题
这个坑是我在所有桌面系统开发中遇到最多的。我的开发环境最开始时在 Windows 上,源代码文件默认是 GBK 编码,而 Qt 6 默认源文件按 UTF-8 处理。一旦源文件里有中文注释或者中文字符串字面量,在旧编译器或者跨平台编译时就会出现乱码,严重时直接执行失败。
解决办法很干脆:把全工程的源文件统一转成 UTF-8 编码,并且工程配置里强制指定 UTF-8。
add_compile_options("-finput-charset=UTF-8 -fexec-charset=UTF-8")如果是在 MSVC 环境下,则要加/utf-8编译选项。这一步不做,后续每一个界面中文都是隐性炸弹。另外数据库路径如果包含中文目录(比如"E:\非遗数据\hands.db"),SQLite 打开时偶尔会失败,尤其是在某些 Qt 版本下。稳妥做法是:优先用 QStandardPaths 的应用数据目录来存放数据库文件,这个路径一般不包含中文;如果必须放在用户自选目录,则用 QDir::toNativeSeparators 做一次路径规范转换。
5.2 图片路径的相对化与备份
图片路径问题是管理类系统最容易翻车的地方。我在开发早期直接存了图片原路径,后来模拟用户场景时,把整个数据目录从一台电脑拷贝到另一台电脑,结果图片全部丢失,因为数据库里存的是原始机器的绝对路径。
最终的方案是路径相对化:数据库里存图片文件名,不存完整路径。例如一个成品图片最终存在程序目录下的 images/20240512_1530_01.jpg,数据库里就只存"images/20240512_1530_01.jpg"这种相对记录。显示图片时,用一个查找函数把相对路径映射到当前程序目录下的绝对路径:
QString resolveImagePath(const QString &relativePath) { if (QFileInfo(relativePath).isAbsolute()) { return relativePath; } return QDir(QCoreApplication::applicationDirPath()).filePath(relativePath); }对应地,备份数据库时也要把 images 目录一起打包,否则备份的数据是没图片的。我在备份工具里提供了两种模式:完整备份(数据库加图片目录一起打包)和纯数据备份(只备份数据库文件)。实际使用中完整备份模式更受欢迎,恢复起来最简单。
5.3 打包发布与精简体积
Qt 程序打包发布是一个慢工出细活的环节。我最早直接把自己开发环境里编译出的 exe 拷到别的电脑上试运行,报错提示缺少 Qt6Core.dll,然后逐个拷 DLL,接着又报缺少某个插件目录。后来用官方提供的 windeployqt 工具,一步到位解决大部分依赖。
命令大致是:
windeployqt --release --no-translations --no-compiler-runtime --no-system-d3d-compiler加 no-translations 可以去掉占体积的多语言翻译文件,加 no-compiler-runtime 可以把 VC 运行库排除掉,前提是目标机器装有对应运行库。实际给工作室安装时,我做了两个版本:一个是完整带运行库的绿色包,一个是精简包。实测下来完整包因为拷贝了更多 DLL,兼容性更好,精简包在某些电脑上会遇到缺运行库的尴尬。我的建议是追求稳定优先,完整绿色包反而是最省心的。
Linux 平台我用的打包方案是 linuxdeployqt 制作 AppImage。AppImage 的好处是单个文件,双击即运行,不污染系统环境,这对交付给非技术背景的用户非常友好。
6. 顺手的扩展:报表导出、二维码标签与数据备份
6.1 CSV 导出与 Excel 打开乱码问题
管理系统的数据最终要交出去做分析,或者给其他同事看。我在工具栏加了一个"导出当前筛选结果"按钮,把当前过滤后的成品列表导出成 CSV 文件。
导出本身不难,但有一个老经验必须强调:用 Excel 直接打开 UTF-8 编码的 CSV 时会乱码,因为 Excel 默认按 ANSI 编码解析。这个问题的通用解法是在 CSV 文件开头写入 BOM 头(EF BB BF):
QFile file(filePath); if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { QTextStream out(&file); out.setCodec("UTF-8"); out << QChar(0xFEFF); // 写入 BOM // 然后写表头和每一行数据 }加了 BOM 之后,Excel 打开就会正确识别为 UTF-8。如果你的导出文件会发给别人用 Excel 打开,这一步千万别省。另外 CSV 里的中文内容建议统一用 UTF-8 编码,避免历史遗留的 GBK 问题。
6.2 二维码标签的实现思路
非遗成品经常要参加展会或放在展销区,一件作品旁边放一个标签很常见。我的扩展方案是给每件成品生成一个二维码,二维码内容本身不是链接,而是一个固定的唯一编号字符串,比如"NH-2024-0001"。二维码可视化之后打印出来贴在作品旁边,扫码的人可以用手机看到这个编号,回到系统里查详情。
Qt 本身不带二维码生成库,我用的是一个第三方开源库 qrencode 的封装思路。核心操作是:
QRcode *code = QRcode_encodeString(uniqueCode.toUtf8().constData(), 0, QR_ECLEVEL_M, QR_MODE_8, 1); if (code) { QImage qrImage(code->width, code->width, QImage::Format_Mono); // 按位填充黑白像素 }如果只是内部记录用,也可以退一步用 Qt 画一个简单的条形码凑合,但既然需求量不大,我建议还是直接用二维码库,生成稳定且能存更多信息。生成好的二维码图片统一输出到一个 labels 目录,打印时直接贴进文档即可。
6.3 一键备份与自动清理策略
数据是管理系统的命根子。我给系统加了一键备份功能,按钮放在工具栏的独立位置,点击后弹出保存对话框,默认文件名带日期。
QString backupName = QString("backup_%1.zip") .arg(QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss"));备份内容包含数据库文件、images 目录和 labels 目录。我使用 QuaZip 或者直接调用外部 zip 命令来完成打包,考虑到目标机器不一定装了压缩工具,后来改用了 Qt 自带的 QBuffer 加简单文件拼接,再配合一个极简的 zip 生成逻辑,避免依赖第三方运行时。
备份策略上我提醒工作室管理员每周做一次完整备份,保留最近 4 份,更早的自动清理。清理逻辑按文件日期判断,超过保留份数的备份文件直接删除,防止磁盘被备份文件塞满。这类系统数据量本身不大,但备份散落各处反而会造成混乱,集中管理最省心。
做完整套系统,我最大的感受是,业务系统最大的成本和风险其实不在编码,而在对业务对象和状态的梳理。Qt C++ 和 SQLite 这套组合,非常适合非遗手作成品这种数据量不大但字段形态丰富的单机管理场景。你花时间把数据模型和状态流转理清楚之后,后面界面和功能的实现都是水到渠成的事。项目中踩过的中文编码、图片路径、打包部署这些坑,如果你正在动手做类似的桌面管理系统,照着上面说的方法提前规避,能省下不少排查时间。