
mainwindow8.cpp 是一个 QTableView 插件化 demo。它不是简单地在窗口里堆一个 QTableView,然后手动往 QStandardItemModel 里塞数据,而是把表格能力抽象成一个独立插件:MenuPluginTableViewWidget。
这个 demo 的核心目标很明确:
表格插件负责通用能力,比如显示、筛选、拖拽、排序、冻结列、选择状态。 业务代码只负责准备数据,以及告诉插件“每一行应该怎么变成表格单元格”。 数据通过 QVariant承载,插件不绑定具体业务结构。行内容通过 Adapter 生成, buildRowItems可以由使用者自定义。
这套设计和 Android 里的 ListView + Adapter、RecyclerView + Adapter 思路很像:控件本身只管展示和交互,具体数据长什么样、每一行怎么画,由外部适配器决定。
效果:

一、为什么要把 QTableView 抽成插件
直接在窗口代码里写 QTableView,最常见的写法大概是这样:
auto *model = new QStandardItemModel(this);model->setHorizontalHeaderLabels({"编号", "名称", "状态"});model->appendRow({new QStandardItem("TC-001"),new QStandardItem("登录"),new QStandardItem("已完成")});auto *tableView = new QTableView(this);tableView->setModel(model);
这种写法在小 demo 里没问题,但工程里很快会遇到几个问题:
每个页面都要重复写表格初始化代码。 每个页面都要重复处理筛选、拖拽、表头、列宽、行高、冻结列。 表格和业务数据耦合很重,换一种数据结构就要改一堆表格逻辑。 想复用时很困难,因为代码散落在 MainWindow里。
所以 mainwindow8.cpp 的重构方向是:把表格通用能力放进 menuplugintableviewwidget,业务 demo 只负责配置它。
最终结构可以理解成三层:
MainWindow8负责 demo 页面、按钮、配置项、业务数据 DemoTableRowDataMenuPluginVariantTableViewAdapter负责把 QVariant 数据转成 QStandardItem 列表MenuPluginTableViewWidget负责 QTableView、QStandardItemModel、筛选、拖拽、冻结列、信号
这样一来,后面如果有另一个页面也要用 QTableView,不需要复制 mainwindow8.cpp 的整套逻辑,只要新建一个 adapter 配置即可。
mainwindow8.cpp 的整套逻辑,只要新建一个 adapter 配置即可。二、整体设计:插件不认识业务数据,只认识 QVariant
mainwindow8.cpp 里定义了一个 demo 用的数据结构:
struct DemoTableRowData{QString id;QString title;QString category;QString status;int score = 0;QString owner;QString updated;QString note;bool checked = false;};
这是业务数据。插件本身不应该依赖它,否则插件就只能服务这个 demo。
所以代码里使用了:
Q_DECLARE_METATYPE(DemoTableRowData)
以及:
rows.append(QVariant::fromValue(buildRowData(i)));这一步很关键。
QVariant 可以理解成 Qt 里的“通用盒子”。不同业务页面可以把自己的结构体、字符串、数字、对象包装进 QVariant,然后交给插件。插件不关心盒子里具体是什么,只把它传给 adapter。
真正知道 QVariant 里面是什么的人,是业务侧配置的 adapter。
数据流大概是这样:
DemoTableRowData↓ QVariant::fromValue(...)QVariant↓ adapter->createRowItems(...)QList<QStandardItem *>↓ QStandardItemModelQTableView 显示
这就是 QVariant + Adapter 的核心价值:插件负责通用表格,业务负责解释数据。
三、Adapter 接口:插件和业务之间的边界
MenuPluginTableViewAdapter 是一个抽象接口,它规定了插件需要向业务侧询问哪些信息。
核心接口包括:
class MenuPluginTableViewAdapter{public:virtualintrowCount() const = 0;virtual QVariant rowAt(int row) const = 0;virtualintcolumnCount() const = 0;virtual QStringList headerLabels() const = 0;virtual QList<QStandardItem *> createRowItems(const QVariant &rowData, int rowIndex) const = 0;virtual QStringList filterTexts(const QVariant &rowData, int rowIndex) const;virtualboolmoveRow(intfrom, int to);virtualboolmoveCell(int fromRow, int fromColumn, int toRow, int toColumn);};
这里最重要的是 createRowItems。
插件在重建表格时,会遍历每一行数据:
const QVariant payload = m_adapter->rowAt(row);QList<QStandardItem *> items = m_adapter->createRowItems(payload, row);
也就是说,插件并不自己决定单元格长什么样。它只问 adapter:
“这条 QVariant 数据应该生成哪些 QStandardItem?”
Adapter 返回以后,插件再把这些 item 放进 QStandardItemModel,最终由 QTableView 显示。
可以,而且这个 demo 已经按“可自定义”的方式实现了。
在 MenuPluginVariantTableViewAdapter 里有一个配置入口:
using RowItemsBuilder = std::function<QList<QStandardItem *>(const QVariant &rowData, int rowIndex)>;voidsetRowItemsBuilder(const RowItemsBuilder &builder);
m_tableAdapter->setRowItemsBuilder([](const QVariant &item, int) {return buildDemoRowItems(item.value<DemoTableRowData>());});
这意味着 buildRowItems 不是写死在插件里的。
默认情况下,如果没有设置 setRowItemsBuilder,adapter 可以根据列值解析器走一套通用逻辑。但只要业务传入了 RowItemsBuilder,插件就会使用业务自己的行构建函数。
这也是这个插件最重要的扩展点。
比如 demo 里的 buildDemoRowItems 做了这些事:
auto *idItem = new QStandardItem(rowData.id);idItem->setEditable(false);idItem->setTextAlignment(Qt::AlignCenter);auto *statusItem = new QStandardItem(rowData.status);statusItem->setEditable(true);statusItem->setCheckable(true);statusItem->setCheckState(rowData.checked ? Qt::Checked : Qt::Unchecked);auto *scoreItem = new QStandardItem(QString::number(rowData.score));scoreItem->setData(rowData.score, Qt::EditRole);scoreItem->setTextAlignment(Qt::AlignCenter);
这里可以看到,业务不只是设置文本,还可以设置:
是否可编辑。 是否居中。 是否带勾选框。 DisplayRole和 EditRole的数据。tooltip。 后续也可以扩展字体、颜色、图标、自定义 role。
所以它不是简单的“字符串表格”,而是把 QTableView 的 item 能力暴露给业务侧。
Qt 里有两个常见表格控件:
QTableWidget使用简单,适合小型、固定结构表格QTableView更偏 Model/View,适合工程化、复用、扩展
这个 demo 选择 QTableView + QStandardItemModel,主要原因是:
插件可以统一维护 model。 后续可以替换成自定义 model。 更适合做筛选、排序、冻结列、拖拽等高级能力。 Adapter 抽象更自然。
QTableWidget 本质上是更方便的封装,但当项目需要长期维护和复用时,QTableView 更适合做插件底座。
MainWindow8 不再直接承载所有表格逻辑,它主要做四件事。
第一,创建业务数据:
QList<QVariant> buildDefaultRows(){QList<QVariant> rows;rows.reserve(14);for (int i = 1; i <= 14; ++i) {rows.append(QVariant::fromValue(buildRowData(i)));}return rows;}
m_tableAdapter->setHeaderLabels(m_baseHeaderLabels);m_tableAdapter->setFilterTextResolver(...);m_tableAdapter->setRowItemsBuilder(...);m_tableAdapter->setCellMoveHandler(...);
m_tablePluginWidget->setAdapter(m_tableAdapter);tableView() 获取原生 QTableView,继续配置 Qt 自带能力:m_tableView = m_tablePluginWidget->tableView();m_model = m_tablePluginWidget->model();m_tableView->setSortingEnabled(true);m_tableView->setAlternatingRowColors(true);m_tableView->setShowGrid(true);m_tableView->verticalHeader()->setVisible(false);
QTableView 做细节配置。
class TagListDelegate : public QStyledItemDelegate{public:explicitTagListDelegate(QObject *parent = nullptr): QStyledItemDelegate(parent){}voidpaint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index)constoverride{const QList<TagInfo> tags = index.data(kTagListRole).value<QList<TagInfo>>();if (tags.isEmpty()) {QStyledItemDelegate::paint(painter, option, index);return;}QStyleOptionViewItem opt(option);initStyleOption(&opt, index);opt.text.clear();QStyle *style = opt.widget ? opt.widget->style() : QApplication::style();style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, opt.widget);painter->save();painter->setRenderHint(QPainter::Antialiasing, true);const QRect contentRect = option.rect.adjusted(8, 5, -8, -5);const QFontMetrics metrics(option.font);const int tagHeight = qMin(22, qMax(18, contentRect.height()));int x = contentRect.left();const int y = contentRect.top() + (contentRect.height() - tagHeight) / 2;for (int i = 0; i < tags.size(); ++i) {const TagInfo &tag = tags.at(i);const int tagWidth = metrics.horizontalAdvance(tag.text) + 18;if (x + tagWidth > contentRect.right()) {const QString moreText = QStringLiteral("+%1").arg(tags.size() - i);const int moreWidth = metrics.horizontalAdvance(moreText) + 16;if (x + moreWidth <= contentRect.right()) {const QRect moreRect(x, y, moreWidth, tagHeight);painter->setPen(Qt::NoPen);painter->setBrush(QColor(QStringLiteral("#CBD5E1")));painter->drawRoundedRect(moreRect, 8, 8);painter->setPen(QColor(QStringLiteral("#334155")));painter->drawText(moreRect, Qt::AlignCenter, moreText);}break;}const QRect tagRect(x, y, tagWidth, tagHeight);painter->setPen(Qt::NoPen);painter->setBrush(tag.backgroundColor);painter->drawRoundedRect(tagRect, 8, 8);painter->setPen(tag.textColor);painter->drawText(tagRect.adjusted(9, 0, -9, 0), Qt::AlignCenter, tag.text);x += tagWidth + 6;}painter->restore();}QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index)constoverride{QSize size = QStyledItemDelegate::sizeHint(option, index);size.setHeight(qMax(size.height(), 32));return size;}};
m_tableView->setItemDelegateForColumn(kTagsColumn, new TagListDelegate(m_tableView));筛选逻辑也没有写死。
插件只保存一个关键词,然后逐行判断这一行是否匹配:
QStringList filterTexts(const QVariant &rowData, int rowIndex) const;业务侧通过 setFilterTextResolver 告诉插件:这条数据有哪些文本可以参与搜索。
demo 里大概是这样:
m_tableAdapter->setFilterTextResolver([](const QVariant &item, int) {const DemoTableRowData rowData = item.value<DemoTableRowData>();return QStringList{rowData.id,rowData.title,rowData.category,rowData.status,rowData.owner,rowData.updated,rowData.note};});
插件拿到这些文本以后,把它们拼起来和搜索关键词比较。
这比在插件里写死“搜索第 1 列、第 2 列、第 3 列”更灵活。不同业务页面可以决定不同字段是否参与搜索。

QTableView 自带拖拽能力,但实际项目里常常需要更明确的业务规则。
这个插件里做了两类拖拽:
按行拖动调用 adapter->moveRow(from, to)按单元格拖动调用 adapter->moveCell(fromRow, fromColumn, toRow, toColumn)
判断方式和 QTableView 的选择行为有关。
如果当前是行选择模式,拖动就是行移动。
如果当前是单元格选择模式,拖动就是单元格移动。
demo 里的单元格拖动规则是:只允许同一列内交换。
bool swapDemoCellInSameColumn(QList<QVariant> &rows,int fromRow,int fromColumn,int toRow,int toColumn){if (fromColumn != toColumn || fromRow == toRow) {return false;}DemoTableRowData source = rows.at(fromRow).value<DemoTableRowData>();DemoTableRowData target = rows.at(toRow).value<DemoTableRowData>();const QVariant sourceValue = demoCellValue(source, fromColumn);const QVariant targetValue = demoCellValue(target, toColumn);setDemoCellValue(source, fromColumn, targetValue);setDemoCellValue(target, toColumn, sourceValue);rows[fromRow] = QVariant::fromValue(source);rows[toRow] = QVariant::fromValue(target);return true;}
为什么只允许同列交换?
因为不同列代表的数据类型可能不同。比如分数列是数字,负责人列是字符串,日期列是日期。如果允许跨列随便拖,业务数据会变脏。
所以插件只提供 moveCell 扩展点,具体能不能移动、移动后怎么改数据,由业务 adapter 决定。
这就是 adapter 设计的价值:插件不替业务做错误决定。

