ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

PySide6表格多格式录入与主从表联动实现

2026/9/24 23:07:40 拓冰建站 浏览量
PySide6表格多格式录入与主从表联动实现 做桌面端业务工具的老哥应该都有同感表格控件是绕不开的核心组件但你很少能只靠一个QTableWidget走天下。至少在我最近做的进销存桌面工具里光表格录入就折腾了两个多星期——用户要求在一个表格里同时敲文本、数字、日期还得支持从Excel直接粘贴一批数据另一边入库单头部主表和明细行从表要联动显示保存时必须一块提交不能出现主表写进去了明细丢了的情况。这套功能在PySide6/PyQt6里实现起来并不难但细节很多网上碎片化的资料很难一次串起来。这个项目虽然是个进销存工具但思路可以直接搬到订单管理、工单记录、设备台账等几乎所有主表明细场景我把完整实现过程和踩过的坑整理出来给需要做同类功能的朋友一个参考。1. 多格式录入的边界先把需求拆成三件事1.1 先给表格录入分个类三种刚需场景动手之前必须先弄清楚多种格式录入在真实业务里到底指什么。以我做的入库登记界面为例细拆下来其实是三种很不一样的需求。第一种是手工逐行输入。操作员在表格里一行一行填这个场景对键盘友好度要求极高——光标跳转、回车换行、方向键移动都要顺手否则一天录几百行数据会累死。格式上要求数字列只能输数字日期列只能输合法日期枚举列要用下拉框而不是让用户自由发挥。第二种是批量粘贴。用户从Excel或者别的系统里复制一批数据直接CtrlV粘到表格里几行几十行都有。这种场景下粘贴内容的格式识别就成了关键——粘贴进来的是纯文本你得根据列类型自动把12345转成数字把2024-03-15转成日期把已入库匹配到枚举项的下标上。第三种是针对外键字段的选择式录入。比如供应商ID那一列用户不记得供应商编号传统做法是弹出一个选择窗口但效率太低。更好的方式是用下拉框或者自动补全输入框既能看到名称又能存入ID。这三种场景对UI层的要求完全不同如果一开始没拆清楚后面要么只做手工录入导致粘贴难受要么只做粘贴导致列类型乱套。我的经验是在设计阶段就把表格列类型配置表列出来比如列输入类型录入方式物料编码文本手工 粘贴物料名称文本手工数量整数手工 粘贴单价浮点手工 粘贴入库日期日期日期选择器 粘贴状态枚举下拉框供应商外键下拉框这样一列开发范围立刻清楚后面写Delegate的时候按这个配置表来就行。1.2 为什么QTableWidget裸奔撑不住这种需求如果只是显示数据QTableWidget的setItem加几个字符串确实够用了。但要做多格式录入裸用的短板马上暴露。第一个问题是格式丢失。QTableWidget的item本质上是字符串你把数字塞进去再拿出来就变成了str后面做排序、求和、格式化全都别扭。虽然setData可以塞自定义role但编辑器用的还是默认的QLineEdit用户照样能往数字列里输入abc。第二个问题是校验太被动。默认编辑器只有在视图提交数据时才会触发校验如果你在itemChanged信号里做校验等于事后再清掉非法数据体验很差还容易让用户以为数据已经生效了。第三个问题是粘贴行为不可控。QTableWidget默认粘贴是把单元格文本替换掉用户从Excel复制多行数据它不会按tab符拆列也不会按换行拆行体验一言难尽。所以最终结论很明确不要直接在QTableWidget上做文章老老实实用QTableView 自定义Delegate配合一个QAbstractTableModel子类或者QSqlTableModel所有格式层面的东西在Delegate的createEditor和setModelData里统一处理。2. 用Delegate做格式感知的单元格编辑器2.1 先从QStyledItemDelegate起步Qt里表格的编辑体系核心是Delegate委托它负责怎么编辑一个单元格。默认Delegate用的是QLineEdit对任何类型一视同仁。我们要做的就是继承QStyledItemDelegate按列类型返回不同的编辑器。这里要注意选QStyledItemDelegate而不是QItemDelegate。前者会自动根据EditRole的类型选择默认编辑器还支持样式表省很多事后者是纯手工画代码除非你要完全自绘否则完全没必要。我的Delegate骨架长这样from PySide6.QtWidgets import ( QStyledItemDelegate, QLineEdit, QSpinBox, QDoubleSpinBox, QDateEdit, QComboBox, ) from PySide6.QtCore import Qt, QDate class FormatDelegate(QStyledItemDelegate): column_types: {列索引: text/int/float/date/enum} enum_options: {列索引: [选项A, 选项B, ...]} def __init__(self, column_types, enum_optionsNone, parentNone): super().__init__(parent) self.column_types column_types self.enum_options enum_options or {} def createEditor(self, parent, option, index): col_type self.column_types.get(index.column(), text) if col_type int: editor QSpinBox(parent) editor.setRange(-999999999, 999999999) editor.setAlignment(Qt.AlignRight | Qt.AlignVCenter) return editor if col_type float: editor QDoubleSpinBox(parent) editor.setRange(-999999999.0, 999999999.0) editor.setDecimals(6) editor.setAlignment(Qt.AlignRight | Qt.AlignVCenter) return editor if col_type date: editor QDateEdit(parent) editor.setCalendarPopup(True) editor.setDisplayFormat(yyyy-MM-dd) return editor if col_type enum and index.column() in self.enum_options: editor QComboBox(parent) editor.addItems(self.enum_options[index.column()]) return editor editor QLineEdit(parent) editor.setFrame(False) return editor def setEditorData(self, editor, index): value index.data(Qt.EditRole) if isinstance(editor, QSpinBox): editor.setValue(int(value or 0)) elif isinstance(editor, QDoubleSpinBox): editor.setValue(float(value or 0.0)) elif isinstance(editor, QDateEdit): if isinstance(value, QDate): editor.setDate(value) else: parsed QDate.fromString(str(value), yyyy-MM-dd) if not parsed.isValid(): parsed QDate.fromString(str(value), yyyy/M/d) editor.setDate(parsed if parsed.isValid() else QDate.currentDate()) elif isinstance(editor, QComboBox): text str(value or ) idx editor.findText(text) editor.setCurrentIndex(idx if idx 0 else 0) else: editor.setText(str(value) if value is not None else ) def setModelData(self, editor, model, index): if isinstance(editor, QSpinBox): model.setData(index, editor.value()) elif isinstance(editor, QDoubleSpinBox): model.setData(index, editor.value()) elif isinstance(editor, QDateEdit): model.setData(index, editor.date().toString(yyyy-MM-dd)) elif isinstance(editor, QComboBox): model.setData(index, editor.currentText()) else: model.setData(index, editor.text())几个容易忽略的细节QSpinBox默认最大只有99不设置range数量列的9999就输入不进去这种坑最容易在测试阶段才暴露。QDoubleSpinBox默认精确到2位小数但业务上单价经常出现4位甚至6位务必setDecimals设置到位。QDateEdit的setEditorData里模型里存的是字符串还是QDate要提前约定好我在项目里统一存ISO格式字符串因为要进数据库不想在模型和数据库之间频繁转换。这里没有重写updateEditorGeometry因为默认行为是把编辑器套进单元格矩形对QComboBox和QDateEdit已经很友好。只有QLineEdit设了setFrame(False)否则编辑时边框会和单元格叠加显得很突兀。2.2 把Delegate装到视图上Delegate写好后安装到QTableView上column_types { 0: text, 1: text, 2: int, 3: float, 4: date, 5: enum, 6: enum, } enum_options { 5: [待处理, 已入库, 已出库, 已作废], 6: [供应商A, 供应商B, 供应商C], } delegate FormatDelegate(column_types, enum_options) table_view.setItemDelegate(delegate)这里有个选择setItemDelegate是全表统一Delegate内部再按列分发也可以setItemDelegateForColumn给每一列设独立Delegate。我用统一Delegate是因为很多公共逻辑可以集中在一起比如后面要加的悬停提示、错误标记都能在一处处理。手工逐行录入的交互细节也要配合上from PySide6.QtWidgets import QAbstractItemView table_view.setEditTriggers( QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed | QAbstractItemView.AnyKeyPressed ) table_view.setSelectionBehavior(QAbstractItemView.SelectItems) table_view.setTabKeyNavigation(True)AnyKeyPressed的意思是直接敲一个字符就立刻进入编辑状态这对快速录入非常重要。Tab键导航要打开用户填完一个格子按Tab就能跑到下一个格整行录完回车自动进入下一行同列这在Qt里是默认行为但前提是别把TabKeyNavigation关掉。2.3 从Excel批量粘贴时的格式识别批量粘贴是另一个大头。我在视图上覆写了keyPressEvent拦截CtrlV走自己写的粘贴逻辑。核心思路是解析剪贴板文本按行拆成二维数组然后从当前单元格开始填充每列按column_types做类型转换遇到转换失败就放弃整行并提示用户。import re from PySide6.QtCore import QDate from PySide6.QtWidgets import QApplication, QMessageBox def parse_paste_text(text, column_type): 把剪贴板字符串按目标列类型转换成模型能接受的值。 转换失败返回 None由调用方决定怎么处理。 text text.strip() if column_type int: text_clean text.replace(,, ).replace(, ) return int(text_clean) if re.fullmatch(r-?\d, text_clean) else None if column_type float: text_clean text.replace(,, ).replace(, ) try: return float(text_clean) except ValueError: return None if column_type date: for fmt in (yyyy-MM-dd, yyyy/M/d, yyyy年M月d日): d QDate.fromString(text, fmt) if d.isValid(): return d.toString(yyyy-MM-dd) return None if column_type enum: return text return text def paste_from_clipboard(view, model, delegate): clipboard QApplication.clipboard() raw clipboard.text() if not raw.strip(): return lines [ln for ln in raw.splitlines() if ln.strip()] target_row view.currentIndex().row() target_col view.currentIndex().column() column_types delegate.column_types error_rows [] for row_offset, line in enumerate(lines): cells line.split(\t) for col_offset, cell in enumerate(cells): row target_row row_offset col target_col col_offset if row model.rowCount() or col model.columnCount(): continue col_type column_types.get(col, text) converted parse_paste_text(cell, col_type) if converted is None: error_rows.append(row_offset 1) break model.setData(model.index(row, col), converted, Qt.EditRole) if error_rows: QMessageBox.warning( view, 粘贴格式提示, f第 {, .join(str(r) for r in sorted(set(error_rows)))} 行存在格式错误已跳过。 )这段逻辑里有个细节转换失败时是全行跳过而不是逐格跳过。原因是业务上复制过来的一行是一个整体如果只塞半行不塞半行保存时做完整性校验会很痛苦。宁可让用户看到提示后去补那一行也不要留下脏数据。另外如果用户粘贴的目标区域已有数据我是默认覆盖。如果希望改成插入新行逻辑上也不难在循环前先model.insertRows(target_row, len(lines))即可但要注意行号重新计算。3. 主从表联动显示、选中、刷新三件事3.1 数据层用两个模型实例而不是一个主从表在业务层是主表一行 从表多行的关系。比如入库单头有一条记录单号、供应商、日期明细表里对应多行物料、数量、单价。在Qt的模型视图体系里我建议直接拆成两个独立的模型实例分别驱动两个QTableView而不是试图把主从关系揉进一个模型。为什么不用一个模型因为主表和从表的行数、列结构完全不一样强行合并会让模型代码充满行偏移计算维护成本直线上升。而拆开之后两者通过一个共同的当前主表ID来协作逻辑非常清晰。如果你用的是数据库可以直接继承QSqlTableModelfrom PySide6.QtSql import QSqlTableModel from PySide6.QtCore import Qt class MasterModel(QSqlTableModel): def __init__(self, parentNone): super().__init__(parent) self.setTable(stock_in_header) self.setEditStrategy(QSqlTableModel.OnManualSubmit) self.setHeaderData(0, Qt.Horizontal, 单号) self.setHeaderData(1, Qt.Horizontal, 供应商) self.setHeaderData(2, Qt.Horizontal, 入库日期) class DetailModel(QSqlTableModel): def __init__(self, master_id, parentNone): super().__init__(parent) self.setTable(stock_in_detail) self.setEditStrategy(QSqlTableModel.OnManualSubmit) self.master_id master_id self.setFilter(fheader_id {master_id}) def reload(self, master_id): self.master_id master_id self.setFilter(fheader_id {master_id}) self.select()这里的关键是setEditStrategy(QSqlTableModel.OnManualSubmit)。这意味着所有编辑先缓存在内存里只有调用submitAll()才会真正写库。这个策略和后面的事务保存是绝配——你先改一堆最后统一submitAll配合database.transaction()保证要么全成要么全败。3.2 主表选中行变化时刷新从表主从表联动的核心信号是从表视图的selectionModel发出。当你点击主表某一行需要取出这一行的主键然后让从表模型reload出对应的明细。self.master_view.selectionModel().currentRowChanged.connect( self.on_master_row_changed ) def on_master_row_changed(self, current, previous): if not current.isValid(): self.detail_model.reload(-1) # 没有有效主键时让从表变空 return master_id self.master_model.data( self.master_model.index(current.row(), 0) ) self.detail_model.reload(master_id)这里要注意currentRowChanged信号在模型调用reset或者view刷新时会变得很敏感。QSqlTableModel每次select()之后当前行索引会失效可能触发一堆中间状态。所以我在reload里加了一个防御当传入的master_id是一个非法值时直接清空从表避免界面出现上一单的明细挂着不动的假象。从表的刷新有两种做法setFilter select让数据库帮忙过滤或者手动清空重查。如果数据量不大几千行以内setFilter的重查速度完全可接受。如果明细表能到几万行就该考虑在UI层面做分页或者改用自定义模型加按主键批量加载这种优化后面单独说。3.3 要不要用QDataWidgetMapper很多教程喜欢用QDataWidgetMapper把主表字段映射到表单输入框。但我这次的需求里主表本身也是表格副表也是表格两个表格联动就够了表单反而多余。所以我没有硬套QDataWidgetMapper。如果你的场景是左边主表表格右边上部主表单据右边下部明细表格那可以考虑给主表字段用QDataWidgetMapper给明细用表格。两类控件一起工作也没问题只要注意两点QDataWidgetMapper的模型要绑到和表格同一个模型实例model.setData才能同步。mapper的当前索引要和表格当前行绑定推荐直接用currentRowChanged信号驱动mapper.setCurrentIndex。4. 主从表保存事务边界和入库顺序4.1 保存必须包在一个事务里保存主从表最怕一种状态主表提交成功从表因为外键错误或者某个字段非法提交失败。一旦出现这种状态库里的孤儿明细会让业务对账变得非常麻烦。因此保存动作必须包进一个数据库事务里。Qt这边可以这样from PySide6.QtSql import QSqlDatabase class MasterDetailForm(QWidget): def save_all(self): db QSqlDatabase.database() if not db.transaction(): QMessageBox.critical(self, 错误, 无法开启事务) return False try: # 1) 处理主表的新增/修改 if not self.master_model.submitAll(): raise RuntimeError(self.master_model.lastError().text()) # 2) 拿到当前主表主键 current self.master_view.currentIndex() if not current.isValid(): raise RuntimeError(请先选择或新增一条主表记录) master_id self.master_model.data( self.master_model.index(current.row(), 0) ) # 3) 把主键回填到从表的外键列 for row in range(self.detail_model.rowCount()): self.detail_model.setData( self.detail_model.index(row, self.detail_fk_column), master_id ) # 4) 保存从表 if not self.detail_model.submitAll(): raise RuntimeError(self.detail_model.lastError().text()) db.commit() return True except Exception as exc: db.rollback() QMessageBox.critical(self, 保存失败, str(exc)) return False这段代码有两点体现了主从表保存的核心逻辑提交顺序必须是先主后从。从表的外键要拿主表的ID做基础反过来就卡死了。第三步在内存里把所有从表行的外键刷一遍再一次性submitAll。这样既保证了外键正确又减少了数据库写入次数。4.2 新增主表时外键等待问题如果用户点新增在主表里输入了数据但还没点保存这时从表明细怎么挂因为此时主表还没有数据库主键。我试过两种方案。第一种是用负ID临时代替新增主表时给一个负数临时主键例如-1从表外键也全部填-1。保存时先处理主表拿到真实自增ID再统一回填从表的外键然后提交。第二种是先用事务插入主表点新增时立刻把主表空行插入数据库这时候事务还没提交拿到自增ID后再绑定从表。缺点是一旦用户最后取消需要回滚或删除。在桌面工具的场景下我最终选了第一种方案理由很直接用户可能新增了一半又后悔方案一只需要退出编辑状态时把内存里的临时行清掉数据库完全没有垃圾。方案二如果用户在编辑过程中程序崩溃数据库里可能留下一堆半成品主表行这是我不能接受的。方案一的实现核心是在主表模型上设置一个默认临时主键列def add_master_row(self): row self.master_model.rowCount() self.master_model.insertRow(row) self.master_model.setData( self.master_model.index(row, 0), -1 # 临时主键 ) # 从表的临时外键也设成 -1 self.detail_model.reload(-1)保存时临时主键会被数据库的自增主键覆盖。我用的SQLite和PostgreSQL都支持自增主键但QSqlTableModel不直接暴露新插入行的ID我一般做法是事务开始后先submitAll主表再单独查一下自增IDquery QSqlQuery(db) query.exec(SELECT last_insert_rowid()) # SQLite 专用如果是PostgreSQL用SELECT LASTVAL()MySQL用SELECT LAST_INSERT_ID()。每种数据库写法不同所以这个逻辑用一个小函数做适配。4.3 保存前的行级整体校验在submitAll之前还应该做一遍行级校验不然一行错误数据会把整个事务拖垮。我的做法是遍历两个模型的每一行检查必填列是不是空、外键列是否有效。这个校验也分两层模型层的setData里做一次基础校验比如数量不能小于0。保存前在save_all里做一次整体校验把所有错误的行和列收集起来弹一个汇总框告诉用户哪里不对。第二层校验特别重要因为Delegate只负责单个编辑器的合法性但表格整体有没有漏填用户是不是压根没点任何单元格就点了保存只有整体遍历才知道。def validate_row(model, row, required_cols): for col in required_cols: value model.data(model.index(row, col)) if value is None or str(value).strip() : return False, f第 {row 1} 行第 {col 1} 列为空 return True, 5. 实测环境里容易踩的五个坑5.1 Delegate编辑器还没提交就点了保存这是一个非常典型的时序坑。用户在某单元格里输入完日期没有回车、没有失焦直接点界面上的保存按钮。此时编辑器还开着model里的值还是旧的你调用submitAll时数据根本没有进入模型导致用户明明改了但库里面没有反映。解决办法是在任何外部动作触发保存前强制让表格先处理掉正在编辑的单元格def flush_pending_edit(view): if view.state() QAbstractItemView.EditingState: view.commitData(view.currentIndex())这里commitData会触发Delegate的setModelData把编辑器里的值写回模型。如果你用QAbstractItemView.persistentEditor或者closePersistentEditor也要一并调用closePersistentEditor因为closePersistentEditor只负责关编辑器commitData负责把值写回模型两个动作都要发生。5.2 QDoubleSpinBox小数位数悄悄坑人QDoubleSpinBox默认decimals是2。业务里如果单价要精确到小数点后4位用户在界面上看着明明是1.2345但数据库里存成了1.23——不是程序没保存是编辑器截断的。排查这类问题最快的方法是看setEditorData和setModelData有没有显式设置decimals。我在Delegate初始化时统一按列设置if col_type float: editor QDoubleSpinBox(parent) editor.setDecimals(6)宁可保留6位小数数据库里再按round处理也不在界面上偷偷截断。5.3 日期格式里locale和ISO格式混用用户可能习惯2024-03-15也可能习惯2024/03/15。Qt里QDateEdit的displayFormat决定了显示格式不用管locale也能显示。但如果你把日期字符串直接塞给SQL去比较SQLite还好PostgreSQL和MySQL对yyyy/MM/dd这种格式也能认但一旦混进去15/03/2024这种日在前格式就很容易出现隐蔽的排序和比较错误。我的统一规则是模型和数据库里始终存ISO 8601格式yyyy-MM-dd只在显示层按用户的偏好格式展示。这样最省事也最不容易出幺蛾子。5.4 从表刷新后滚动位置丢失刷新从表数据后QTableView的滚动条通常回到顶部如果用户正在看第几十行明细这个跳动体验很烦躁。解决方法是保存当前滚动位置在reload之后恢复scroll_pos self.detail_view.verticalScrollBar().value() self.detail_model.reload(master_id) self.detail_view.verticalScrollBar().setValue(scroll_pos)这招简单但是非常实用尤其是明细表几百行的时候用户体验差异特别明显。5.5 大量粘贴时主界面卡死一次粘贴200行数据如果每一格都走createEditor setModelData再加上信号满天飞界面肯定卡一段时间严重的会像卡死一样。我的做法是粘贴期间用信号块from PySide6.QtCore import QSignalBlocker blocker QSignalBlocker(self.detail_model) for row_offset, line in enumerate(lines): # 数据填充或者更干脆一点把整个QTableView的updatesEnabled关掉self.detail_view.setUpdatesEnabled(False) # 写入逻辑 self.detail_view.setUpdatesEnabled(True)粘贴之后只更新一次界面。如果数据量非常大可以在模型里加一个批量beginInsertRows加endInsertRows但QSqlTableModel不直接给你这种接口我的实践经验是200行以内用模型提供的setData就足够快超过200行建议考虑换成自定义模型加一次批量提交。实测下来表格多格式录入和主从表联动这套组合核心难点不在单个控件而在把编辑、校验、联动、落库这四件事串成一条稳定的链路。先把列类型配置表定清楚再让Delegate、粘贴逻辑、事务保存都围绕这张表工作整个模块会清晰很多。等哪天需求变成从表也要支持子表三层联动这套思路依然能往上叠只是把当前主表ID换成当前从表ID而已。