第二章中,我们完成了课程与作业管理系统的界面原型。本章将处理真实交互:阻止非法输入、响应关闭和键盘事件,并通过 Model/View 将作业数据与表格显示分离。

本章暂时仍然使用内存数据。这样可以先把界面交互和模型接口做正确,再在下一章替换为 SQLite 数据源。

1. 本章目标

完成本章后,应当能够:

  • 区分事件、信号和槽;
  • 重写关闭事件并正确接受或忽略;
  • 使用事件过滤器统一处理局部交互;
  • 使用 QValidator 和业务规则验证输入;
  • 理解 Model、View、Delegate 的职责;
  • 实现一个 QAbstractTableModel;
  • 使用 QSortFilterProxyModel 搜索和排序;
  • 把界面、领域数据和业务逻辑初步分层。

2. 事件与信号有什么区别

信号表示 QObject 的状态或行为发生了变化,例如按钮被点击、文本发生变化或网络请求结束。事件则是由 Qt 事件系统投递的 QEvent 对象,例如鼠标、键盘、绘制、窗口关闭和尺寸变化。

典型流程如下:

操作系统或 Qt 产生事件
    → QApplication 事件循环
    → QObject::event()
    → closeEvent()、keyPressEvent() 等具体处理函数

处理按钮点击通常使用信号与槽;需要拦截窗口关闭、修改特定按键行为或观察另一个控件的事件时,再使用事件处理函数或事件过滤器。

不要把所有交互都塞进 event()。Qt 已经提供具体事件函数时,优先重写对应函数。

3. 关闭窗口前确认未保存数据

假设窗口维护一个 dirty_ 标记,数据变化后设为 true,保存后恢复为 false。可以重写关闭事件:

#include <QCloseEvent>
#include <QMessageBox>

void MainWindow::closeEvent(QCloseEvent *event) {
    if (!dirty_) {
        event->accept();
        return;
    }

    const auto answer = QMessageBox::question(
        this,
        tr("退出程序"),
        tr("还有未保存的修改,确定退出吗?"),
        QMessageBox::Yes | QMessageBox::No,
        QMessageBox::No);

    if (answer == QMessageBox::Yes) {
        event->accept();
    } else {
        event->ignore();
    }
}

事件是否被接受会影响后续行为。仅仅弹出 QMessageBox 而不处理 accept()ignore(),容易让退出逻辑与预期不一致。

4. 使用事件过滤器处理搜索框

如果希望在搜索框中按 Esc 清空内容,可以让主窗口观察搜索框的事件:

ui->searchEdit->installEventFilter(this);

然后重写 eventFilter()

#include <QEvent>
#include <QKeyEvent>

bool MainWindow::eventFilter(QObject *watched, QEvent *event) {
    if (watched == ui->searchEdit && event->type() == QEvent::KeyPress) {
        auto *keyEvent = static_cast<QKeyEvent *>(event);

        if (keyEvent->key() == Qt::Key_Escape) {
            ui->searchEdit->clear();
            return true;
        }
    }

    return QMainWindow::eventFilter(watched, event);
}

返回 true 表示事件已经处理,不再交给目标控件;返回 false 则允许继续传递。过滤器适合局部、明确的需求,不应变成一个处理全应用所有事件的巨大函数。

5. 输入验证分为两层

第一层是控件级格式检查。例如课程学分只允许 1~10:

creditsEdit->setValidator(new QIntValidator(1, 10, creditsEdit));

课程代码可以使用正则表达式:

const QRegularExpression pattern(
    QStringLiteral("[A-Za-z]{2,8}-[0-9]{2,6}"));

courseCodeEdit->setValidator(
    new QRegularExpressionValidator(pattern, courseCodeEdit));

第二层是业务规则检查,例如标题不能为空、截止日期不能早于课程开始日期、同一课程下作业标题不能重复:

QString validateAssignment(const QString &title,
                           const QDate &deadline) {
    if (title.trimmed().isEmpty()) {
        return QObject::tr("作业标题不能为空");
    }

    if (!deadline.isValid()) {
        return QObject::tr("截止日期无效");
    }

    return {};
}

QValidator 只能判断输入形式,不能替代业务规则。点击“确定”时仍要进行最终检查,并把焦点移到出错控件。

6. 定义领域数据

先用一个简单结构描述作业:

#pragma once

