基于Qt C++和SQLite打造非遗手作成品管理系统 非遗手作的成品管理听起来不像什么技术难题真做起来才发现这里面的坑一点不比写复杂业务系统少。成品有类别、有工艺、有传承人、有价格、有存放位置、有在库/已售/借展状态还要管图片、管流转记录。传统记账方式只能记录文字时间一长图片找不到、状态对不上、谁借走的一件作品完全无据可查。所以我用了大约三周时间基于 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、执行查询、使用 TableModelQt Gui图片加载、委托绘制相关的绘图能力模块上刻意没有引入 Qt Network 和 Qt WebEngine因为单机系统用不到网络请求引入反而会让最终绿色包体积膨胀不少。界面布局我采用了传统的 QMainWindow 加中央 QWidget 的方案顶部放工具栏按钮左侧放筛选面板中间放成品表格右边放一个可折叠的预览面板用于展示当前选中成品的图片和详细信息。整体代码组织分了下面几个部分数据层DatabaseManager 负责数据库初始化和连接模型层基于 QSqlTableModel 派生了一个产品模型再包一层自定义的过滤代理模型界面层MainWindow、ProductEditDialog、PreviewPanel工具层ImageUtils图片缩略图生成、ExportUtilsCSV导出、BackupUtils数据库备份2.2 数据库表设计主表、分类表和操作流水表数据库我选 SQLite理由之前说了单机场景下它就是最合适的不需要单独安装服务一个文件就是整个数据库备份复制都方便。建表时我设计了三个表产品主表 products字段类型说明idINTEGER PRIMARY KEY AUTOINCREMENT主键nameTEXT NOT NULL成品名称categoryTEXT所属类别craftTEXT工艺名称creatorTEXT作者/传承人descriptionTEXT作品描述priceREAL价格statusINTEGER DEFAULT 1状态0未知、1在库、2已售、3借展locationTEXT存放位置image_pathTEXT图片文件路径created_atTEXT创建时间分类表 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 默认会对第一列做字符串匹配这显然不够用。我的搜索需求其实很简单用户在一个搜索框里输入任意关键字系统去成品名称、工艺、作者、描述这几个字段里做包含匹配不区分大小写。实现方式很直接写一个子类重写 filterAcceptsRowclass 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_castQSqlTableModel *(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 QHashQString, 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 note) { 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-charsetUTF-8 -fexec-charsetUTF-8)如果是在 MSVC 环境下则要加/utf-8编译选项。这一步不做后续每一个界面中文都是隐性炸弹。另外数据库路径如果包含中文目录比如E:\非遗数据\hands.dbSQLite 打开时偶尔会失败尤其是在某些 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 BFQFile 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 这套组合非常适合非遗手作成品这种数据量不大但字段形态丰富的单机管理场景。你花时间把数据模型和状态流转理清楚之后后面界面和功能的实现都是水到渠成的事。项目中踩过的中文编码、图片路径、打包部署这些坑如果你正在动手做类似的桌面管理系统照着上面说的方法提前规避能省下不少排查时间。