尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

PySide/PyQt表格多格式录入与主从表事务保存实战

发布时间:2026/9/24 19:57:35

资讯中心
01
ARTICLE

PySide/PyQt表格多格式录入与主从表事务保存实战

PySide/PyQt表格多格式录入与主从表事务保存实战
最近在给仓库部门改造一套材料入库工具表格里除了普通文本还要能直接录入日期、选仓库、填数量、勾选抽检状态同时底部需要把入库单和明细行一起联动展示、一起保存。这个需求放在 PySide/PyQt 项目里相当典型几乎可以当成一个“桌面端主从表业务模板”来用。文章里我会把从表格多格式录入到主从表显示、再到事务保存的完整思路和代码细节都拆开讲适合正在做进销存、工单管理、资产盘点这类桌面工具的朋友直接参考。很多时候大家第一反应是拖一个 QTableView 或者 QTableWidget 上去然后逐列写 setCellWidget写到一半发现编辑器跟数据校验、跟主从表刷新逻辑缠在一起越写越乱。这篇文章会把 Model/View 和 Delegate 的职责分清楚也会把主从表保存时最容易被忽略的“事务回滚”和“主键回填”讲明白帮你少踩几个坑。1. 先理清需求表格录入和主从表到底难在哪1.1 一个表格里藏着多少种格式表格录入看着简单真上手就发现“每列格式都不一样”才是麻烦源头。拿我手里的材料入库单举例入库单号是普通文本供应商要从下拉列表选到货日期要弹日历数量必须只能输数字单价保留两位小数金额列最好是只读自动计算备注允许任意文本再加一个“是否抽检”的勾选状态。把这些需求全部堆在一个二维表格里最直接的问题是编辑器的行为差异很大。默认 QTableWidget 的编辑就是一个文本框你想让某列变成下拉框、某列变成日期控件、某列只能输数字就得针对列做专门的编辑器控制。如果只是用 setItemWidget 往单元格里塞控件等到数据量一大、界面一刷新控件数量爆炸交互卡顿而且取值、赋值、校验代码会散落到各个角落维护成本直线上升。PySide/PyQt 里真正合适的做法是 Model/View 结构加 Delegate 机制。也就是说数据本身交给 Model 管理界面绘制和编辑器创建交给 Delegate 负责视图只管展示和交互。这个分工一旦建立起来后面加一列“税率下拉”、加一列“日期范围校验”都只需要新增对应 Delegate不用在业务代码里到处改。顺便提一个大家常纠结的话题桌面框架选 Electron 还是 PySide/Qt。Electron 的好处是前端生态丰富界面好做但如果你要处理这种重表格、强键盘操作、频繁读取本地数据库的业务工具PySide/PyQt 的 QTableView/QAbstractTableModel 这套原生控件组合其实更顺手启动体积小、内存占用低打包也不难。选框架前先想清楚数据形态表格密集型工具我基本都推荐 Qt 系。1.2 主从表不是两张大表摆一起主从表指的是“一张主表 多行明细”的结构比如“采购入库单”对应多条“入库明细”每条明细有物料、数量、单价等字段。界面上通常上面是主表单或主表列表下面是明细表列表选主表某一项下面明细表跟着切换。这里有个容易犯的逻辑错误主从表不只是在界面上放两个 QTableView然后各自绑定一张表的查询结果。主从表的核心是数据关系明细表的每条记录都要通过外键关联到主表记录界面上所有操作最终要落到“一个事务里同时保存主表和明细表”要么全成功要么全回滚。所以在设计数据库表结构时就要先约定好主从关系。比如我常用的建表思路是CREATE TABLE stock_in_main ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL, supplier TEXT NOT NULL, in_date TEXT NOT NULL, remark TEXT DEFAULT , created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE stock_in_detail ( id INTEGER PRIMARY KEY AUTOINCREMENT, main_id INTEGER NOT NULL REFERENCES stock_in_main(id), material_code TEXT NOT NULL, material_name TEXT NOT NULL, quantity REAL NOT NULL, price REAL NOT NULL, amount REAL NOT NULL, check_status INTEGER DEFAULT 0 );界面上的主从联动本质是对这两张表的操作。这样设计之后后面无论是新增、修改、删除还是保存逻辑都清晰很多。接下来我会分别从“多格式录入”和“主从表展示/保存”两条线展开。2. 多格式录入的核心实现Delegate 才是编辑器的主角2.1 别再用 setCellWidget 一把梭很多初学者甚至老手习惯在 QTableWidget 上直接 setCellWidget 把 QComboBox、QDateEdit 塞进单元格。这种写法在数据行数少、不做排序、不做筛选时确实能用但它有几个严重问题每次 setCellWidget 都会创建真实控件几百行以上性能下降明显。数据模型和控件绑定混乱刷新界面时要手动找回所有控件并重新赋值容易漏。排序、过滤后控件会跟着行走经常出现“行移动了但控件没跟着移动”的怪现象。校验逻辑分散今天这个数字校验写在控件的事件里明天那个下拉框默认值写在另一个地方时间一长没人敢改。更进阶的做法是使用 QStyledItemDelegate。Delegate 负责“需要编辑时创建编辑器控件”但编辑器是临时的编辑完就销毁不会常驻单元格。这样性能好逻辑也集中。你只要为某列设置一个 Delegate视图在需要编辑时自动调用它而不用你自己管理控件生命周期。PySide/PyQt 里自带默认 Delegate普通文本、勾选状态、图标显示都有基础支持。但要让某列变成数字输入受限、日期日历弹窗、下拉选择就需要自定义 Delegate。下面我会给出实际项目里比较通用的一套写法。2.2 自定义 Delegate数字、日期、下拉一次说清楚假设我的材料入库单明细表里有这些列列数据类型要求物料编码文本只读或从物料表选择数量数值只能输入非负数最多两位小数单价数值只能输入非负数最多两位小数金额数值自动计算不可编辑到货日期日期弹日历格式 yyyy-MM-dd仓库文本下拉选择是否抽检布尔显示勾选框对应的 Delegate 可以这么写from PySide6 import QtCore, QtGui, QtWidgets class DecimalDelegate(QtWidgets.QStyledItemDelegate): 只能输数字保留指定小数位数 def __init__(self, decimals2, minimum0.0, maximum999999999.0, parentNone): super().__init__(parent) self._decimals decimals self._minimum minimum self._maximum maximum def createEditor(self, parent, option, index): editor QtWidgets.QLineEdit(parent) validator QtGui.QDoubleValidator( self._minimum, self._maximum, self._decimals, editor ) editor.setValidator(validator) return editor def setEditorData(self, editor, index): value index.data() if value is None: editor.setText() return if isinstance(value, (int, float)): editor.setText(f{value:.{self._decimals}f}) else: editor.setText(str(value)) def setModelData(self, editor, model, index): text editor.text().strip() if text : model.setData(index, None) return model.setData(index, float(text))再看日期和下拉框class DateDelegate(QtWidgets.QStyledItemDelegate): 日历选择日期 def createEditor(self, parent, option, index): editor QtWidgets.QDateEdit(parent) editor.setCalendarPopup(True) editor.setDisplayFormat(yyyy-MM-dd) editor.setDate(QtCore.QDate.currentDate()) return editor def setEditorData(self, editor, index): value index.data() if value: editor.setDate(QtCore.QDate.fromString(value, yyyy-MM-dd)) def setModelData(self, editor, model, index): model.setData(index, editor.date().toString(yyyy-MM-dd)) class ComboBoxDelegate(QtWidgets.QStyledItemDelegate): 下拉选择 def __init__(self, items, parentNone): super().__init__(parent) self._items items or [] def createEditor(self, parent, option, index): editor QtWidgets.QComboBox(parent) editor.addItems(self._items) editor.setEditable(False) return editor def setEditorData(self, editor, index): editor.setCurrentText(index.data() or ) def setModelData(self, editor, model, index): model.setData(index, editor.currentText())用的时候只需要对具体列设置 Delegatetable QtWidgets.QTableView() # 第2列是数量 table.setItemDelegateForColumn(2, DecimalDelegate(decimals2)) # 第4列是日期 table.setItemDelegateForColumn(4, DateDelegate()) # 第5列是仓库 table.setItemDelegateForColumn(5, ComboBoxDelegate([华东仓, 华南仓, 华北仓]))这样编辑时 QTableView 会自动创建临时编辑器编辑结束后自动读取结果并写入 Model整个交互流程统一且干净。2.3 Delegate 和 Model 怎么配合才稳自定义 Delegate 只是解决了“用什么控件编辑”的问题真正决定数据格式能不能落到 Model 里还要看 Model 的 setData 方法。最好在 Model 层再做一次类型转换和校验形成一个双保险。比如我的自定义 QAbstractTableModel 里会根据列类型对输入值做处理class DetailTableModel(QtCore.QAbstractTableModel): def __init__(self, parentNone): super().__init__(parent) self._data [] self._columns [ {field: material_code, label: 物料编码, type: str}, {field: material_name, label: 物料名称, type: str}, {field: quantity, label: 数量, type: float}, {field: price, label: 单价, type: float}, {field: amount, label: 金额, type: calc}, {field: arrive_date, label: 到货日期, type: date}, {field: warehouse, label: 仓库, type: str}, {field: is_check, label: 是否抽检, type: bool}, ] def flags(self, index): # 金额是自动计算的不让编辑 if self._columns[index.column()][type] calc: return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable return ( QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEditable ) def setData(self, index, value, roleQtCore.Qt.EditRole): if role ! QtCore.Qt.EditRole: return False if not index.isValid() or not (0 index.row() len(self._data)): return False col self._columns[index.column()] try: if col[type] float: value float(value) if value is not None else None elif col[type] int: value int(value) if value is not None else None elif col[type] bool: value bool(value) except (TypeError, ValueError): return False row index.row() self._data[row][col[field]] value # 只要数量或单价变了金额自动重算 if col[field] in (quantity, price): qty self._data[row].get(quantity) or 0 price self._data[row].get(price) or 0 self._data[row][amount] round(qty * price, 2) self.dataChanged.emit( self.index(row, 0), self.index(row, len(self._columns) - 1), ) else: self.dataChanged.emit(index, index) return True这段代码的效果是Delegate 负责“编辑器不让用户乱输入”Model 负责“就算值从程序代码里塞过来也做一遍类型规整和业务联动”。两个层面各管一段表格最后的数据质量才靠得住。3. 主从表的数据显示从数据库关系模型到界面结构3.1 主从表场景的数据组织方式主从表界面在进销存、工单、盘点、检测任务里都非常常见。以我的材料入库场景来说主表是一张入库单字段包括单号、供应商、入库日期、备注明细表是多条物料记录每条都要落在同一张入库单下。界面组织上我一般不用复杂的嵌套表格也不建议你在 PySide/PyQt 里试图做一个“可展开子表”的花哨控件。最简单、最可靠、最容易被业务人员接受的结构是上下两段式上半部分主表列表或者主表单据编辑区。下半部分明细 QTableView根据当前选中的主表记录自动刷新。这样做的好处是数据库关系天然是“一对多”界面上对应“选中一条主记录下方显示它关联的多条明细”思维模型完全一致。做起来也不容易出错。3.2 用两个 QTableView 实现主从联动具体操作时我会给主表和明细表分别准备一个 Model然后用主表的 selectionModel 信号去驱动明细表的刷新。class MainTableModel(QtCore.QAbstractTableModel): # 主表字段: id, order_no, supplier, in_date, remark pass class DetailTableModel(QtCore.QAbstractTableModel): # 明细表字段: material_code, quantity, price, amount... pass main_table QtWidgets.QTableView() detail_table QtWidgets.QTableView() main_model MainTableModel() detail_model DetailTableModel() main_table.setModel(main_model) detail_table.setModel(detail_model) def on_main_row_changed(current, previous): if not current.isValid(): detail_model.clear() return main_id main_model.data(current.siblingAtColumn(0)) detail_model.setMainId(main_id) detail_model.reload() main_sel_model main_table.selectionModel() main_sel_model.currentRowChanged.connect(on_main_row_changed)这可能是最直白的主从联动方式。选择主表某一行明细 Model 带着 main_id 重新查询并刷新。不过这里有个实际经验要分享如果明细表数据已经全部加载到内存比如一次性查出当前单据的所有明细那就没必要每次切换都查数据库。更好的做法是把明细数据按主表主键缓存成字典切换时直接 setData只有新建单据时才追加空明细行。具体的缓存方式可以这样class MainDetailData(object): def __init__(self): self._detail_cache {} # main_id - list[dict] def load_all(self): # 一次性把主表和明细表数据都查出来 # 按 main_id 分组存到 _detail_cache pass def get_details(self, main_id): return self._detail_cache.get(main_id, [])这是典型的“空间换时间”。单据数量不大时一次性加载对性能没有任何压力但界面流畅度会有明显提升。用户切来切去不卡比什么都重要。3.3 明细表增删行时的顺序和索引问题主从表界面上明细表必须有“新增行”“删除行”操作。新增行容易直接在 Model 尾部 append 一个空行就行。删除行要注意删除后当前选中行索引会变化如果没有处理好容易出现“明明删了第3行结果第4行没了”的情况。我的习惯是只允许删除当前选中行并在删除前记录自定义主键如果明细行已经存过库就标记为“待删除”等保存时统一执行 DELETE。如果 UI 操作只是临时的删除时可以给行打一个 hidden 状态标记而不是立刻改数据。def remove_current_row(self, table, model): row table.currentIndex().row() if row 0 or row model.rowCount(): return model.removeRow(row)如果你的保存逻辑希望支持“修改时先删后插”那么这里只需要维护好内存数据保存时再统一生成 SQL 操作。不要把 UI 删除行为和数据库删除行为混在一起不然很容易在做事务时把逻辑搞乱。4. 主从表的保存操作事务、回滚与主键回填4.1 保存顺序为什么一定是“先主后从”主从表保存时顺序几乎是固定的先保存主表拿到主表新纪录的 ID再把主表 ID 作为外键写进每一条明细记录最后保存明细。这么做的原因很简单明细表的外键不能为空而新主表记录的 ID 只有在插入之后才能拿到。拿“新增一张入库单 3条明细”举例如果先把3条明细插进明细表此时它们还没有 main_id外键字段就得留空这既违反表结构约束也会让数据变成“孤儿明细”。反过来先插主表拿到 id再插明细一切就顺理成章。有人会想我能不能先插入主表但不提交然后立刻 SELECT MAX(id) 拿主键这个思路在并发环境下是错的。多个人同时录单时MAX(id) 可能拿到别的主记录 ID数据就会串。正确做法是在插入后通过数据库驱动提供的 lastInsertId 获取当前会话生成的主键。SQLite 和 MySQL、PostgreSQL 都支持这个能力。4.2 事务里拿到新插入的主键事务是主从表保存的底线。没有事务明细插入到一半出错了主表已经入库悬在数据库里的主记录就成了脏数据。所以保存逻辑必须包裹在 transaction/commit/rollback 里。用 PySide6 的 QSqlDatabase 写大概是这样from PySide6.QtSql import QSqlDatabase def save_stock_in(main_data, detail_rows): db QSqlDatabase.database() if not db.transaction(): raise RuntimeError(开启事务失败) try: # 1. 插入主表 query db.exec( INSERT INTO stock_in_main(order_no, supplier, in_date, remark) VALUES({order_no}, {supplier}, {in_date}, {remark}).format( **main_data ) ) if not query.isActive(): raise RuntimeError(主表插入失败) main_id query.lastInsertId() # 2. 插入或更新明细 for row in detail_rows: row[main_id] main_id if row.get(id): # 已有ID说明之前保存过这里做UPDATE pass else: # 没有ID说明是新增这里做INSERT pass if not db.commit(): raise RuntimeError(提交事务失败) return main_id except Exception as exc: db.rollback() raise RuntimeError(f保存失败已回滚: {exc})上面代码里我把 SQL 用 format 直接拼了只是为了表达流程真实项目里一定要用 QSqlQuery 的 bindValue 避免注入风险尤其当表格字段来自用户输入时。这个原则不用多说凡是处理外部输入SQL 都该用参数绑定而不是字符串拼接。4.3 明细行的增删改怎么合并处理明细表保存时最麻烦的不是插入而是“既有新增又有修改又有删除”。因为用户可能在某条旧明细上改了数量又新增了几行还删掉了另外几行。如果保存逻辑只是把所有明细重新插一遍旧数据就会重复如果只是 UPDATE新增行又没有 ID 没法处理。所以我一般会在 Model 的数据结构里给每一行加一个状态字段new、modified、deleted。界面上一切操作只改内存数据和状态等用户点“保存”时再根据状态决定执行 INSERT、UPDATE 还是 DELETE。这样处理的好处是事务提交前可以清晰列出本次保存会影响哪些记录万一出问题回滚后界面状态也不会乱。new_rows [r for r in detail_rows if r.get(_status) new] modified_rows [r for r in detail_rows if r.get(_status) modified] deleted_rows [r for r in detail_rows if r.get(_status) deleted] for row in deleted_rows: # 先删除明细 pass for row in new_rows modified_rows: # 再插入或更新 pass顺序上建议先处理删除再处理插入和更新避免因为外键、唯一索引等原因中途失败。4.4 保存完成后如何刷新界面保存成功只是一个开始界面需要立刻反映数据库里的新状态。我习惯在保存完成后做两件事用事务返回的新主键更新主表 Model 里的当前行 ID。重新加载明细表数据把新建行的临时状态清空改成“已保存”状态。如果不做这一步用户保存后继续编辑界面里的新增行还是没有 ID下次保存时又会被当成新数据产生重复记录。这也是新手踩得比较多的坑。以下是一个简单的刷新流程def after_save(main_id): main_model.reload() # 定位到刚保存的那一行 row main_model.findRowById(main_id) if row 0: main_table.selectRow(row) detail_model.setMainId(main_id) detail_model.reload()5. 实际项目中的问题排查与避坑5.1 日期控件总是弹不出来用 QDateEdit 做日期编辑时如果只设置 setDisplayFormat不设置 setCalendarPopup(True)默认是上翻按钮翻日期不是日历弹窗。很多人以为没生效其实就是少了这一行。另外如果你在 Delegate 的 createEditor 里创建了 QDateEdit但 createEditor 被频繁调用每次都会重新创建控件用户点击单元格后日历弹窗一闪而过接下来还要留意父窗口是不是设置了无边框或特殊事件过滤可能会吞掉弹窗事件。5.2 数字校验“明明输了字母还保存成功”这类问题一般是 Delegate 和 Model 两层里有一层没做限制。比如只重写了 createEditor但 validator 没正确设置或者 Model 的 setData 收到字符串时做了强制转换结果字符串也能转成数字等于没拦住。另一个常见场景是 QDoubleValidator 在使用时需要配合 setNotation(QDoubleValidator.StandardNotation)否则在某些 Qt 版本里会允许输入 e 或科学计数法。我的建议是UI 层的 validator 只负责“用户输入体验”Model 层的 setData 才负责最终把关。两边都要做不能只做一边。5.3 主从表保存时外键插不进去外键插不进去是主从表最常见的报错。原因往往是主表插入后没有拿到正确的 lastInsertId或者拿到的是 0。出现这种情况先去查数据库驱动是否支持 QSqlQuery.lastInsertId有些驱动在批量操作或 ORM 封装下会返回空值。如果不支持就得在建表时用自增主键并在插入后通过 RETURNING 语句或先查询当前会话的插入记录来获取主键具体要看数据库类型。5.4 主从表刷新时表格列宽、排序状态老被重置界面刷新时如果直接 setModel 替换掉整个 Model列宽、排序、筛选状态都会丢。一个省钱省力的技巧是始终使用同一个 Model 实例刷新时只修改 Model 内部数据和 emit dataChanged、layoutChanged然后调用视图的 reset() 时尽量不要整套重建。如果你必须在不同 Model 之间切换那就用 QTableView::setModel 外面包一层状态保留手动记录旧列宽并恢复。虽然麻烦但值得。为了方便排查我把常见问题整理成了一张速查表现象可能原因处理办法日期弹窗不显示没设置 setCalendarPopup(True)在 QDateEdit 编辑器里加日历弹窗设置数字列还能输入字母Validator 没设置或 Model 没校验createEditor 加 validatorsetData 再校验主从表保存后明细重复新增行没有正常更新主键状态保存后回填 ID 并 reload 明细外键字段为空主记录 lastInsertId 获取失败检查驱动支持使用 RETURNING 或事务内查询切换主表迟迟不刷新selectionModel 信号连接没断开确认当前使用 currentRowChanged 且选中索引合法主从表保存一半报错没有开启事务或事务提交失败后未回滚统一用 transaction/commit/rollback 包裹所有 SQL5.5 性能与交互细节表格量大的时候主从表界面最容易卡在“刷新明细时速度慢”。我这里有几个实操优化建议明细表用 QAbstractTableModel 代替 QStandardItemModel几百行数据下性能差距不明显但几千行时差异会拉开。不要在每次单元格数据变化时立刻请求数据库保存而是只在用户点击“保存”按钮时统一处理。不用的 Delegate 不要给每一列都装能用默认编辑器就用默认编辑器减少创建临时控件的开销。如果有好多列是只读的在 Model 的 flags 里返回不可编辑这样点击时不会触发编辑器创建视图也更轻快。我在实际项目里还发现一个细节主从表界面如果上方主表是多条记录用户第一反应是用键盘上下键切换单据这时 currentRowChanged 会触发明细刷新。如果刷新是同步且数据量大按一下箭头卡一秒体验非常差。可以把刷新改成异步加载用 QTimer.singleShot(0, ...) 延后执行或者在数据量特别大时先显示明细框的等待状态再在子线程里查询完回到主线程 setData。一点收尾经验我自己最早给一个物料盘点工具做主从表时犯过一个特别低级但特别典型的错误保存明细时每条明细都去查一次主表 ID。看起来逻辑没错但几百行明细提交时一次保存要执行几百条查询慢得业务人员直摇头。后来改成先保存主表拿到 ID 后所有明细共用这个 ID再配合事务保存时间从几十秒降到一秒内。做这类功能先想清楚数据怎么落库再回头做界面效率会高很多。如果你现在正准备开发 PySide/PyQt 的表格录入或主从表功能建议先把 Model、Delegate、事务这三块吃透剩下的就都是“拼细节”的工作了。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。