#include <QDate>
#include <QString>

struct Assignment {
    qint64 id = 0;
    QString courseName;
    QString title;
    QDate deadline;
    bool completed = false;
};

领域对象不应保存 QLabel、QTableView 等界面指针。它只描述业务数据,可以被模型、数据库、测试和导出功能共同使用。

7. 为什么使用 Model/View

QTableWidget 会把数据直接存进单元格,适合非常小的临时表格。课程项目的数据还要被搜索、排序、保存和测试,如果界面单元格成为唯一数据源,很快就会出现同步问题。

Model/View 将职责分开:

Assignment 数据
       ↓
AssignmentTableModel
       ↓
QSortFilterProxyModel
       ↓
QTableView
  • Model 提供数据;
  • View 负责显示和选择;
  • Delegate 负责单元格绘制和编辑;
  • Proxy Model 在不复制原数据的前提下排序或过滤。

8. 实现 AssignmentTableModel

头文件 assignmenttablemodel.h

#pragma once

#include <QAbstractTableModel>
#include <QVector>

#include "assignment.h"

class AssignmentTableModel final : public QAbstractTableModel {
    Q_OBJECT

public:
    enum Column {
        CourseColumn,
        TitleColumn,
        DeadlineColumn,
        StatusColumn,
        ColumnCount
    };

    explicit AssignmentTableModel(QObject *parent = nullptr);

    int rowCount(const QModelIndex &parent = {}) const override;
    int columnCount(const QModelIndex &parent = {}) const override;
    QVariant data(const QModelIndex &index,
                  int role = Qt::DisplayRole) const override;
    QVariant headerData(int section,
                        Qt::Orientation orientation,
                        int role) const override;

    void setAssignments(QVector<Assignment> assignments);
    void addAssignment(const Assignment &assignment);
    bool removeAssignment(int row);
    const Assignment *assignmentAt(int row) const;

private:
    QVector<Assignment> assignments_;
};

核心实现:

#include "assignmenttablemodel.h"

#include <utility>

AssignmentTableModel::AssignmentTableModel(QObject *parent)
    : QAbstractTableModel(parent) {}

int AssignmentTableModel::rowCount(const QModelIndex &parent) const {
    return parent.isValid() ? 0 : assignments_.size();
}

int AssignmentTableModel::columnCount(const QModelIndex &parent) const {
    return parent.isValid() ? 0 : ColumnCount;
}

QVariant AssignmentTableModel::data(const QModelIndex &index,
                                    int role) const {
    if (!index.isValid() || index.row() >= assignments_.size()) {
        return {};
    }

    const Assignment &item = assignments_.at(index.row());

    if (role == Qt::TextAlignmentRole) {
        return int(Qt::AlignCenter);
    }

    if (role != Qt::DisplayRole) {
        return {};
    }

    switch (index.column()) {
    case CourseColumn:
        return item.courseName;
    case TitleColumn:
        return item.title;
    case DeadlineColumn:
        return item.deadline.toString("yyyy-MM-dd");
    case StatusColumn:
        return item.completed ? tr("已完成") : tr("进行中");
    default:
        return {};
    }
}

QVariant AssignmentTableModel::headerData(
    int section, Qt::Orientation orientation, int role) const {
    if (orientation != Qt::Horizontal || role != Qt::DisplayRole) {
        return {};
    }

    switch (section) {
    case CourseColumn:
        return tr("课程");
    case TitleColumn:
        return tr("作业");
    case DeadlineColumn:
        return tr("截止日期");
    case StatusColumn:
        return tr("状态");
    default:
        return {};
    }
}

void AssignmentTableModel::setAssignments(
    QVector<Assignment> assignments) {
    beginResetModel();
    assignments_ = std::move(assignments);
    endResetModel();
}

void AssignmentTableModel::addAssignment(
    const Assignment &assignment) {
    const int row = assignments_.size();
    beginInsertRows({}, row, row);
    assignments_.append(assignment);
    endInsertRows();
}

bool AssignmentTableModel::removeAssignment(int row) {
    if (row < 0 || row >= assignments_.size()) {
        return false;
    }

    beginRemoveRows({}, row, row);
    assignments_.removeAt(row);
    endRemoveRows();
    return true;
}

const Assignment *AssignmentTableModel::assignmentAt(int row) const {
    if (row < 0 || row >= assignments_.size()) {
        return nullptr;
    }

    return &assignments_.at(row);
}

