教学示例补 5 个:3 个坑点复现 + QTableWidget + Day5 学员管理器参考实现

- pitfall_stack_widget / pitfall_lambda_capture / pitfall_double_connect:
  默认跑正确写法(离屏验证退出码 0),--crash / --dangle 开关课堂现场触发
  真实崩溃/悬空访问
- table_widget:QTableWidget 单元格事件最小示例(新安排 Day4「表格事件」)
- student_manager:Day5 综合案例参考实现,离屏自测覆盖 JSON round-trip
  与 QThread 后台导入闭环
- 均接入 examples/CMakeLists.txt 的 add_teaching_example,手工维护区不影响生成器
This commit is contained in:
张宗平
2026-07-05 12:49:20 +08:00
parent 6640baffae
commit 6d1895d696
11 changed files with 798 additions and 1 deletions
@@ -0,0 +1,52 @@
# 学员信息管理器 —— Day5 综合案例参考实现
> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) §3「Day5 综合项目案例讲解」。
> 教师侧参考答案:把 Day1–4 作业线(计算器→主窗口化→JSON 落盘)收束成一个完整
> 小软件。手工维护,非生成产物。
## 覆盖的知识点(讲解时逐条回指)
| 环节 | 知识点 | 首次出现 |
|---|---|---|
| 主窗口骨架 | QMainWindow 菜单/工具栏/状态栏/`setCentralWidget` | Day3 上午 |
| 列表展示 | QTableWidget + item 所有权 | Day4 上午 |
| 添加记录 | 模态 QDialog `exec()` + QFormLayout | Day3 上午/下午 |
| 通信 | 信号槽(成员函数槽 + 带 context 的 lambda 槽) | Day2 |
| 数据层 | Student ↔ QJson 互逆序列化,round-trip 校验 | Day4 下午 |
| 路径 | `applicationDirPath()` 拼数据文件路径(不用相对路径) | Day4 ★坑卡 |
| 健壮性 | `open()` 返回值、`QJsonParseError` 都检查 | Day4 坑卡 |
| **提升** | QThread worker-object 模式后台导入,界面不冻结 | Day5 新授 |
QThread 三件套在 `startImport()` 里全部有注释标注:worker 不给 parent、
`moveToThread``start()` 之前、跨线程信号→主线程槽自动 Queued(`addRow`
安全地在 GUI 线程执行)。
## 验证方式(自测模式)
```bash
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_student_manager
# 自测流程:添加 2 条 → 保存 JSON → 清空 → 读回校验 →
# 后台线程导入 3 条 → 总数校验 5 条;全过退出码 0,任一步失败退出码 1
tools/run_qt.sh qt_student_manager # 有 X 环境:完整交互(菜单/工具栏/对话框/后台导入)
```
有显示环境时数据保存在可执行文件旁的 `students.json`(状态栏显示完整路径)。
## 课堂讲解建议(对应 OUTLINE_4DAY.md §3 时段表)
1. 上午先讲界面骨架与数据层(自顶向下:先跑成品,再拆 MainWindow → 数据层);
2. 下午「后台导入」是提升重点:先把 `msleep` 挪进主线程按钮槽里演示界面冻结,
再回到 worker 版本对比——「卡与不卡」亲眼见一次胜过讲十分钟;
3. 每讲完一段,回指上表对应的 Day1–4 知识点,让学员意识到「项目 = 已学内容的组装」。
## 练习方向(供 Day6–10 项目热身)
- 加「删除选中行」(`currentRow` + `removeRow`);
- 保存前若有未保存修改,关窗时弹确认框(`closeEvent` + `QMessageBox::question`);
- 把成绩列改为按数值排序(提示:`QTableWidgetItem::setData(Qt::EditRole, double)`)。
## 参考
- 前身作业:[`../json_io/`](../json_io/README.md)Day4 数据落盘)、
[`../table_widget/`](../table_widget/README.md)Day4 表格)、
[`../qthread_worker/`](../qthread_worker/README.md)worker 模式最小版)
@@ -0,0 +1,284 @@
// ============================================================================
// qt_student_manager —— Day5 综合案例参考实现:学员信息管理器
// 对应教案:OUTLINE_4DAY.md §3Day5 综合项目案例讲解)——把 Day1–4 作业线
// 收束成一个完整小软件:QMainWindow 骨架 + QTableWidget 列表 + 增改对话框 +
// JSON 落盘读回 + QThread 后台导入不卡界面。
// 配套文档:docs/teaching/examples/student_manager/README.md
//
// 离屏验证(自测模式):QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_student_manager
// 自测流程:添加 2 条记录 → 保存 JSON → 清空 → 读回校验 → 后台导入 3 条 →
// 总数校验,全部通过退出码 0,任一步失败退出码 1。
// ============================================================================
#include <QAction>
#include <QApplication>
#include <QDebug>
#include <QDialog>
#include <QDialogButtonBox>
#include <QFile>
#include <QFormLayout>
#include <QHeaderView>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QLineEdit>
#include <QMainWindow>
#include <QMenuBar>
#include <QMessageBox>
#include <QStatusBar>
#include <QTableWidget>
#include <QThread>
#include <QTimer>
#include <QToolBar>
// ---------------------------------------------------------------- 数据层 ----
struct Student {
QString name;
double score = 0.0;
QString tag;
};
// 序列化/反序列化是一对互逆操作(Day4):正确性标准 = 写出去再读回来数据一致。
static QJsonObject toJson(const Student& s) {
return QJsonObject{{"name", s.name}, {"score", s.score}, {"tag", s.tag}};
}
static Student fromJson(const QJsonObject& obj) {
// 类型不匹配时 toString()/toDouble() 不报错只给默认值(Day4 坑卡)——
// 教学示例里字段固定,生产代码应先 contains()+isDouble() 逐字段校验。
return Student{obj["name"].toString(), obj["score"].toDouble(), obj["tag"].toString()};
}
// ---------------------------------------------------------- 添加记录对话框 ----
class AddStudentDialog : public QDialog {
Q_OBJECT
public:
explicit AddStudentDialog(QWidget* parent = nullptr) : QDialog(parent) {
setWindowTitle(QStringLiteral("添加学员"));
m_name = new QLineEdit(this);
m_score = new QLineEdit(this);
m_tag = new QLineEdit(this);
auto* buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto* form = new QFormLayout(this);
form->addRow(QStringLiteral("姓名"), m_name);
form->addRow(QStringLiteral("成绩"), m_score);
form->addRow(QStringLiteral("标签"), m_tag);
form->addRow(buttons);
}
Student student() const {
return Student{m_name->text(), m_score->text().toDouble(), m_tag->text()};
}
private:
QLineEdit* m_name;
QLineEdit* m_score;
QLineEdit* m_tag;
};
// ------------------------------------------------------ 后台导入 worker ----
// worker-object 模式(而非继承 QThread):耗时活儿放进 worker,把 worker
// moveToThread 到工作线程;worker 与界面之间只通过信号槽通信——跨线程连接
// 自动变 Queued,槽在接收者所属线程执行,天然不碰 GUI 线程以外的控件。
class ImportWorker : public QObject {
Q_OBJECT
public slots:
void doImport(int rows) {
for (int i = 1; i <= rows; ++i) {
QThread::msleep(20); // 模拟耗时 IO;放主线程就是界面冻结(Day4 埋点)
emit rowReady(QStringLiteral("导入学员%1").arg(i), 60.0 + i, QStringLiteral("导入"));
}
emit finished();
}
signals:
void rowReady(const QString& name, double score, const QString& tag);
void finished();
};
// -------------------------------------------------------------- 主窗口 ----
class MainWindow : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget* parent = nullptr) : QMainWindow(parent) {
setWindowTitle(QStringLiteral("学员信息管理器"));
// QMainWindow 不是大号 QWidget:中心部件必须显式安放(Day3 坑卡)
m_table = new QTableWidget(0, 3, this);
m_table->setHorizontalHeaderLabels(
{QStringLiteral("姓名"), QStringLiteral("成绩"), QStringLiteral("标签")});
m_table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
setCentralWidget(m_table);
auto* actAdd = new QAction(QStringLiteral("添加记录"), this);
auto* actSave = new QAction(QStringLiteral("保存"), this);
auto* actLoad = new QAction(QStringLiteral("读取"), this);
m_actImport = new QAction(QStringLiteral("后台导入"), this);
auto* actAbout = new QAction(QStringLiteral("关于"), this);
QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("文件(&F)"));
fileMenu->addAction(actSave);
fileMenu->addAction(actLoad);
QMenu* recordMenu = menuBar()->addMenu(QStringLiteral("记录(&R)"));
recordMenu->addAction(actAdd);
recordMenu->addAction(m_actImport);
menuBar()->addMenu(QStringLiteral("帮助(&H)"))->addAction(actAbout);
QToolBar* bar = addToolBar(QStringLiteral("main"));
bar->addAction(actAdd);
bar->addAction(actSave);
bar->addAction(actLoad);
bar->addAction(m_actImport);
// QAction 显示出来 ≠ 生效:忘 connect 就是「菜单点了没反应」(Day3 坑卡)
connect(actAdd, &QAction::triggered, this, &MainWindow::onAdd);
connect(actSave, &QAction::triggered, this, [this] { saveTo(m_dataPath); });
connect(actLoad, &QAction::triggered, this, [this] { loadFrom(m_dataPath); });
connect(m_actImport, &QAction::triggered, this, &MainWindow::onImport);
connect(actAbout, &QAction::triggered, this, [this] {
QMessageBox::about(this, QStringLiteral("关于"),
QStringLiteral("Day5 综合案例参考实现:Day1–4 全部知识点的收束。"));
});
// 相对路径随工作目录漂移(Day4 坑卡)——一律基于 applicationDirPath 拼路径
m_dataPath = QCoreApplication::applicationDirPath() + "/students.json";
statusBar()->showMessage(QStringLiteral("数据文件:") + m_dataPath);
}
int rowCount() const { return m_table->rowCount(); }
void clearAll() { m_table->setRowCount(0); }
void addRow(const Student& s) {
const int row = m_table->rowCount();
m_table->insertRow(row);
m_table->setItem(row, 0, new QTableWidgetItem(s.name));
m_table->setItem(row, 1, new QTableWidgetItem(QString::number(s.score)));
m_table->setItem(row, 2, new QTableWidgetItem(s.tag));
}
bool saveTo(const QString& path) {
QJsonArray arr;
for (int row = 0; row < m_table->rowCount(); ++row) {
arr.append(toJson(Student{m_table->item(row, 0)->text(),
m_table->item(row, 1)->text().toDouble(),
m_table->item(row, 2)->text()}));
}
QFile file(path);
if (!file.open(QIODevice::WriteOnly)) { // open() 必须检查(Day4 坑卡)
statusBar()->showMessage(QStringLiteral("保存失败:打不开 ") + path);
return false;
}
file.write(QJsonDocument(arr).toJson(QJsonDocument::Indented));
statusBar()->showMessage(QStringLiteral("已保存 %1 条记录").arg(arr.size()));
return true;
}
bool loadFrom(const QString& path) {
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
statusBar()->showMessage(QStringLiteral("读取失败:打不开 ") + path);
return false;
}
QJsonParseError err; // 不检查 ParseError = 坏文件静默变空表(Day4 坑卡)
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &err);
if (err.error != QJsonParseError::NoError || !doc.isArray()) {
statusBar()->showMessage(QStringLiteral("JSON 解析失败:") + err.errorString());
return false;
}
clearAll();
const QJsonArray arr = doc.array();
for (const QJsonValue& v : arr) addRow(fromJson(v.toObject()));
statusBar()->showMessage(QStringLiteral("已读取 %1 条记录").arg(arr.size()));
return true;
}
void startImport(int rows) {
m_actImport->setEnabled(false); // 防重复启动(也防重复 connect,Day2 坑卡)
auto* thread = new QThread(this);
auto* worker = new ImportWorker; // 不能给 parent:有 parent 的对象禁止 moveToThread
worker->moveToThread(thread); // 必须在 start() 之前(Day5 讲授点)
connect(thread, &QThread::started, worker, [worker, rows] { worker->doImport(rows); });
// 跨线程信号 → 主线程槽:自动 Queued,addRow 在 GUI 线程安全执行
connect(worker, &ImportWorker::rowReady, this,
[this](const QString& name, double score, const QString& tag) {
addRow(Student{name, score, tag});
});
connect(worker, &ImportWorker::finished, this, [this] {
m_actImport->setEnabled(true);
statusBar()->showMessage(QStringLiteral("后台导入完成"));
emit importFinished();
});
connect(worker, &ImportWorker::finished, thread, &QThread::quit);
connect(thread, &QThread::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QObject::deleteLater);
thread->start();
}
signals:
void importFinished();
private slots:
void onAdd() {
AddStudentDialog dlg(this); // 模态:exec() 阻塞等结果(Day3
if (dlg.exec() == QDialog::Accepted) addRow(dlg.student());
}
void onImport() { startImport(10); }
private:
QTableWidget* m_table = nullptr;
QAction* m_actImport = nullptr;
QString m_dataPath;
};
// ------------------------------------------------------------ 自测入口 ----
// 离屏自测:走一遍「添加→保存→清空→读回→后台导入」完整闭环并校验。
static void runSelfTest(MainWindow* win) {
const QString path = QCoreApplication::applicationDirPath() + "/students_selftest.json";
win->addRow({QStringLiteral("张三"), 91.5, QStringLiteral("组长")});
win->addRow({QStringLiteral("李四"), 88.0, QStringLiteral("美工")});
if (!win->saveTo(path)) { QCoreApplication::exit(1); return; }
win->clearAll();
if (!win->loadFrom(path) || win->rowCount() != 2) {
qCritical() << "自测失败:JSON 读回记录数 =" << win->rowCount() << "预期 2";
QCoreApplication::exit(1);
return;
}
qInfo() << "自测 1/2 通过:JSON 写入→清空→读回,2 条记录完整";
QObject::connect(win, &MainWindow::importFinished, win, [win, path] {
QFile::remove(path);
if (win->rowCount() != 5) {
qCritical() << "自测失败:导入后总数 =" << win->rowCount() << "预期 5";
QCoreApplication::exit(1);
return;
}
qInfo() << "自测 2/2 通过:后台线程导入 3 条,总计 5 条,界面未阻塞";
QCoreApplication::exit(0);
});
win->startImport(3);
}
int main(int argc, char* argv[]) {
QApplication app(argc, argv);
MainWindow win;
win.resize(560, 360);
win.show();
if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
QTimer::singleShot(0, &win, [&win] { runSelfTest(&win); });
}
return app.exec();
}
#include "qt_student_manager.moc"