用户提到一个典型需求:表格列很多时会出现横向滚动条,希望某几列冻结在左侧。
这个插件支持多列冻结:
voidsetFrozenColumnCount(int count);intfrozenColumnCount() const;
m_tablePluginWidget->setFrozenColumnCount(m_frozenColumnSpinBox->value());主 QTableView显示完整表格,负责正常滚动冻结 QTableView叠在主表格左侧,只显示前 N 列
m_tableView->setModel(m_model);m_frozenTableView->setModel(m_model);
m_frozenTableView->setSelectionModel(m_tableView->selectionModel());这样用户选中某一行或某个单元格时,两个表格的选中状态是一致的。
冻结表格还会同步主表格的垂直滚动:
connect(m_tableView->verticalScrollBar(), &QScrollBar::valueChanged,m_frozenTableView->verticalScrollBar(), &QScrollBar::setValue);
updateFrozenColumns();updateFrozenGeometry();syncFrozenRowHeights();
这个方案的优点是实现清晰,不需要重写复杂的 QTableView 绘制逻辑。
需要注意的是,冻结列不应该单独变色。当前设计中冻结表格同步主表格 palette,不额外设置背景样式:
m_frozenTableView->setPalette(m_tableView->palette());m_frozenTableView->viewport()->setPalette(m_tableView->viewport()->palette());
1234...
这不是业务列,而是 Qt 的 row header。
demo 中已经默认隐藏它:
m_tableView->verticalHeader()->setVisible(false);
QTableView 开启排序以后,Qt 默认会在表头显示排序指示器。
问题是:默认排序箭头会占用表头内部空间。当点击某一列时,这一列的文字可能会轻微左移;点击其他列后,原来的列又恢复居中。
这就是用户看到的“点击模块列,模块文字向左移动一点”的原因。
demo 的处理方式是:关闭 Qt 默认排序指示器。
m_tableView->horizontalHeader()->setSortIndicatorShown(false);void MainWindow8::updateSortHeaderLabels(int sortColumn, Qt::SortOrder order){QStringList labels = m_baseHeaderLabels;if (m_sortIndicatorCheckBox->isChecked()&& sortColumn >= 0&& sortColumn < labels.size()) {labels[sortColumn] += (order == Qt::AscendingOrder)? QStringLiteral(" ↑"): QStringLiteral(" ↓");}m_model->setHorizontalHeaderLabels(labels);}
这样箭头是文字的一部分,不再由 QHeaderView 单独绘制,表头布局就稳定了。
顺序和倒序可以通俗理解为:
↑ 顺序 / 升序从小到大,例如 1、2、3从 A 到 Z从较早日期到较晚日期↓ 倒序 / 降序从大到小,例如 3、2、1从 Z 到 A从较晚日期到较早日期
▲ / ▼。MenuPluginTableViewWidget 对外提供了一组常用接口:voidsetAdapter(MenuPluginTableViewAdapter *adapter);MenuPluginTableViewAdapter *adapter() const;QTableView *tableView() const;QStandardItemModel *model() const;voidsetFrozenColumnCount(int count);intfrozenColumnCount() const;voidrebuild();voidsetFilterKeyword(const QString &keyword);QString filterKeyword() const;QVariant rowData(const QModelIndex &index) const;QList<QVariant> rows() const;
这里有两个接口特别重要。
第一个是 tableView()。
它允许外部继续使用 QTableView 原生能力,比如排序、选择模式、列宽策略、拖拽模式。
第二个是 model()。
它允许外部在必要时读取或调整 QStandardItemModel,比如设置表头、span、item role。
这两个接口让插件既封装了通用逻辑,又没有把底层能力完全封死。
signals:voidcellClicked(const QModelIndex &index, const QVariant &rowData);voidcellDoubleClicked(const QModelIndex &index, const QVariant &rowData);voidselectionChanged(int selectedRows, int selectedCells);voidrowsReordered(const QList<QVariant> &rows);voidcellsMoved(const QList<QVariant> &rows);
connect(m_tablePluginWidget, &MenuPluginTableViewWidget::cellsMoved,this, [this](const QList<QVariant> &rows) {// 保存拖拽后的数据});
这里仍然保持了 QVariant 的抽象。
插件不会把数据强转成 DemoTableRowData,因为它不应该知道业务数据类型。
如果另一个页面也要用这个表格插件,大致步骤如下。
第一步,定义自己的业务数据:
struct UserRow{QString name;int age;QString role;};Q_DECLARE_METATYPE(UserRow)
QList<QVariant> rows;rows.append(QVariant::fromValue(UserRow{"张三", 28, "管理员"}));rows.append(QVariant::fromValue(UserRow{"李四", 31, "普通用户"}));
auto *adapter = new MenuPluginVariantTableViewAdapter;adapter->setHeaderLabels({"姓名", "年龄", "角色"});adapter->setRows(rows);
adapter->setRowItemsBuilder([](const QVariant &item, int) {const UserRow row = item.value<UserRow>();auto *nameItem = new QStandardItem(row.name);auto *ageItem = new QStandardItem(QString::number(row.age));ageItem->setData(row.age, Qt::EditRole);ageItem->setTextAlignment(Qt::AlignCenter);auto *roleItem = new QStandardItem(row.role);return QList<QStandardItem *>{nameItem, ageItem, roleItem};});
auto *tablePlugin = new MenuPluginTableViewWidget(this);tablePlugin->setAdapter(adapter);tablePlugin->rebuild();
tablePlugin->setFrozenColumnCount(1);这样就完成了一个新的业务表格。
不需要复制 mainwindow8.cpp 里的筛选、冻结列、拖拽、信号处理底层逻辑。
这个 demo 的设计有几个明显优点。
第一,业务数据和表格控件解耦。
插件不认识 DemoTableRowData,只认识 QVariant。换一个业务结构体,插件不用改。
第二,行内容可以完全自定义。
通过 setRowItemsBuilder,用户可以自己决定每一列用什么 QStandardItem,也可以设置编辑、对齐、勾选、tooltip、role。
第三,通用能力可以复用。
冻结列、筛选、拖拽、选择变化、点击信号都在插件里,多个页面可以共用。
第四,扩展点清晰。
行移动走 moveRow,单元格移动走 moveCell,筛选文本走 filterTexts,行构建走 createRowItems。
第五,没有破坏 Qt 原生能力。
外部仍然能通过 tableView() 和 model() 访问原生 QTableView / QStandardItemModel。
Q_DECLARE_METATYPE(MyRowData)qRegisterMetaType<MyRowData>("MyRowData");第二,createRowItems 里 new 出来的 QStandardItem 交给 model 管理,不要手动 delete。
第三,单元格拖拽不要盲目允许跨列移动。
不同列的数据含义不同,跨列移动很容易破坏业务数据。推荐像 demo 一样,由 setCellMoveHandler 自己判断是否合法。
第四,冻结列是叠加视图方案。
它实现简单、效果稳定,但要注意同步行高、列宽、隐藏行、表头显示状态。
第五,排序箭头如果使用 Qt 默认指示器,表头文字可能会轻微移动。
当前 demo 用“修改表头文本”的方式显示 ↑ / ↓,就是为了解决这个视觉跳动。
mainwindow8.cpp 这个 demo 的重点不是 QTableView 某一个 API,而是一个可复用表格插件的设计方式。
它把表格拆成了三部分:
数据DemoTableRowData,或者其他业务结构体适配器MenuPluginTableViewAdapter / MenuPluginVariantTableViewAdapter视图插件MenuPluginTableViewWidget
其中最关键的是 QVariant + Adapter。
QVariant 负责让插件不依赖具体业务类型,Adapter 负责把业务数据转成表格可显示的 QStandardItem。
所以 buildRowItems 不但可以自定义,而且应该作为这个插件最核心的扩展点来使用。
当后续项目中再出现新的表格页面时,不需要重新写一套 QTableView 逻辑,只需要:
定义业务数据。 包装成 QVariant。 配置 adapter。 自定义 setRowItemsBuilder。把 adapter 设置给 MenuPluginTableViewWidget。
这样表格插件就可以真正做到复用,而不是只服务某一个 demo 页面。
最后源码:
mainwindow8.h
#pragma once#include <QList>#include <QMainWindow>#include <QVariant>classQCheckBox;classQComboBox;classQDateEdit;classQLabel;classMenuPluginTableViewWidget;classMenuPluginVariantTableViewAdapter;classQLineEdit;classQPushButton;classQSpinBox;classQStandardItemModel;classQTableView;classMainWindow8 : publicQMainWindow{public:explicit MainWindow8(QWidget *parent = nullptr);private:void buildUi();void buildControlPanel(QWidget *parent);void buildTablePanel(QWidget *parent);void populateModel();void syncAdapterRows(const QList<QVariant> &rows);void appendDemoRow(int rowIndex);void applyViewSettings();void applyFilter();void updateStatusText(const QString &prefix = QString());void updateSortHeaderLabels(int sortColumn = -1, Qt::SortOrder order = Qt::AscendingOrder);static QString buildStyleSheet();MenuPluginTableViewWidget *m_tablePluginWidget;MenuPluginVariantTableViewAdapter *m_tableAdapter;QTableView *m_tableView;QStandardItemModel *m_model;QLabel *m_statusLabel;QLabel *m_selectionLabel;QLineEdit *m_filterEdit;QComboBox *m_selectionBehaviorCombo;QComboBox *m_selectionModeCombo;QComboBox *m_editTriggerCombo;QComboBox *m_dragDropModeCombo;QComboBox *m_horizontalResizeCombo;QComboBox *m_verticalResizeCombo;QComboBox *m_scrollModeCombo;QCheckBox *m_sortCheckBox;QCheckBox *m_alternatingColorCheckBox;QCheckBox *m_showGridCheckBox;QCheckBox *m_wordWrapCheckBox;QCheckBox *m_cornerButtonCheckBox;QCheckBox *m_rowHeaderCheckBox;QCheckBox *m_columnHeaderCheckBox;QCheckBox *m_stretchLastSectionCheckBox;QCheckBox *m_sortIndicatorCheckBox;QCheckBox *m_spanFirstRowCheckBox;QCheckBox *m_hideLowScoreRowsCheckBox;QSpinBox *m_rowHeightSpinBox;QSpinBox *m_columnWidthSpinBox;QSpinBox *m_frozenColumnSpinBox;QPushButton *m_addRowButton;QPushButton *m_removeRowButton;QPushButton *m_resetButton;QStringList m_baseHeaderLabels;int m_nextRowNumber;};
mainwindow8.cpp
#include"mainwindow8.h"#include"menuplugintableviewwidget.h"#include<algorithm>#include<QAbstractItemView>#include<QApplication>#include<QColor>#include<QCheckBox>#include<QComboBox>#include<QDate>#include<QFontMetrics>#include<QFormLayout>#include<QFrame>#include<QHeaderView>#include<QHBoxLayout>#include<QItemSelectionModel>#include<QLabel>#include<QLineEdit>#include<QPainter>#include<QPushButton>#include<QScrollArea>#include<QSpinBox>#include<QStandardItem>#include<QStandardItemModel>#include<QStatusBar>#include<QStyle>#include<QStyledItemDelegate>#include<QStringList>#include<QTableView>#include<QVBoxLayout>#include<QWidget>// MainWindow8 是 menuplugintableviewwidget 的使用示例。// 这个文件刻意把“业务数据”和“通用表格插件”分开:// 1. DemoTableRowData 表示业务层的一行原始数据。// 2. MenuPluginVariantTableViewAdapter 负责把 QVariant 数据适配成 QStandardItem。// 3. MenuPluginTableViewWidget 负责 QTableView 的通用能力,如筛选、拖拽、冻结列和信号转发。struct TagInfo{QString text;QColor backgroundColor;QColor textColor;};Q_DECLARE_METATYPE(TagInfo)Q_DECLARE_METATYPE(QList<TagInfo>)struct DemoTableRowData{QString id;QString title;QString category;QString status;QList<TagInfo> tags;int score = 0;QString owner;QString updated;QString note;bool checked = false;};// QVariant 要保存自定义结构体时,需要先声明 Qt 元类型。// 这样 DemoTableRowData 才能通过 QVariant::fromValue(...) 装箱,// 后续再通过 item.value<DemoTableRowData>() 还原。Q_DECLARE_METATYPE(DemoTableRowData)namespace {constexpr int kTagsColumn = 4;constexpr int kTagListRole = Qt::UserRole + 100;QStringList tagTexts(const QList<TagInfo> &tags){QStringList texts;texts.reserve(tags.size());for (const TagInfo &tag : tags) {texts.append(tag.text);}return texts;}class TagListDelegate : public QStyledItemDelegate{public:explicitTagListDelegate(QObject *parent = nullptr): QStyledItemDelegate(parent){}voidpaint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index)constoverride{const QList<TagInfo> tags = index.data(kTagListRole).value<QList<TagInfo>>();if (tags.isEmpty()) {QStyledItemDelegate::paint(painter, option, index);return;}QStyleOptionViewItem opt(option);initStyleOption(&opt, index);opt.text.clear();QStyle *style = opt.widget ? opt.widget->style() : QApplication::style();style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, opt.widget);painter->save();painter->setRenderHint(QPainter::Antialiasing, true);const QRect contentRect = option.rect.adjusted(8, 5, -8, -5);const QFontMetrics metrics(option.font);const int tagHeight = qMin(22, qMax(18, contentRect.height()));int x = contentRect.left();const int y = contentRect.top() + (contentRect.height() - tagHeight) / 2;for (int i = 0; i < tags.size(); ++i) {const TagInfo &tag = tags.at(i);const int tagWidth = metrics.horizontalAdvance(tag.text) + 18;if (x + tagWidth > contentRect.right()) {const QString moreText = QStringLiteral("+%1").arg(tags.size() - i);const int moreWidth = metrics.horizontalAdvance(moreText) + 16;if (x + moreWidth <= contentRect.right()) {const QRect moreRect(x, y, moreWidth, tagHeight);painter->setPen(Qt::NoPen);painter->setBrush(QColor(QStringLiteral("#CBD5E1")));painter->drawRoundedRect(moreRect, 8, 8);painter->setPen(QColor(QStringLiteral("#334155")));painter->drawText(moreRect, Qt::AlignCenter, moreText);}break;}const QRect tagRect(x, y, tagWidth, tagHeight);painter->setPen(Qt::NoPen);painter->setBrush(tag.backgroundColor);painter->drawRoundedRect(tagRect, 8, 8);painter->setPen(tag.textColor);painter->drawText(tagRect.adjusted(9, 0, -9, 0), Qt::AlignCenter, tag.text);x += tagWidth + 6;}painter->restore();}QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index)constoverride{QSize size = QStyledItemDelegate::sizeHint(option, index);size.setHeight(qMax(size.height(), 32));return size;}};// 以下几个函数只服务本 demo,用来构造模拟数据。// 真实项目中可以替换成接口返回、数据库读取或本地配置文件读取。QString categoryForIndex(int index){static const QStringList categories = {QStringLiteral("登录"),QStringLiteral("档案"),QStringLiteral("设备"),QStringLiteral("测量"),QStringLiteral("回放"),QStringLiteral("报告"),QStringLiteral("系统")};return categories.at(index % categories.size());}QString statusForIndex(int index){static const QStringList statuses = {QStringLiteral("已完成"),QStringLiteral("进行中"),QStringLiteral("待复测"),QStringLiteral("待确认")};return statuses.at(index % statuses.size());}DemoTableRowData buildRowData(int rowIndex){DemoTableRowData rowData;rowData.id = QStringLiteral("TC-%1").arg(rowIndex, 3, 10, QLatin1Char('0'));rowData.title = QStringLiteral("功能项 %1").arg(rowIndex, 3, 10, QLatin1Char('0'));rowData.category = categoryForIndex(rowIndex);rowData.status = statusForIndex(rowIndex);rowData.tags = {{QStringLiteral("正常"), QColor(QStringLiteral("#22C55E")), QColor(Qt::white)},{QStringLiteral("心电"), QColor(QStringLiteral("#3B82F6")), QColor(Qt::white)},{rowIndex % 2 == 0 ? QStringLiteral("已审核") : QStringLiteral("待审核"),rowIndex % 2 == 0 ? QColor(QStringLiteral("#F59E0B")) : QColor(QStringLiteral("#64748B")),QColor(Qt::white)}};rowData.score = 68 + (rowIndex * 7) % 31;rowData.owner = QStringLiteral("工程师 %1").arg((rowIndex % 5) + 1);rowData.updated = QDate::currentDate().addDays(-rowIndex).toString(QStringLiteral("yyyy-MM-dd"));rowData.note = QStringLiteral("支持排序、选择、编辑、拖放、表头调节、过滤和跨度显示演示。");rowData.checked = rowIndex % 2 == 0;return rowData;}// 插件侧统一接收 QList<QVariant>,而不是接收 QList<DemoTableRowData>。// 这样 MenuPluginTableViewWidget 不依赖任何具体业务类型,其他页面也能复用。QList<QVariant> buildDefaultRows(){QList<QVariant> rows;rows.reserve(14);//初始化空间长度,避免后续append时多次扩容for (int i = 1; i <= 14; ++i) {rows.append(QVariant::fromValue(buildRowData(i)));}return rows;}// 这是本 demo 最关键的“行布局构建函数”。// Adapter 会把每一条 QVariant 还原成 DemoTableRowData,然后调用这里生成一整行 QStandardItem。// 如果其他业务页面要复用插件,只需要换成自己的 RowItemsBuilder 即可。QList<QStandardItem *> buildDemoRowItems(const DemoTableRowData &rowData){auto *idItem = new QStandardItem(rowData.id);idItem->setEditable(false);idItem->setTextAlignment(Qt::AlignCenter);auto *titleItem = new QStandardItem(rowData.title);titleItem->setEditable(true);auto *categoryItem = new QStandardItem(rowData.category);categoryItem->setEditable(true);categoryItem->setTextAlignment(Qt::AlignCenter);auto *statusItem = new QStandardItem(rowData.status);statusItem->setEditable(true);statusItem->setCheckable(true);statusItem->setCheckState(rowData.checked ? Qt::Checked : Qt::Unchecked);statusItem->setToolTip(QStringLiteral("勾选框演示:QTableView 默认委托会直接显示可勾选项。"));auto *tagsItem = new QStandardItem(tagTexts(rowData.tags).join(QStringLiteral(",")));tagsItem->setEditable(false);tagsItem->setData(QVariant::fromValue(rowData.tags), kTagListRole);tagsItem->setToolTip(QStringLiteral("标签列使用 QStyledItemDelegate 绘制,每个标签可拥有独立背景色和文字色。"));auto *scoreItem = new QStandardItem(QString::number(rowData.score));scoreItem->setData(rowData.score, Qt::EditRole);scoreItem->setTextAlignment(Qt::AlignCenter);auto *ownerItem = new QStandardItem(rowData.owner);auto *updatedItem = new QStandardItem(rowData.updated);updatedItem->setTextAlignment(Qt::AlignCenter);auto *noteItem = new QStandardItem(rowData.note);return {idItem, titleItem, categoryItem, statusItem, tagsItem, scoreItem, ownerItem, updatedItem, noteItem};}// 单元格拖动时,需要能从业务结构体中按列读出值。// 这里把“列号 -> 字段”的映射集中放在一个函数里,避免拖拽逻辑直接散落 switch。QVariant demoCellValue(const DemoTableRowData &rowData, int column){switch (column) {case 0:return rowData.id;case 1:return rowData.title;case 2:return rowData.category;case 3:return rowData.status;case 4:return QVariant::fromValue(rowData.tags);case 5:return rowData.score;case 6:return rowData.owner;case 7:return rowData.updated;case 8:return rowData.note;default:return QVariant();}}// 和 demoCellValue 对应,用于单元格拖动后把新值写回业务结构体。// 插件本身不会也不应该知道 DemoTableRowData 有哪些字段。voidsetDemoCellValue(DemoTableRowData &rowData, int column, const QVariant &value){switch (column) {case 0:rowData.id = value.toString();break;case 1:rowData.title = value.toString();break;case 2:rowData.category = value.toString();break;case 3:rowData.status = value.toString();break;case 4:rowData.tags = value.value<QList<TagInfo>>();break;case 5:rowData.score = value.toInt();break;case 6:rowData.owner = value.toString();break;case 7:rowData.updated = value.toString();break;case 8:rowData.note = value.toString();break;default:break;}}// 单元格拖拽的业务规则:只允许同一列内交换内容。// 不允许跨列交换,是为了避免把“分数”拖到“负责人”这类数据类型不匹配的问题。// 插件只负责捕获拖拽动作,真正是否允许移动由 Adapter 的 CellMoveHandler 决定。boolswapDemoCellInSameColumn(QList<QVariant> &rows, int fromRow, int fromColumn, int toRow, int toColumn){if (fromColumn != toColumn || fromRow == toRow) {return false;}DemoTableRowData source = rows.at(fromRow).value<DemoTableRowData>();DemoTableRowData target = rows.at(toRow).value<DemoTableRowData>();const QVariant sourceValue = demoCellValue(source, fromColumn);const QVariant targetValue = demoCellValue(target, toColumn);setDemoCellValue(source, fromColumn, targetValue);setDemoCellValue(target, toColumn, sourceValue);rows[fromRow] = QVariant::fromValue(source);rows[toRow] = QVariant::fromValue(target);return true;}} // namespaceMainWindow8::MainWindow8(QWidget *parent): QMainWindow(parent), m_tablePluginWidget(nullptr), m_tableAdapter(nullptr), m_tableView(nullptr), m_model(nullptr), m_statusLabel(nullptr), m_selectionLabel(nullptr), m_filterEdit(nullptr), m_selectionBehaviorCombo(nullptr), m_selectionModeCombo(nullptr), m_editTriggerCombo(nullptr), m_dragDropModeCombo(nullptr), m_horizontalResizeCombo(nullptr), m_verticalResizeCombo(nullptr), m_scrollModeCombo(nullptr), m_sortCheckBox(nullptr), m_alternatingColorCheckBox(nullptr), m_showGridCheckBox(nullptr), m_wordWrapCheckBox(nullptr), m_cornerButtonCheckBox(nullptr), m_rowHeaderCheckBox(nullptr), m_columnHeaderCheckBox(nullptr), m_stretchLastSectionCheckBox(nullptr), m_sortIndicatorCheckBox(nullptr), m_spanFirstRowCheckBox(nullptr), m_hideLowScoreRowsCheckBox(nullptr), m_rowHeightSpinBox(nullptr), m_columnWidthSpinBox(nullptr), m_frozenColumnSpinBox(nullptr), m_addRowButton(nullptr), m_removeRowButton(nullptr), m_resetButton(nullptr), m_baseHeaderLabels(), m_nextRowNumber(1){// 注册元类型后,DemoTableRowData 可以更安全地参与 QVariant 和 Qt 元对象系统。qRegisterMetaType<TagInfo>("TagInfo");qRegisterMetaType<QList<TagInfo>>("QList<TagInfo>");qRegisterMetaType<DemoTableRowData>("DemoTableRowData");// 初始化顺序:// 1. buildUi 创建控制区和表格插件。// 2. populateModel 准备 QVariant 行数据并交给 adapter。// 3. applyViewSettings 把右侧控制项同步到 QTableView。// 4. applyFilter 应用初始过滤条件。buildUi();setStyleSheet(buildStyleSheet());populateModel();applyViewSettings();applyFilter();updateStatusText(QStringLiteral("QTableView 插件示例已就绪"));statusBar()->showMessage(QStringLiteral("QTableView 适配器插件示例已加载"), 3000);}voidMainWindow8::buildUi(){// 页面分成左右两块:左侧是表格控制面板,右侧是 QTableView 插件预览区。// MainWindow8 只做 demo 编排,不把通用表格逻辑写死在窗口里。resize(1500, 920);setWindowTitle(QStringLiteral("QTableView 插件化演示"));auto *centralWidget = new QWidget(this);auto *rootLayout = new QVBoxLayout(centralWidget);rootLayout->setContentsMargins(24, 22, 24, 24);rootLayout->setSpacing(16);auto *titleLabel = new QLabel(QStringLiteral("QTableView 插件化演示"), centralWidget);titleLabel->setObjectName(QStringLiteral("titleLabel"));auto *descLabel = new QLabel(QStringLiteral("MainWindow8 只负责传入 QVariant 行数据和 adapter 配置,表格控件、模型重建和关键字过滤由 menuplugintableviewwidget 统一承接。"),centralWidget);descLabel->setObjectName(QStringLiteral("descLabel"));descLabel->setWordWrap(true);auto *contentLayout = new QHBoxLayout();contentLayout->setSpacing(16);auto *controlCard = new QFrame(centralWidget);controlCard->setObjectName(QStringLiteral("controlCard"));buildControlPanel(controlCard);auto *tableCard = new QFrame(centralWidget);tableCard->setObjectName(QStringLiteral("tableCard"));buildTablePanel(tableCard);contentLayout->addWidget(controlCard, 0);contentLayout->addWidget(tableCard, 1);rootLayout->addWidget(titleLabel);rootLayout->addWidget(descLabel);rootLayout->addLayout(contentLayout, 1);setCentralWidget(centralWidget);}voidMainWindow8::buildControlPanel(QWidget *parent){// 控制项比较多,直接塞进固定高度的 QFormLayout 容易在窗口变小时互相挤压。// 所以这里外层使用 QScrollArea:高度不够时滚动,而不是压缩控件。auto *layout = new QVBoxLayout(parent);layout->setContentsMargins(16, 16, 16, 16);layout->setSpacing(14);auto *panelTitle = new QLabel(QStringLiteral("表格控制"), parent);panelTitle->setObjectName(QStringLiteral("panelTitle"));layout->addWidget(panelTitle);auto *scrollArea = new QScrollArea(parent);scrollArea->setObjectName(QStringLiteral("controlScrollArea"));scrollArea->setWidgetResizable(true);scrollArea->setFrameShape(QFrame::NoFrame);scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);auto *controlContent = new QWidget(scrollArea);auto *controlLayout = new QVBoxLayout(controlContent);controlLayout->setContentsMargins(0, 0, 6, 0);controlLayout->setSpacing(12);auto *formLayout = new QFormLayout();formLayout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);formLayout->setLabelAlignment(Qt::AlignLeft | Qt::AlignVCenter);formLayout->setFormAlignment(Qt::AlignTop);formLayout->setHorizontalSpacing(12);formLayout->setVerticalSpacing(10);m_selectionBehaviorCombo = new QComboBox(parent);m_selectionBehaviorCombo->addItem(QStringLiteral("按单元格"), static_cast<int>(QAbstractItemView::SelectItems));m_selectionBehaviorCombo->addItem(QStringLiteral("按整行"), static_cast<int>(QAbstractItemView::SelectRows));m_selectionModeCombo = new QComboBox(parent);m_selectionModeCombo->addItem(QStringLiteral("单选"), static_cast<int>(QAbstractItemView::SingleSelection));m_selectionModeCombo->addItem(QStringLiteral("多选"), static_cast<int>(QAbstractItemView::MultiSelection));m_selectionModeCombo->addItem(QStringLiteral("扩展选择"), static_cast<int>(QAbstractItemView::ExtendedSelection));m_selectionModeCombo->addItem(QStringLiteral("连续选择"), static_cast<int>(QAbstractItemView::ContiguousSelection));m_selectionModeCombo->addItem(QStringLiteral("不可选择"), static_cast<int>(QAbstractItemView::NoSelection));m_editTriggerCombo = new QComboBox(parent);m_editTriggerCombo->addItem(QStringLiteral("双击编辑"), static_cast<int>(QAbstractItemView::DoubleClicked));m_editTriggerCombo->addItem(QStringLiteral("编辑键触发"), static_cast<int>(QAbstractItemView::EditKeyPressed));m_editTriggerCombo->addItem(QStringLiteral("选中后单击编辑"), static_cast<int>(QAbstractItemView::SelectedClicked));m_editTriggerCombo->addItem(QStringLiteral("任意按键编辑"), static_cast<int>(QAbstractItemView::AnyKeyPressed));m_editTriggerCombo->addItem(QStringLiteral("全部触发"), static_cast<int>(QAbstractItemView::AllEditTriggers));m_editTriggerCombo->addItem(QStringLiteral("禁止编辑"), static_cast<int>(QAbstractItemView::NoEditTriggers));m_dragDropModeCombo = new QComboBox(parent);m_dragDropModeCombo->addItem(QStringLiteral("禁止拖放"), static_cast<int>(QAbstractItemView::NoDragDrop));m_dragDropModeCombo->addItem(QStringLiteral("仅拖动"), static_cast<int>(QAbstractItemView::DragOnly));m_dragDropModeCombo->addItem(QStringLiteral("仅放入"), static_cast<int>(QAbstractItemView::DropOnly));m_dragDropModeCombo->addItem(QStringLiteral("拖放"), static_cast<int>(QAbstractItemView::DragDrop));m_dragDropModeCombo->addItem(QStringLiteral("内部移动"), static_cast<int>(QAbstractItemView::InternalMove));m_horizontalResizeCombo = new QComboBox(parent);m_horizontalResizeCombo->addItem(QStringLiteral("交互调整"), static_cast<int>(QHeaderView::Interactive));m_horizontalResizeCombo->addItem(QStringLiteral("拉伸填充"), static_cast<int>(QHeaderView::Stretch));m_horizontalResizeCombo->addItem(QStringLiteral("按内容自适应"), static_cast<int>(QHeaderView::ResizeToContents));m_horizontalResizeCombo->addItem(QStringLiteral("固定宽度"), static_cast<int>(QHeaderView::Fixed));m_verticalResizeCombo = new QComboBox(parent);m_verticalResizeCombo->addItem(QStringLiteral("交互调整"), static_cast<int>(QHeaderView::Interactive));m_verticalResizeCombo->addItem(QStringLiteral("按内容自适应"), static_cast<int>(QHeaderView::ResizeToContents));m_verticalResizeCombo->addItem(QStringLiteral("固定高度"), static_cast<int>(QHeaderView::Fixed));m_scrollModeCombo = new QComboBox(parent);m_scrollModeCombo->addItem(QStringLiteral("按项滚动"), static_cast<int>(QAbstractItemView::ScrollPerItem));m_scrollModeCombo->addItem(QStringLiteral("按像素滚动"), static_cast<int>(QAbstractItemView::ScrollPerPixel));m_rowHeightSpinBox = new QSpinBox(parent);m_rowHeightSpinBox->setRange(24, 80);m_rowHeightSpinBox->setValue(34);m_columnWidthSpinBox = new QSpinBox(parent);m_columnWidthSpinBox->setRange(70, 220);m_columnWidthSpinBox->setValue(120);m_frozenColumnSpinBox = new QSpinBox(parent);m_frozenColumnSpinBox->setRange(0, 9);m_frozenColumnSpinBox->setValue(0);// 这些下拉框直接映射 Qt 原生枚举值。// applyViewSettings() 中会读取 currentData(),再设置回 QTableView。formLayout->addRow(QStringLiteral("选择行为"), m_selectionBehaviorCombo);formLayout->addRow(QStringLiteral("选择模式"), m_selectionModeCombo);formLayout->addRow(QStringLiteral("编辑触发"), m_editTriggerCombo);formLayout->addRow(QStringLiteral("拖放模式"), m_dragDropModeCombo);formLayout->addRow(QStringLiteral("水平表头尺寸"), m_horizontalResizeCombo);formLayout->addRow(QStringLiteral("垂直表头尺寸"), m_verticalResizeCombo);formLayout->addRow(QStringLiteral("滚动模式"), m_scrollModeCombo);formLayout->addRow(QStringLiteral("默认行高"), m_rowHeightSpinBox);formLayout->addRow(QStringLiteral("默认列宽"), m_columnWidthSpinBox);formLayout->addRow(QStringLiteral("冻结列数"), m_frozenColumnSpinBox);controlLayout->addLayout(formLayout);m_sortCheckBox = new QCheckBox(QStringLiteral("启用点击表头排序"), parent);m_alternatingColorCheckBox = new QCheckBox(QStringLiteral("交替行颜色"), parent);m_showGridCheckBox = new QCheckBox(QStringLiteral("显示网格线"), parent);m_wordWrapCheckBox = new QCheckBox(QStringLiteral("单元格文本自动换行"), parent);m_cornerButtonCheckBox = new QCheckBox(QStringLiteral("显示左上角按钮"), parent);m_rowHeaderCheckBox = new QCheckBox(QStringLiteral("显示行表头序号"), parent);m_columnHeaderCheckBox = new QCheckBox(QStringLiteral("显示列表头"), parent);m_stretchLastSectionCheckBox = new QCheckBox(QStringLiteral("最后一列自动拉伸"), parent);m_sortIndicatorCheckBox = new QCheckBox(QStringLiteral("显示排序指示器"), parent);m_spanFirstRowCheckBox = new QCheckBox(QStringLiteral("第一行演示列合并"), parent);m_hideLowScoreRowsCheckBox = new QCheckBox(QStringLiteral("隐藏分数低于 80 的行"), parent);// 默认配置尽量展示 QTableView 常用能力:// 排序、隔行色、网格线、列头、左上角按钮默认开启;垂直行号默认隐藏。m_sortCheckBox->setChecked(true);m_alternatingColorCheckBox->setChecked(true);m_showGridCheckBox->setChecked(true);m_wordWrapCheckBox->setChecked(false);m_cornerButtonCheckBox->setChecked(true);m_rowHeaderCheckBox->setChecked(false);m_columnHeaderCheckBox->setChecked(true);m_stretchLastSectionCheckBox->setChecked(true);m_sortIndicatorCheckBox->setChecked(true);m_spanFirstRowCheckBox->setChecked(false);m_hideLowScoreRowsCheckBox->setChecked(false);controlLayout->addWidget(m_sortCheckBox);controlLayout->addWidget(m_alternatingColorCheckBox);controlLayout->addWidget(m_showGridCheckBox);controlLayout->addWidget(m_wordWrapCheckBox);controlLayout->addWidget(m_cornerButtonCheckBox);controlLayout->addWidget(m_rowHeaderCheckBox);controlLayout->addWidget(m_columnHeaderCheckBox);controlLayout->addWidget(m_stretchLastSectionCheckBox);controlLayout->addWidget(m_sortIndicatorCheckBox);controlLayout->addWidget(m_spanFirstRowCheckBox);controlLayout->addWidget(m_hideLowScoreRowsCheckBox);auto *buttonRow = new QHBoxLayout();buttonRow->setSpacing(8);m_addRowButton = new QPushButton(QStringLiteral("新增行"), parent);m_removeRowButton = new QPushButton(QStringLiteral("删除选中行"), parent);m_resetButton = new QPushButton(QStringLiteral("重置模型"), parent);buttonRow->addWidget(m_addRowButton);buttonRow->addWidget(m_removeRowButton);buttonRow->addWidget(m_resetButton);controlLayout->addLayout(buttonRow);controlLayout->addStretch();scrollArea->setWidget(controlContent);layout->addWidget(scrollArea, 1);}voidMainWindow8::buildTablePanel(QWidget *parent){// 右侧区域才是真正演示 menuplugintableviewwidget 的地方。// 上方是过滤输入框,中间是插件控件,底部是选择状态和操作状态。auto *layout = new QVBoxLayout(parent);layout->setContentsMargins(16, 16, 16, 16);layout->setSpacing(12);auto *headerRow = new QHBoxLayout();headerRow->setSpacing(10);auto *panelTitle = new QLabel(QStringLiteral("预览区域"), parent);panelTitle->setObjectName(QStringLiteral("panelTitle"));m_filterEdit = new QLineEdit(parent);m_filterEdit->setPlaceholderText(QStringLiteral("按用例编号、功能项、模块、状态或备注过滤"));headerRow->addWidget(panelTitle);headerRow->addStretch();headerRow->addWidget(m_filterEdit, 0);m_tablePluginWidget = new MenuPluginTableViewWidget(parent);m_tableAdapter = new MenuPluginVariantTableViewAdapter();// 表头文本保存两份:// 1. m_baseHeaderLabels 永远保存原始表头。// 2. updateSortHeaderLabels() 会在当前排序列后追加箭头,再写入 model。// 这样可以避免 Qt 默认排序箭头导致表头文字左右跳动。m_baseHeaderLabels = {QStringLiteral("用例编号"),QStringLiteral("功能项"),QStringLiteral("模块"),QStringLiteral("状态"),QStringLiteral("标签"),QStringLiteral("分数"),QStringLiteral("负责人"),QStringLiteral("更新日期"),QStringLiteral("备注")};m_tableAdapter->setHeaderLabels(m_baseHeaderLabels);// 过滤文本由业务侧决定。// 插件只负责拿关键词匹配这些字符串,不关心 DemoTableRowData 具体有哪些字段。m_tableAdapter->setFilterTextResolver([](const QVariant &item, int) {const DemoTableRowData rowData = item.value<DemoTableRowData>();return QStringList{rowData.id,rowData.title,rowData.category,rowData.status,tagTexts(rowData.tags).join(QStringLiteral(" ")),QString::number(rowData.score),rowData.owner,rowData.updated,rowData.note};});// 自定义 buildRowItems 的入口。// 这里把 QVariant 还原成 DemoTableRowData,再生成 QStandardItem 列表。// 其他页面复用插件时,重点就是替换这个 lambda。m_tableAdapter->setRowItemsBuilder([](const QVariant &item, int) {return buildDemoRowItems(item.value<DemoTableRowData>());});// 单元格拖动的业务规则也通过 adapter 注入。// 当前 demo 只允许同列交换;如果业务希望跨列移动,可以在这里换成自己的处理函数。m_tableAdapter->setCellMoveHandler([](QList<QVariant> &rows, int fromRow, int fromColumn, int toRow, int toColumn) {return swapDemoCellInSameColumn(rows, fromRow, fromColumn, toRow, toColumn);});// 插件接收 adapter 后,就可以根据 adapter 的 rowCount/headerLabels/createRowItems 重建 model。m_tablePluginWidget->setAdapter(m_tableAdapter);// 插件仍然暴露原生 QTableView 和 QStandardItemModel。// 这样 demo 可以继续配置 Qt 自带能力,而插件不用把每个小配置都包装一遍。m_tableView = m_tablePluginWidget->tableView();m_model = m_tablePluginWidget->model();m_tableView->setObjectName(QStringLiteral("demoTableView"));m_tableView->setSortingEnabled(true);m_tableView->setCornerButtonEnabled(true);m_tableView->setWordWrap(false);m_tableView->setAlternatingRowColors(true);m_tableView->setShowGrid(true);m_tableView->setItemDelegateForColumn(kTagsColumn, new TagListDelegate(m_tableView));// 这里隐藏的是 QTableView 左侧默认行号 1、2、3...,不是业务里的“用例编号”列。m_tableView->verticalHeader()->setVisible(false);m_selectionLabel = new QLabel(parent);m_selectionLabel->setObjectName(QStringLiteral("selectionLabel"));m_statusLabel = new QLabel(parent);m_statusLabel->setObjectName(QStringLiteral("statusLabel"));m_statusLabel->setWordWrap(true);layout->addLayout(headerRow);layout->addWidget(m_tablePluginWidget, 1);layout->addWidget(m_selectionLabel);layout->addWidget(m_statusLabel);// 右侧控制面板的所有选项最终都汇总到 applyViewSettings()。// 这样每个控件只负责触发刷新,具体如何设置 QTableView 保持集中管理。connect(m_selectionBehaviorCombo, &QComboBox::currentIndexChanged, this, [this](int) { applyViewSettings(); });connect(m_selectionModeCombo, &QComboBox::currentIndexChanged, this, [this](int) { applyViewSettings(); });connect(m_editTriggerCombo, &QComboBox::currentIndexChanged, this, [this](int) { applyViewSettings(); });connect(m_dragDropModeCombo, &QComboBox::currentIndexChanged, this, [this](int) { applyViewSettings(); });connect(m_horizontalResizeCombo, &QComboBox::currentIndexChanged, this, [this](int) { applyViewSettings(); });connect(m_verticalResizeCombo, &QComboBox::currentIndexChanged, this, [this](int) { applyViewSettings(); });connect(m_scrollModeCombo, &QComboBox::currentIndexChanged, this, [this](int) { applyViewSettings(); });connect(m_rowHeightSpinBox, &QSpinBox::valueChanged, this, [this](int) { applyViewSettings(); });connect(m_columnWidthSpinBox, &QSpinBox::valueChanged, this, [this](int) { applyViewSettings(); });connect(m_frozenColumnSpinBox, &QSpinBox::valueChanged, this, [this](int) { applyViewSettings(); });connect(m_sortCheckBox, &QCheckBox::toggled, this, [this](bool) { applyViewSettings(); });connect(m_alternatingColorCheckBox, &QCheckBox::toggled, this, [this](bool) { applyViewSettings(); });connect(m_showGridCheckBox, &QCheckBox::toggled, this, [this](bool) { applyViewSettings(); });connect(m_wordWrapCheckBox, &QCheckBox::toggled, this, [this](bool) { applyViewSettings(); });connect(m_cornerButtonCheckBox, &QCheckBox::toggled, this, [this](bool) { applyViewSettings(); });connect(m_rowHeaderCheckBox, &QCheckBox::toggled, this, [this](bool) { applyViewSettings(); });connect(m_columnHeaderCheckBox, &QCheckBox::toggled, this, [this](bool) { applyViewSettings(); });connect(m_stretchLastSectionCheckBox, &QCheckBox::toggled, this, [this](bool) { applyViewSettings(); });connect(m_sortIndicatorCheckBox, &QCheckBox::toggled, this, [this](bool) { applyViewSettings(); });connect(m_spanFirstRowCheckBox, &QCheckBox::toggled, this, [this](bool) { applyViewSettings(); });connect(m_hideLowScoreRowsCheckBox, &QCheckBox::toggled, this, [this](bool) {applyFilter();updateStatusText(QStringLiteral("隐藏条件已更新"));});connect(m_filterEdit, &QLineEdit::textChanged, this, [this](const QString &) {applyFilter();updateStatusText(QStringLiteral("过滤条件已更新"));});// Qt 默认排序指示器会占用表头空间,导致文字点击后轻微偏移。// 这里监听排序变化,改用 updateSortHeaderLabels() 在表头文本后追加箭头。connect(m_tableView->horizontalHeader(), &QHeaderView::sortIndicatorChanged, this,[this](int logicalIndex, Qt::SortOrder order) {updateSortHeaderLabels(logicalIndex, order);});// 新增行不直接操作 QStandardItemModel,而是先更新 adapter 中的 QVariant 数据源,// 再调用 syncAdapterRows() 让插件统一 rebuild。connect(m_addRowButton, &QPushButton::clicked, this, [this]() {appendDemoRow(m_nextRowNumber++);updateStatusText(QStringLiteral("已新增一行"));});connect(m_removeRowButton, &QPushButton::clicked, this, [this]() {const QModelIndexList indexes = m_tableView->selectionModel()->selectedRows();if (indexes.isEmpty()) {updateStatusText(QStringLiteral("没有可删除的选中行"));return;}// 删除时先从选中行拿到业务 id,再从 adapter 的 QVariant 数据源中删除。// 这样可以保持“数据源 -> adapter -> model”的单向刷新路径。QList<QString> idsToRemove;idsToRemove.reserve(indexes.size());for (const QModelIndex &index : indexes) {const DemoTableRowData rowData = m_tablePluginWidget->rowData(index).value<DemoTableRowData>();idsToRemove.append(rowData.id);}QList<QVariant> rows = m_tableAdapter->rows();rows.erase(std::remove_if(rows.begin(), rows.end(), [&idsToRemove](const QVariant &item) {return idsToRemove.contains(item.value<DemoTableRowData>().id);}), rows.end());syncAdapterRows(rows);updateStatusText(QStringLiteral("已删除选中行"));});connect(m_resetButton, &QPushButton::clicked, this, [this]() {populateModel();applyViewSettings();applyFilter();updateStatusText(QStringLiteral("模型已重置"));});// 以下信号由 MenuPluginTableViewWidget 对原生 QTableView 事件进行转发。// 外部页面无需直接关心 clicked/doubleClicked/selectionModel 的底层细节。connect(m_tablePluginWidget, &MenuPluginTableViewWidget::cellClicked, this,[this](const QModelIndex &index, const QVariant &) {updateStatusText(QStringLiteral("单击单元格:第 %1 行,第 %2 列").arg(index.row() + 1).arg(index.column() + 1));});connect(m_tablePluginWidget, &MenuPluginTableViewWidget::cellDoubleClicked, this,[this](const QModelIndex &index, const QVariant &) {updateStatusText(QStringLiteral("双击单元格:%1").arg(index.data().toString()));});connect(m_tablePluginWidget, &MenuPluginTableViewWidget::selectionChanged, this, [this](int rowCount, int cellCount) {m_selectionLabel->setText(QStringLiteral("选中行数:%1,选中单元格数:%2").arg(rowCount).arg(cellCount));});connect(m_tablePluginWidget, &MenuPluginTableViewWidget::rowsReordered, this, [this](const QList<QVariant> &) {updateStatusText(QStringLiteral("表格行顺序已更新"));});connect(m_tablePluginWidget, &MenuPluginTableViewWidget::cellsMoved, this, [this](const QList<QVariant> &) {// 单元格移动后,adapter 数据已经变化;重新应用过滤,保证隐藏行状态同步。applyFilter();updateStatusText(QStringLiteral("单元格内容已移动"));});// itemChanged 这里只用于 demo 状态提示。// 如果真实业务要把编辑结果写回 QVariant 数据源,需要额外做 model -> adapter 的同步策略。connect(m_model, &QStandardItemModel::itemChanged, this, [this](QStandardItem *item) {const QString text = item->isCheckable()? QStringLiteral("%1 勾选状态 -> %2").arg(item->text(), item->checkState() == Qt::Checked ? QStringLiteral("已选中") : QStringLiteral("未选中")): QStringLiteral("已编辑:%1").arg(item->text());updateStatusText(text);});}voidMainWindow8::populateModel(){// 初始化 demo 数据:构造默认行并同步给 adapter。// m_nextRowNumber 用于后续“新增行”继续生成不重复的用例编号。syncAdapterRows(buildDefaultRows());m_nextRowNumber = 15;}voidMainWindow8::syncAdapterRows(const QList<QVariant> &rows){// 所有数据变更都走这个函数:// 1. 更新 adapter 内部 QVariant 列表。// 2. 让插件根据 adapter 重新构建 QStandardItemModel。// 3. 重新应用过滤条件。m_tableAdapter->setRows(rows);m_tablePluginWidget->rebuild();applyFilter();}voidMainWindow8::appendDemoRow(int rowIndex){// 新增行仍然先改 adapter 的原始数据,而不是直接 appendRow 到 model。// 这样能保证拖拽、过滤、重建时数据源一致。QList<QVariant> rows = m_tableAdapter->rows();rows.append(QVariant::fromValue(buildRowData(rowIndex)));syncAdapterRows(rows);}voidMainWindow8::applyViewSettings(){// 这个函数把控制面板的配置统一写回 QTableView。// 插件不重复封装所有 QTableView API,复杂页面仍可直接使用原生能力。m_tableView->setSelectionBehavior(static_cast<QAbstractItemView::SelectionBehavior>(m_selectionBehaviorCombo->currentData().toInt()));m_tableView->setSelectionMode(static_cast<QAbstractItemView::SelectionMode>(m_selectionModeCombo->currentData().toInt()));m_tableView->setEditTriggers(static_cast<QAbstractItemView::EditTriggers>(m_editTriggerCombo->currentData().toInt()));m_tableView->setDragDropMode(static_cast<QAbstractItemView::DragDropMode>(m_dragDropModeCombo->currentData().toInt()));m_tableView->setVerticalScrollMode(static_cast<QAbstractItemView::ScrollMode>(m_scrollModeCombo->currentData().toInt()));m_tableView->setHorizontalScrollMode(static_cast<QAbstractItemView::ScrollMode>(m_scrollModeCombo->currentData().toInt()));m_tableView->setSortingEnabled(m_sortCheckBox->isChecked());m_tableView->setAlternatingRowColors(m_alternatingColorCheckBox->isChecked());m_tableView->setShowGrid(m_showGridCheckBox->isChecked());m_tableView->setWordWrap(m_wordWrapCheckBox->isChecked());m_tableView->setCornerButtonEnabled(m_cornerButtonCheckBox->isChecked());m_tableView->verticalHeader()->setVisible(m_rowHeaderCheckBox->isChecked());m_tableView->horizontalHeader()->setVisible(m_columnHeaderCheckBox->isChecked());m_tableView->horizontalHeader()->setStretchLastSection(m_stretchLastSectionCheckBox->isChecked());// 关闭 Qt 默认排序箭头,避免表头文字因箭头占位产生左右跳动。// 排序方向由 updateSortHeaderLabels() 自己追加到表头文本里。m_tableView->horizontalHeader()->setSortIndicatorShown(false);// QTableView 的拖放能力由 DragDropMode 决定。// 插件内部会根据当前选择行为区分“按行拖动”和“按单元格拖动”。m_tableView->setDragEnabled(m_tableView->dragDropMode() != QAbstractItemView::NoDragDrop&& m_tableView->dragDropMode() != QAbstractItemView::DropOnly);m_tableView->viewport()->setAcceptDrops(m_tableView->dragDropMode() != QAbstractItemView::NoDragDrop&& m_tableView->dragDropMode() != QAbstractItemView::DragOnly);m_tableView->setDropIndicatorShown(m_tableView->dragDropMode() != QAbstractItemView::NoDragDrop);m_tableView->setDefaultDropAction(Qt::MoveAction);const auto horizontalMode = static_cast<QHeaderView::ResizeMode>(m_horizontalResizeCombo->currentData().toInt());const auto verticalMode = static_cast<QHeaderView::ResizeMode>(m_verticalResizeCombo->currentData().toInt());m_tableView->horizontalHeader()->setSectionResizeMode(horizontalMode);m_tableView->verticalHeader()->setSectionResizeMode(verticalMode);m_tableView->verticalHeader()->setDefaultSectionSize(m_rowHeightSpinBox->value());if (horizontalMode == QHeaderView::Interactive || horizontalMode == QHeaderView::Fixed) {for (int column = 0; column < m_model->columnCount(); ++column) {m_tableView->setColumnWidth(column, m_columnWidthSpinBox->value() + column * 8);}}// 冻结列能力由插件实现:内部叠加一个共享 model 的 QTableView,只显示前 N 列。m_tablePluginWidget->setFrozenColumnCount(m_frozenColumnSpinBox->value());m_tableView->clearSpans();if (m_spanFirstRowCheckBox->isChecked() && m_model->rowCount() > 0) {// span 只是演示 QTableView 原生能力,说明插件没有封死底层 tableView。m_tableView->setSpan(0, 1, 1, 2);}updateSortHeaderLabels(m_tableView->horizontalHeader()->sortIndicatorSection(),m_tableView->horizontalHeader()->sortIndicatorOrder());updateStatusText(QStringLiteral("表格设置已更新"));}voidMainWindow8::updateSortHeaderLabels(int sortColumn, Qt::SortOrder order){// 每次都从原始表头开始生成,避免多次点击后箭头重复追加。QStringList labels = m_baseHeaderLabels;if (m_sortIndicatorCheckBox->isChecked()&& sortColumn >= 0&& sortColumn < labels.size()) {labels[sortColumn] = QStringLiteral("%1 %2").arg(labels.at(sortColumn), order == Qt::AscendingOrder ? QStringLiteral("↑") : QStringLiteral("↓"));}m_model->setHorizontalHeaderLabels(labels);}voidMainWindow8::applyFilter(){// 第一层过滤交给插件:插件根据 adapter->filterTexts(...) 判断关键词是否命中。m_tablePluginWidget->setFilterKeyword(m_filterEdit->text().trimmed());for (int row = 0; row < m_model->rowCount(); ++row) {bool hidden = !m_tablePluginWidget->rowMatchesFilter(row);if (!hidden && m_hideLowScoreRowsCheckBox->isChecked()) {// 第二层过滤是 demo 自己追加的业务规则:隐藏分数低于 80 的行。// 分数列写入了 Qt::EditRole,所以这里按数值读取,而不是按显示字符串读取。const int score = m_model->index(row, 5).data(Qt::EditRole).toInt();hidden = score < 80;}m_tableView->setRowHidden(row, hidden);}}voidMainWindow8::updateStatusText(const QString &prefix){// 统计当前可见行数,用于底部状态栏说明。// 注意这里读取的是 view 的隐藏状态,所以能同时反映关键词过滤和低分过滤。const int total = m_model->rowCount();int visible = 0;for (int row = 0; row < total; ++row) {if (!m_tableView->isRowHidden(row)) {++visible;}}const QString selectionText = m_selectionBehaviorCombo->currentText();const QString editText = m_editTriggerCombo->currentText();m_selectionLabel->setText(QStringLiteral("选中行数:%1,选中单元格数:%2").arg(m_tableView->selectionModel()->selectedRows().size()).arg(m_tableView->selectionModel()->selectedIndexes().size()));m_statusLabel->setText(QStringLiteral("%1 | 选择=%2 | 编辑=%3 | 可见行 %4/%5").arg(prefix.isEmpty() ? QStringLiteral("已就绪") : prefix, selectionText, editText).arg(visible).arg(total));}QString MainWindow8::buildStyleSheet(){// 样式只服务 demo 展示。// 插件本身不强制业务页面使用这套样式,真实项目可以在外部统一接入主题。return QStringLiteral(R"(QMainWindow {background: #eef3f8;}QLabel#titleLabel {color: #0f172a;font-size: 30px;font-weight: 800;}QLabel#descLabel,QLabel#statusLabel,QLabel#selectionLabel {color: #475569;font-size: 14px;}QLabel#panelTitle {color: #0f172a;font-size: 18px;font-weight: 700;}QFrame#controlCard,QFrame#tableCard {background: #ffffff;border: 1px solid #dbe3ee;border-radius: 18px;}QComboBox,QLineEdit,QSpinBox {min-height: 32px;padding: 4px 8px;border: 1px solid #cbd5e1;border-radius: 8px;background: #ffffff;color: #0f172a;}QCheckBox {color: #334155;font-size: 14px;}QScrollArea#controlScrollArea {background: transparent;border: none;}QScrollArea#controlScrollArea > QWidget > QWidget {background: transparent;}QPushButton {min-height: 34px;padding: 6px 12px;border: none;border-radius: 8px;background: #1d4ed8;color: #ffffff;font-weight: 700;}QPushButton:hover {background: #2563eb;}QTableView#demoTableView {border: 1px solid #dbe3ee;border-radius: 14px;background: #ffffff;gridline-color: #e2e8f0;selection-background-color: #dbeafe;selection-color: #1d4ed8;alternate-background-color: #f8fafc;color: #0f172a;}QHeaderView::section {background: #f8fafc;color: #0f172a;padding: 8px;border: none;border-right: 1px solid #e2e8f0;border-bottom: 1px solid #e2e8f0;font-weight: 700;}QStatusBar {background: #ffffff;color: #64748b;border-top: 1px solid #dbe3ee;})");}
menuplugintableviewwidget.h
#pragma once#include<functional>#include<QList>#include<QStringList>#include<QVariant>#include<QWidget>class QModelIndex;class QStandardItem;class QStandardItemModel;class QTableView;// QTableView 插件的数据适配器基类。//// 插件本身只认识 QVariant,不认识业务结构体。业务侧需要继承这个接口,// 或者直接使用下面的 MenuPluginVariantTableViewAdapter,把自己的数据转换成:// 1. 行数。// 2. 表头。// 3. 每一行对应的 QStandardItem 列表。// 4. 可参与过滤的文本。// 5. 拖拽后的数据移动规则。class MenuPluginTableViewAdapter{public:virtual ~MenuPluginTableViewAdapter() = default;// 返回当前数据源中的行数。virtualintrowCount()const= 0;// 返回某一行的原始业务数据。// 这里使用 QVariant 是为了让插件和业务类型解耦。virtual QVariant rowAt(int row)const= 0;// 返回列数。通常等于表头数量,也可以由列解析器数量决定。virtualintcolumnCount()const= 0;// 返回水平表头文本。virtual QStringList headerLabels()const= 0;// 把一条 QVariant 业务数据转换成一整行 QStandardItem。// 这是最核心的扩展点:业务可以在这里设置文本、对齐、可编辑、勾选框、tooltip、role 等。virtual QList<QStandardItem *> createRowItems(const QVariant &rowData, int rowIndex)const= 0;// 返回这一行参与关键字过滤的文本集合。// 默认实现只使用 rowData.toString(),复杂业务建议自定义。virtual QStringList filterTexts(const QVariant &rowData, int rowIndex)const;// 行拖拽时调用。返回 true 表示 adapter 已完成数据源重排。virtualboolmoveRow(int from, int to);// 单元格拖拽时调用。是否允许移动、如何移动,由业务 adapter 决定。virtualboolmoveCell(int fromRow, int fromColumn, int toRow, int toColumn);};// 一个通用的 QVariant adapter 实现。//// 适合大多数页面直接使用:// - setRows(...) 设置 QList<QVariant> 数据源。// - setRowItemsBuilder(...) 自定义每行 QStandardItem 的构建方式。// - setFilterTextResolver(...) 自定义过滤字段。// - setCellMoveHandler(...) 自定义单元格拖拽规则。class MenuPluginVariantTableViewAdapter : public MenuPluginTableViewAdapter{public:// 自定义行构建函数。业务最常用的扩展点。using RowItemsBuilder = std::function<QList<QStandardItem *>(const QVariant &rowData, int rowIndex)>;// 如果没有提供 RowItemsBuilder,可以用列解析器把一行 QVariant 拆成多列值。using ColumnValueResolver = std::function<QVariant(const QVariant &rowData, int rowIndex)>;// 自定义过滤文本。比如只让编号、名称、状态参与搜索。using FilterTextResolver = std::function<QStringList(const QVariant &rowData, int rowIndex)>;// 在 RowItemsBuilder 或默认 item 创建之后,再统一调整整行 item。using RowItemsConfigurator = std::function<void(QList<QStandardItem *> &items, const QVariant &rowData, int rowIndex)>;// 自定义单元格拖拽后的数据更新规则。using CellMoveHandler = std::function<bool(QList<QVariant> &rows, int fromRow, int fromColumn, int toRow, int toColumn)>;// 设置和读取原始数据源。数据源中的每一项代表一行。voidsetRows(const QList<QVariant> &rows);QList<QVariant> rows()const;voidclear();// 配置 adapter 的各类扩展点。voidsetHeaderLabels(const QStringList &headers);voidsetColumnValueResolvers(const QList<ColumnValueResolver> &resolvers);voidsetRowItemsBuilder(const RowItemsBuilder &builder);voidsetFilterTextResolver(const FilterTextResolver &resolver);voidsetRowItemsConfigurator(const RowItemsConfigurator &configurator);voidsetCellMoveHandler(const CellMoveHandler &handler);introwCount()constoverride;QVariant rowAt(int row)constoverride;intcolumnCount()constoverride;QStringList headerLabels()constoverride;QList<QStandardItem *> createRowItems(const QVariant &rowData, int rowIndex)constoverride;QStringList filterTexts(const QVariant &rowData, int rowIndex)constoverride;boolmoveRow(int from, int to)override;boolmoveCell(int fromRow, int fromColumn, int toRow, int toColumn)override;private:// 原始业务行数据。插件重建 model、拖拽重排都以它为准。QList<QVariant> m_rows;// 表头文本。QStringList m_headers;// 无 RowItemsBuilder 时的默认列解析方案。QList<ColumnValueResolver> m_columnValueResolvers;// 自定义行 item 构建器。RowItemsBuilder m_rowItemsBuilder;// 自定义过滤文本解析器。FilterTextResolver m_filterTextResolver;// 行 item 创建后的二次配置器。RowItemsConfigurator m_rowItemsConfigurator;// 单元格移动处理器。CellMoveHandler m_cellMoveHandler;};// 可复用 QTableView 插件控件。//// 它内部持有:// - 一个主 QTableView。// - 一个 QStandardItemModel。// - 一个用于冻结列的叠加 QTableView。//// 业务侧通过 setAdapter(...) 提供数据和行构建方式;// 如需继续使用 Qt 原生能力,可以通过 tableView() / model() 拿到底层对象。class MenuPluginTableViewWidget : public QWidget{Q_OBJECTpublic:explicitMenuPluginTableViewWidget(QWidget *parent = nullptr);// 设置数据适配器。插件不接管 adapter 生命周期,通常由外部决定释放时机。voidsetAdapter(MenuPluginTableViewAdapter *adapter);MenuPluginTableViewAdapter *adapter()const;// 暴露底层 QTableView / QStandardItemModel,方便外部配置排序、选择模式、列宽、span 等 Qt 原生能力。QTableView *tableView()const;QStandardItemModel *model()const;// 冻结左侧前 N 列。0 表示关闭冻结列。voidsetFrozenColumnCount(int count);intfrozenColumnCount()const;// 根据 adapter 当前数据重新构建 model。voidrebuild();// 设置关键字过滤。过滤字段由 adapter->filterTexts(...) 提供。voidsetFilterKeyword(const QString &keyword);QString filterKeyword()const;boolrowMatchesFilter(int row)const;intvisibleRowCount()const;// 通过 model index 取回这一行的原始 QVariant 数据。QVariant rowData(const QModelIndex &index)const;// 返回 adapter 中的所有原始行数据。QList<QVariant> rows()const;signals:// 对 QTableView 常用交互做一层转发,同时附带原始 QVariant 行数据。voidcellClicked(const QModelIndex &index, const QVariant &rowData);voidcellDoubleClicked(const QModelIndex &index, const QVariant &rowData);voidselectionChanged(int selectedRows, int selectedCells);// 行拖拽或单元格拖拽完成后发出,外部可据此保存新顺序。voidrowsReordered(const QList<QVariant> &rows);voidcellsMoved(const QList<QVariant> &rows);private:// 监听主表格和表头尺寸变化,用于更新冻结列覆盖区域。booleventFilter(QObject *watched, QEvent *event)override;voidapplyFilter();QVariant rowDataByRow(int row)const;// 内部拖拽回调:主表格捕获拖拽后,交给 adapter 修改数据源,再 rebuild。voidhandleInternalMoveRequest(int from, int to);voidhandleInternalCellMoveRequest(int fromRow, int fromColumn, int toRow, int toColumn);// 冻结列相关:控制哪些列显示在冻结表格中,并同步几何、行高和隐藏状态。voidupdateFrozenColumns();voidupdateFrozenGeometry();voidsyncFrozenRowHeights();// 主表格,负责正常显示、滚动、选择、排序和编辑。QTableView *m_tableView;// 叠加在主表格左侧的冻结表格,只显示前 N 列。QTableView *m_frozenTableView;// 插件内部使用的标准 item model。QStandardItemModel *m_model;// 外部注入的数据适配器。MenuPluginTableViewAdapter *m_adapter;// 当前关键字过滤条件。QString m_filterKeyword;// 左侧冻结列数量。int m_frozenColumnCount;};
menuplugintableviewwidget.cpp
#include"menuplugintableviewwidget.h"#include<functional>#include<QAbstractItemView>#include<QDragEnterEvent>#include<QDragMoveEvent>#include<QDropEvent>#include<QEvent>#include<QFrame>#include<QHeaderView>#include<QItemSelectionModel>#include<QModelIndex>#include<QScrollBar>#include<QStandardItem>#include<QStandardItemModel>#include<QTableView>#include<QTimer>#include<QVBoxLayout>namespace {// 每个 QStandardItem 上都保存两份额外数据:// kRowDataRole 保存这一行的原始 QVariant,方便点击、双击、选择时把业务数据带出去。// kFilterTextRole 保存这一行拼好的过滤文本,避免每次过滤都重新问 adapter。constexpr int kRowDataRole = Qt::UserRole;constexpr int kFilterTextRole = Qt::UserRole + 1;// 默认 item 构建函数。// 当业务没有提供 RowItemsBuilder,只提供了普通列值时,adapter 会用它创建基础 QStandardItem。QStandardItem *createDefaultItem(const QVariant &value){auto *item = new QStandardItem(value.toString());item->setData(value, Qt::EditRole);return item;}// 内部 QTableView 子类。//// Qt 自带 QTableView 支持拖拽,但默认行为更偏 model 层 MIME 数据处理。// 这里重写 drag/drop,是为了把“拖动了哪一行/哪一个单元格”转换成简单回调,// 再交给 MenuPluginTableViewWidget 和 adapter 处理真实数据源。class MenuPluginTableView : public QTableView{public:explicitMenuPluginTableView(QWidget *parent = nullptr): QTableView(parent){setAcceptDrops(true);viewport()->setAcceptDrops(true);setDropIndicatorShown(true);setDragDropOverwriteMode(false);setDefaultDropAction(Qt::MoveAction);}voidsetInternalMoveHandler(const std::function<void(int, int)> &handler){m_internalMoveHandler = handler;}voidsetInternalCellMoveHandler(const std::function<void(int, int, int, int)> &handler){m_internalCellMoveHandler = handler;}protected:voiddragEnterEvent(QDragEnterEvent *event)override{// 只特殊处理本表格内部拖拽;外部拖入仍交给 QTableView 默认逻辑。if (event->source() == this) {event->acceptProposedAction();return;}QTableView::dragEnterEvent(event);}voiddragMoveEvent(QDragMoveEvent *event)override{// 内部拖拽过程中持续接受 move action,保证 dropEvent 能被触发。if (event->source() == this) {event->acceptProposedAction();return;}QTableView::dragMoveEvent(event);}voiddropEvent(QDropEvent *event)override{// 只接管内部拖拽。外部数据拖入时仍使用 QTableView 默认实现。if (event->source() != this || model() == nullptr || selectionModel() == nullptr) {QTableView::dropEvent(event);return;}const QModelIndexList selectedIndexes = selectionModel()->selectedIndexes();if (selectedIndexes.isEmpty()) {event->ignore();return;}#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)const QPoint dropPos = event->position().toPoint();#elseconst QPoint dropPos = event->pos();#endifconst QModelIndex targetIndex = indexAt(dropPos);if (!targetIndex.isValid()) {event->ignore();return;}// SelectItems 表示当前是按单元格选择。// 此时拖拽一个 cell,交给 adapter->moveCell(...) 决定是否移动以及如何修改业务数据。if (selectionBehavior() == QAbstractItemView::SelectItems) {const QModelIndex sourceIndex = selectedIndexes.first();event->acceptProposedAction();if (m_internalCellMoveHandler) {QTimer::singleShot(0, this, [handler = m_internalCellMoveHandler,fromRow = sourceIndex.row(),fromColumn = sourceIndex.column(),toRow = targetIndex.row(),toColumn = targetIndex.column()]() {handler(fromRow, fromColumn, toRow, toColumn);});}return;}// 非 SelectItems 时按“行拖拽”处理。// toRow 根据鼠标落点在目标行上半区/下半区决定插入到目标行前还是后。const QModelIndexList selectedRows = selectionModel()->selectedRows();const int fromRow = selectedRows.isEmpty() ? selectedIndexes.first().row() : selectedRows.first().row();int toRow = targetIndex.isValid() ? targetIndex.row() : model()->rowCount();const QRect rect = visualRect(targetIndex);if (dropPos.y() > rect.center().y()) {++toRow;}event->acceptProposedAction();if (m_internalMoveHandler) {// 延迟到当前 drop event 结束后再重建 model,避免事件处理中直接改 model 带来视图状态异常。QTimer::singleShot(0, this, [handler = m_internalMoveHandler, fromRow, toRow]() {handler(fromRow, toRow);});}}private:std::function<void(int, int)> m_internalMoveHandler;std::function<void(int, int, int, int)> m_internalCellMoveHandler;};}QStringList MenuPluginTableViewAdapter::filterTexts(const QVariant &rowData, int rowIndex)const{Q_UNUSED(rowIndex);// 基类提供一个很保守的默认过滤方案:直接使用 rowData.toString()。// 复杂业务通常会在具体 adapter 中返回多个字段,如编号、名称、状态、备注。return {rowData.toString()};}boolMenuPluginTableViewAdapter::moveRow(int from, int to){Q_UNUSED(from);Q_UNUSED(to);// 基类默认不支持移动。具体 adapter 返回 true 时,插件才会 rebuild 并发出 rowsReordered。return false;}boolMenuPluginTableViewAdapter::moveCell(int fromRow, int fromColumn, int toRow, int toColumn){Q_UNUSED(fromRow);Q_UNUSED(fromColumn);Q_UNUSED(toRow);Q_UNUSED(toColumn);// 基类默认不支持单元格移动,避免插件替业务做错误的数据交换。return false;}voidMenuPluginVariantTableViewAdapter::setRows(const QList<QVariant> &rows){m_rows = rows;}QList<QVariant> MenuPluginVariantTableViewAdapter::rows()const{return m_rows;}voidMenuPluginVariantTableViewAdapter::clear(){m_rows.clear();}voidMenuPluginVariantTableViewAdapter::setHeaderLabels(const QStringList &headers){m_headers = headers;}voidMenuPluginVariantTableViewAdapter::setColumnValueResolvers(const QList<ColumnValueResolver> &resolvers){m_columnValueResolvers = resolvers;}voidMenuPluginVariantTableViewAdapter::setRowItemsBuilder(const RowItemsBuilder &builder){m_rowItemsBuilder = builder;}voidMenuPluginVariantTableViewAdapter::setFilterTextResolver(const FilterTextResolver &resolver){m_filterTextResolver = resolver;}voidMenuPluginVariantTableViewAdapter::setRowItemsConfigurator(const RowItemsConfigurator &configurator){m_rowItemsConfigurator = configurator;}voidMenuPluginVariantTableViewAdapter::setCellMoveHandler(const CellMoveHandler &handler){m_cellMoveHandler = handler;}intMenuPluginVariantTableViewAdapter::rowCount()const{return m_rows.size();}QVariant MenuPluginVariantTableViewAdapter::rowAt(int row)const{return row >= 0 && row < m_rows.size() ? m_rows.at(row) : QVariant();}intMenuPluginVariantTableViewAdapter::columnCount()const{// 列数可以来自表头,也可以来自默认列解析器。// 如果业务提供 RowItemsBuilder,实际 item 数量可能在 rebuild() 时进一步扩展 model 列数。return qMax(m_headers.size(), m_columnValueResolvers.size());}QStringList MenuPluginVariantTableViewAdapter::headerLabels()const{return m_headers;}QList<QStandardItem *> MenuPluginVariantTableViewAdapter::createRowItems(const QVariant &rowData, int rowIndex)const{QList<QStandardItem *> items;// 优先使用业务自定义的 RowItemsBuilder。// 这是最灵活的方式,可以设置每个 item 的编辑状态、对齐、勾选框、tooltip、role 等。if (m_rowItemsBuilder) {items = m_rowItemsBuilder(rowData, rowIndex);} else if (!m_columnValueResolvers.isEmpty()) {// 如果没有 RowItemsBuilder,就使用列解析器把一条 QVariant 拆成多列普通值。items.reserve(m_columnValueResolvers.size());for (const ColumnValueResolver &resolver : m_columnValueResolvers) {items.append(createDefaultItem(resolver ? resolver(rowData, rowIndex) : QVariant()));}} else {// 最后兜底:整条 QVariant 作为一个单元格显示。items.append(createDefaultItem(rowData));}if (m_rowItemsConfigurator) {// 二次配置器适合做整行统一处理,比如统一设置不可编辑、字体、背景、附加 role。m_rowItemsConfigurator(items, rowData, rowIndex);}return items;}QStringList MenuPluginVariantTableViewAdapter::filterTexts(const QVariant &rowData, int rowIndex)const{if (m_filterTextResolver) {// 过滤字段由业务决定,插件只负责关键字匹配。return m_filterTextResolver(rowData, rowIndex);}return MenuPluginTableViewAdapter::filterTexts(rowData, rowIndex);}boolMenuPluginVariantTableViewAdapter::moveRow(int from, int to){if (from < 0 || from >= m_rows.size()) {return false;}int normalizedTo = qBound(0, to, m_rows.size());if (normalizedTo > from) {// QList::move 是“移动到目标下标”,而 drop 位置是“插入到目标行前/后”。// 从上往下移动时,源行移除后目标下标会前移一位,所以这里要减 1。--normalizedTo;}if (normalizedTo == from) {return false;}m_rows.move(from, normalizedTo);return true;}boolMenuPluginVariantTableViewAdapter::moveCell(int fromRow, int fromColumn, int toRow, int toColumn){if (!m_cellMoveHandler) {// 未提供业务处理器时,不允许单元格移动。return false;}if (fromRow < 0 || fromRow >= m_rows.size()|| toRow < 0 || toRow >= m_rows.size()|| fromColumn < 0 || fromColumn >= columnCount()|| toColumn < 0 || toColumn >= columnCount()|| (fromRow == toRow && fromColumn == toColumn)) {return false;}// 真正的数据交换/移动由业务回调执行。// 这样插件不需要知道 QVariant 里放的是哪个结构体,也不会破坏业务字段类型。return m_cellMoveHandler(m_rows, fromRow, fromColumn, toRow, toColumn);}MenuPluginTableViewWidget::MenuPluginTableViewWidget(QWidget *parent): QWidget(parent), m_tableView(new MenuPluginTableView(this)), m_frozenTableView(new QTableView(m_tableView)), m_model(new QStandardItemModel(this)), m_adapter(nullptr), m_frozenColumnCount(0){// 插件自身只放一个主 QTableView。// 冻结列用的 m_frozenTableView 不是 layout 子控件,而是叠加在主表格内部。auto *layout = new QVBoxLayout(this);layout->setContentsMargins(0, 0, 0, 0);layout->addWidget(m_tableView);m_tableView->setModel(m_model);// 冻结表格和主表格共享同一个 model。// 这样数据只有一份,冻结列只是“另一个视图”显示前 N 列。m_frozenTableView->setModel(m_model);m_frozenTableView->setFocusPolicy(Qt::NoFocus);// 共享 selection model,保证主表格和冻结表格选中状态一致。m_frozenTableView->setSelectionModel(m_tableView->selectionModel());m_frozenTableView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);m_frozenTableView->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);m_frozenTableView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);m_frozenTableView->setHorizontalScrollMode(QAbstractItemView::ScrollPerPixel);m_frozenTableView->setFrameShape(QFrame::NoFrame);m_frozenTableView->setSortingEnabled(false);m_frozenTableView->setWordWrap(m_tableView->wordWrap());m_frozenTableView->setAlternatingRowColors(m_tableView->alternatingRowColors());m_frozenTableView->setShowGrid(m_tableView->showGrid());// 冻结表格只负责显示,不抢鼠标事件。// 用户点击冻结列区域时,事件仍由主表格处理,避免两个 view 的交互状态打架。m_frozenTableView->setAttribute(Qt::WA_TransparentForMouseEvents, true);m_frozenTableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Fixed);m_frozenTableView->verticalHeader()->setVisible(m_tableView->verticalHeader()->isVisible());m_frozenTableView->hide();// 确保主表格 viewport 在冻结表格下面,冻结表格 raise() 后可以覆盖左侧列区域。m_tableView->viewport()->stackUnder(m_frozenTableView);// 表格或表头尺寸变化时,需要重新计算冻结表格的位置和宽度。m_tableView->installEventFilter(this);m_tableView->horizontalHeader()->installEventFilter(this);m_tableView->verticalHeader()->installEventFilter(this);// 内部表格只负责识别拖拽动作;真正的数据移动在本类和 adapter 中完成。static_cast<MenuPluginTableView *>(m_tableView)->setInternalMoveHandler([this](int from, int to) {handleInternalMoveRequest(from, to);});static_cast<MenuPluginTableView *>(m_tableView)->setInternalCellMoveHandler([this](int fromRow, int fromColumn, int toRow, int toColumn) {handleInternalCellMoveRequest(fromRow, fromColumn, toRow, toColumn);});connect(m_tableView, &QTableView::clicked, this, [this](const QModelIndex &index) {emit cellClicked(index, rowData(index));});connect(m_tableView, &QTableView::doubleClicked, this, [this](const QModelIndex &index) {emit cellDoubleClicked(index, rowData(index));});// 对外只暴露简化后的选择统计,调用方不用直接操作 selectionModel。connect(m_tableView->selectionModel(), &QItemSelectionModel::selectionChanged, this, [this]() {emit selectionChanged(m_tableView->selectionModel()->selectedRows().size(),m_tableView->selectionModel()->selectedIndexes().size());});// 冻结表格和主表格必须垂直同步滚动,否则左右两块行内容会错位。connect(m_tableView->verticalScrollBar(), &QScrollBar::valueChanged,m_frozenTableView->verticalScrollBar(), &QScrollBar::setValue);connect(m_frozenTableView->verticalScrollBar(), &QScrollBar::valueChanged,m_tableView->verticalScrollBar(), &QScrollBar::setValue);connect(m_tableView->horizontalHeader(), &QHeaderView::sectionResized, this,[this](int, int, int) {// 列宽变化会影响冻结区域宽度。updateFrozenColumns();});connect(m_tableView->verticalHeader(), &QHeaderView::sectionResized, this,[this](int logicalIndex, int, int newSize) {// 行高变化时同步给冻结表格,避免同一行左右高度不同。m_frozenTableView->setRowHeight(logicalIndex, newSize);});}voidMenuPluginTableViewWidget::setAdapter(MenuPluginTableViewAdapter *adapter){// 这里只保存裸指针,不接管生命周期。// 当前项目中 adapter 通常由页面创建并随页面一起销毁。m_adapter = adapter;rebuild();}MenuPluginTableViewAdapter *MenuPluginTableViewWidget::adapter()const{return m_adapter;}QTableView *MenuPluginTableViewWidget::tableView()const{return m_tableView;}QStandardItemModel *MenuPluginTableViewWidget::model()const{return m_model;}voidMenuPluginTableViewWidget::setFrozenColumnCount(int count){// 负数没有意义,统一归零。m_frozenColumnCount = qMax(0, count);updateFrozenColumns();}intMenuPluginTableViewWidget::frozenColumnCount()const{return m_frozenColumnCount;}voidMenuPluginTableViewWidget::rebuild(){// rebuild 是插件的核心刷新入口。// 它不直接关心业务类型,只通过 adapter 拉取 QVariant,再转成 QStandardItem。m_model->clear();if (m_adapter == nullptr) {return;}QStringList headers = m_adapter->headerLabels();int modelColumnCount = qMax(0, m_adapter->columnCount());m_model->setColumnCount(modelColumnCount);for (int row = 0; row < m_adapter->rowCount(); ++row) {const QVariant payload = m_adapter->rowAt(row);QList<QStandardItem *> items = m_adapter->createRowItems(payload, row);const QString filterText = m_adapter->filterTexts(payload, row).join(QStringLiteral(" "));// 如果业务返回的 item 数量超过当前列数,model 自动扩列。if (items.size() > m_model->columnCount()) {m_model->setColumnCount(items.size());}// 如果业务返回的 item 数量不足,补空 item,保证每一行列数一致。while (items.size() < m_model->columnCount()) {items.append(new QStandardItem);}for (int column = 0; column < items.size(); ++column) {if (items.at(column) == nullptr) {items[column] = new QStandardItem;}QStandardItem *item = items.at(column);item->setDragEnabled(true);item->setDropEnabled(true);// 每个单元格都挂同一份行级业务数据。// 这样任意列被点击时,都能通过 rowData(index) 取回完整 QVariant。item->setData(payload, kRowDataRole);item->setData(filterText, kFilterTextRole);}m_model->appendRow(items);}if (headers.size() < m_model->columnCount()) {// 表头数量不足时补空,避免 setHorizontalHeaderLabels 少列导致显示不完整。headers.resize(m_model->columnCount());}m_model->setHorizontalHeaderLabels(headers);// model 重建后,过滤和冻结列状态都需要重新同步。applyFilter();syncFrozenRowHeights();updateFrozenColumns();}voidMenuPluginTableViewWidget::setFilterKeyword(const QString &keyword){// 保存 trimmed 后的关键字,避免用户输入前后空格影响匹配结果。m_filterKeyword = keyword.trimmed();applyFilter();}QString MenuPluginTableViewWidget::filterKeyword()const{return m_filterKeyword;}boolMenuPluginTableViewWidget::rowMatchesFilter(int row)const{if (row < 0 || row >= m_model->rowCount()) {return false;}if (m_filterKeyword.isEmpty()) {// 空关键字表示不过滤。return true;}// kFilterTextRole 存在第 0 列即可,因为同一行所有 item 都保存了相同过滤文本。const QModelIndex index = m_model->index(row, 0);return index.data(kFilterTextRole).toString().contains(m_filterKeyword, Qt::CaseInsensitive);}intMenuPluginTableViewWidget::visibleRowCount()const{int visibleCount = 0;for (int row = 0; row < m_model->rowCount(); ++row) {if (!m_tableView->isRowHidden(row)) {++visibleCount;}}return visibleCount;}QVariant MenuPluginTableViewWidget::rowData(const QModelIndex &index)const{// 对外按 QModelIndex 查询业务数据,但实际只需要行号。return rowDataByRow(index.row());}QList<QVariant> MenuPluginTableViewWidget::rows()const{QList<QVariant> result;if (m_adapter == nullptr) {return result;}for (int row = 0; row < m_adapter->rowCount(); ++row) {result.append(m_adapter->rowAt(row));}return result;}voidMenuPluginTableViewWidget::applyFilter(){// 插件同时隐藏主表格和冻结表格的行,保证冻结列与普通列可见状态一致。for (int row = 0; row < m_model->rowCount(); ++row) {const bool hidden = !rowMatchesFilter(row);m_tableView->setRowHidden(row, hidden);m_frozenTableView->setRowHidden(row, hidden);}}QVariant MenuPluginTableViewWidget::rowDataByRow(int row)const{if (row < 0 || row >= m_model->rowCount()) {return QVariant();}// 原始 QVariant 保存在第 0 列 item 上。return m_model->index(row, 0).data(kRowDataRole);}voidMenuPluginTableViewWidget::handleInternalMoveRequest(int from, int to){// 行拖拽只修改 adapter 的原始数据源。// 如果 adapter 返回 false,说明业务不允许移动或移动失败,视图不刷新。if (m_adapter == nullptr || !m_adapter->moveRow(from, to)) {return;}// adapter 数据源已变化,重建 model 让显示和数据源一致。rebuild();// 恢复拖拽后的当前行选择,给用户一个明确反馈。int currentRow = qBound(0, to, m_model->rowCount());if (currentRow > from) {--currentRow;}if (currentRow >= 0 && currentRow < m_model->rowCount()) {const QModelIndex index = m_model->index(currentRow, 0);m_tableView->setCurrentIndex(index);m_tableView->selectionModel()->select(index, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);}m_tableView->doItemsLayout();m_tableView->viewport()->update();// 把新的 QVariant 行顺序通知外部,外部可持久化。emit rowsReordered(rows());}voidMenuPluginTableViewWidget::handleInternalCellMoveRequest(int fromRow, int fromColumn, int toRow, int toColumn){// 单元格移动也必须先经过 adapter。// 插件不知道不同列的数据语义,所以不直接交换 model item。if (m_adapter == nullptr || !m_adapter->moveCell(fromRow, fromColumn, toRow, toColumn)) {return;}// adapter 已更新 QVariant 数据源,重建 model 生成新的单元格内容。rebuild();// 恢复目标单元格选中状态。const QModelIndex index = m_model->index(toRow, toColumn);if (index.isValid()) {m_tableView->setCurrentIndex(index);m_tableView->selectionModel()->select(index, QItemSelectionModel::ClearAndSelect);}m_tableView->doItemsLayout();m_tableView->viewport()->update();// 通知外部单元格移动完成。emit cellsMoved(rows());}boolMenuPluginTableViewWidget::eventFilter(QObject *watched, QEvent *event){// 冻结列覆盖区域依赖主表格尺寸、表头显示状态和表头高度。// 这些变化发生时需要重新计算冻结表格几何。if ((watched == m_tableView || watched == m_tableView->horizontalHeader() || watched == m_tableView->verticalHeader())&& (event->type() == QEvent::Resize || event->type() == QEvent::Show || event->type() == QEvent::Hide)) {updateFrozenColumns();}return QWidget::eventFilter(watched, event);}voidMenuPluginTableViewWidget::updateFrozenColumns(){// frozenCount 做边界保护:不能超过当前 model 的实际列数。const int frozenCount = qMin(m_frozenColumnCount, m_model->columnCount());const bool enabled = frozenCount > 0;// 冻结表格尽量镜像主表格的可视配置。// 这样冻结列看起来和普通列一致,不额外变色、不单独使用另一套样式。m_frozenTableView->verticalHeader()->setVisible(m_tableView->verticalHeader()->isVisible());m_frozenTableView->horizontalHeader()->setVisible(m_tableView->horizontalHeader()->isVisible());m_frozenTableView->setSelectionBehavior(m_tableView->selectionBehavior());m_frozenTableView->setSelectionMode(m_tableView->selectionMode());m_frozenTableView->setEditTriggers(m_tableView->editTriggers());m_frozenTableView->setAlternatingRowColors(m_tableView->alternatingRowColors());m_frozenTableView->setShowGrid(m_tableView->showGrid());m_frozenTableView->setWordWrap(m_tableView->wordWrap());m_frozenTableView->setPalette(m_tableView->palette());m_frozenTableView->viewport()->setPalette(m_tableView->viewport()->palette());for (int column = 0; column < m_model->columnCount(); ++column) {const bool frozen = enabled && column < frozenCount;// 冻结表格只显示前 frozenCount 列;主表格仍显示所有列。// 两个 view 叠加后,用户看到的是左侧固定、右侧横向滚动。m_frozenTableView->setColumnHidden(column, !frozen);m_tableView->setColumnHidden(column, false);if (frozen) {// 列宽必须同步,否则冻结区与主表格表头/单元格无法对齐。m_frozenTableView->setColumnWidth(column, m_tableView->columnWidth(column));m_frozenTableView->setItemDelegateForColumn(column, m_tableView->itemDelegateForColumn(column));}}if (!enabled) {// 冻结列数为 0 时直接隐藏叠加表格。m_frozenTableView->hide();return;}// 显示冻结表格前,先同步行高和几何位置。syncFrozenRowHeights();updateFrozenGeometry();m_frozenTableView->show();m_frozenTableView->raise();}voidMenuPluginTableViewWidget::updateFrozenGeometry(){// 冻结表格宽度 = 垂直表头宽度 + 前 N 列列宽之和。int frozenWidth = 0;const int frozenCount = qMin(m_frozenColumnCount, m_model->columnCount());for (int column = 0; column < frozenCount; ++column) {frozenWidth += m_tableView->columnWidth(column);}const int verticalHeaderWidth = m_tableView->verticalHeader()->isVisible()? m_tableView->verticalHeader()->width(): 0;const int frameWidth = m_tableView->frameWidth();// 冻结表格作为 m_tableView 的子控件,坐标相对主表格。// 高度覆盖 horizontalHeader + viewport,保证表头和内容都被冻结。m_frozenTableView->setGeometry(frameWidth,frameWidth,verticalHeaderWidth + frozenWidth,m_tableView->viewport()->height() + m_tableView->horizontalHeader()->height());}voidMenuPluginTableViewWidget::syncFrozenRowHeights(){// model rebuild、行高调整、过滤后都需要同步行高和隐藏状态。// 否则冻结列和普通列会出现上下错位。for (int row = 0; row < m_model->rowCount(); ++row) {m_frozenTableView->setRowHeight(row, m_tableView->rowHeight(row));m_frozenTableView->setRowHidden(row, m_tableView->isRowHidden(row));}}

▼点个「
」赞,是我持续更新的动力 ▼
夜雨聆风