Files
scuc-qt-course/docs/teaching/examples/student_manager/qt_student_manager.cpp
T
张宗平 6d1895d696 教学示例补 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,手工维护区不影响生成器
2026-07-05 12:49:20 +08:00

285 lines
12 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 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"