新增、删除和重置数据时必须调用对应的 begin...()end...(),这样 View 才能正确更新内部索引和选中状态。

9. 接入表格、搜索和排序

在主窗口中创建源模型和代理模型:

assignmentModel_ = new AssignmentTableModel(this);
assignmentProxy_ = new QSortFilterProxyModel(this);

assignmentProxy_->setSourceModel(assignmentModel_);
assignmentProxy_->setFilterCaseSensitivity(Qt::CaseInsensitive);
assignmentProxy_->setFilterKeyColumn(-1);
assignmentProxy_->setDynamicSortFilter(true);

ui->assignmentTableView->setModel(assignmentProxy_);
ui->assignmentTableView->setSortingEnabled(true);
ui->assignmentTableView->setSelectionBehavior(
    QAbstractItemView::SelectRows);
ui->assignmentTableView->setSelectionMode(
    QAbstractItemView::SingleSelection);

搜索框变化时更新过滤表达式:

connect(ui->searchEdit, &QLineEdit::textChanged,
        this, [this](const QString &text) {
    assignmentProxy_->setFilterRegularExpression(
        QRegularExpression(
            QRegularExpression::escape(text),
            QRegularExpression::CaseInsensitiveOption));
});

用户在 View 中选择的行属于代理模型。删除或编辑前要映射回源模型:

const QModelIndex proxyIndex =
    ui->assignmentTableView->currentIndex();

if (proxyIndex.isValid()) {
    const QModelIndex sourceIndex =
        assignmentProxy_->mapToSource(proxyIndex);
    assignmentModel_->removeAssignment(sourceIndex.row());
}

忘记映射索引是排序和过滤后删错数据的常见原因。

10. 初步划分项目职责

本章结束时,可以形成下面的职责边界:

层次 负责内容 不负责内容
UI 显示、收集输入、触发操作 直接拼接 SQL
Model 将领域数据暴露给 View 弹窗和导航
Service 校验业务规则、组织操作 控件颜色和布局
Repository 下一章负责持久化 页面切换

MainWindow 可以连接这些对象,但不应把每一层的实现都写进槽函数。

11. 七个练习步骤

顺序 学习与实践
第 1 步 区分事件和信号,重写关闭事件
第 2 步 为搜索框安装事件过滤器
第 3 步 为输入框添加格式校验和错误提示
第 4 步 定义 Assignment 领域对象
第 5 步 实现只读 AssignmentTableModel
第 6 步 接入 QTableView、搜索、排序和选择
第 7 步 完成内存中的新增、删除和刷新流程

12. 常见问题

修改 QVector 后表格没有刷新

不能只修改底层容器。插入、删除、重置和局部更新都要使用 QAbstractItemModel 对应的通知接口。

排序后删除了错误记录

QTableView 返回的是代理模型索引。调用 mapToSource() 后,才能访问源模型中的正确行。

eventFilter 拦截后控件失去正常行为

只有明确要停止传播时才返回 true。其余情况调用父类实现并返回结果。

正则验证通过但数据仍然不合理

格式正确不代表业务正确。控件级验证之外,还要检查空白、重复、日期关系等规则。

所有代码仍在 MainWindow

先把领域对象和 TableModel 提取成独立文件,再把验证函数移动到 Service。每次只拆一类职责,避免一次性重构失控。

13. 本章验收清单

  • 能说明事件与信号的区别;
  • 未保存数据时关闭窗口会提示确认;
  • 搜索框按 Esc 可以清空;
  • 输入格式和业务规则分别验证;
  • 表格使用 QTableView 而不是把 QTableWidget 当数据库;
  • 自定义模型正确实现行数、列数和数据显示;
  • 插入和删除使用模型通知接口;
  • 搜索和排序通过代理模型完成;
  • 排序后编辑或删除能够映射到正确源数据;
  • 项目已经形成 UI、Model 和 Service 的初步边界。

下一章将把内存数据替换为 SQLite,并加入 QSettings、JSON 导入导出和可靠的错误处理,形成真正的数据持久化闭环。

官方参考:Qt 事件系统Model/View 教程QValidator

上一篇:第 2 章:主窗口、布局与界面原型 | 下一篇:第 4 章:文件、设置与 SQLite 数据闭环