// ============================================================================ // qt_student_manager —— Day5 综合案例参考实现:学员信息管理器 // 对应教案:OUTLINE_4DAY.md §3(Day5 综合项目案例讲解)——把 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // ---------------------------------------------------------------- 数据层 ---- 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); auto* actRemoveSelected = 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); recordMenu->addAction(actRemoveSelected); menuBar()->addMenu(QStringLiteral("帮助(&H)"))->addAction(actAbout); QToolBar* bar = addToolBar(QStringLiteral("main")); bar->addAction(actAdd); bar->addAction(actSave); bar->addAction(actLoad); bar->addAction(actRemoveSelected); 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(actRemoveSelected, &QAction::triggered, this, &MainWindow::onRemoveSelected); 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)); } void onRemoveSelected() { // for (int row = m_table->selectedRows().size() - 1; row >= 0; --row) { // m_table->removeRow(m_table->selectedRows().at(row).row()); // } } 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"