From 4ad876fe5a4a9342b747772c55899974bf53703f Mon Sep 17 00:00:00 2001 From: DeevEV Date: Thu, 21 May 2026 13:56:31 +0300 Subject: [PATCH] lab-3: add Qt GUI client with Singleton and Adapter patterns --- README.md | 40 ++++ lab-3/CMakeLists.txt | 76 +++++++ lab-3/api_client.cpp | 158 ++++++++++++++ lab-3/api_client.h | 80 +++++++ lab-3/icons/app.svg | 9 + lab-3/iquiz_adapter.h | 16 ++ lab-3/lab-3_ru_RU.ts | 3 + lab-3/main.cpp | 29 +++ lab-3/mainwindow.cpp | 414 ++++++++++++++++++++++++++++++++++++ lab-3/mainwindow.h | 46 ++++ lab-3/mainwindow.ui | 157 ++++++++++++++ lab-3/quiz.h | 20 ++ lab-3/quiz_json_adapter.cpp | 39 ++++ lab-3/quiz_json_adapter.h | 10 + lab-3/resources.qrc | 5 + 15 files changed, 1102 insertions(+) create mode 100644 lab-3/CMakeLists.txt create mode 100644 lab-3/api_client.cpp create mode 100644 lab-3/api_client.h create mode 100644 lab-3/icons/app.svg create mode 100644 lab-3/iquiz_adapter.h create mode 100644 lab-3/lab-3_ru_RU.ts create mode 100644 lab-3/main.cpp create mode 100644 lab-3/mainwindow.cpp create mode 100644 lab-3/mainwindow.h create mode 100644 lab-3/mainwindow.ui create mode 100644 lab-3/quiz.h create mode 100644 lab-3/quiz_json_adapter.cpp create mode 100644 lab-3/quiz_json_adapter.h create mode 100644 lab-3/resources.qrc diff --git a/README.md b/README.md index 13001ab..e629761 100644 --- a/README.md +++ b/README.md @@ -38,3 +38,43 @@ docker compose up ``` Перед запуском `web_lite` и `web_pg` нужен файл `.env` — пример в `lab-2/local/.env`. + +--- + +## Лабораторная работа 3 — Qt GUI клиент + +**Расположение:** `lab-3/` + +Qt6-приложение на C++ — графический клиент к REST API из Лаб-1. +Демонстрирует паттерны проектирования **Singleton** и **Adapter**. + +**Стек:** Qt 6, C++17, QNetworkAccessManager, CMake + +**Паттерны:** +- **Singleton** — `ApiClient` существует в единственном экземпляре (Meyers' Singleton) +- **Adapter** — `QuizJsonAdapter` конвертирует `QJsonObject` → `Quiz` + +**5 HTTP-методов:** + +| Кнопка | Метод | Endpoint | +|--------|-------|----------| +| Все тесты | GET | `/api/quiz/` | +| По ID | GET | `/api/quiz/:id/` | +| Создать | POST | `/api/quiz/` | +| Обновить | PUT | `/api/quiz/:id/` | +| Удалить | DELETE | `/api/quiz/:id/` | + +**Сборка:** +```bash +cd lab-3 +cmake -B build +cmake --build build +``` + +**Запуск бэкенда перед использованием:** +```bash +cd lab-2/web_lite +docker compose up +``` + +Приложение подключается к `http://localhost:80`. diff --git a/lab-3/CMakeLists.txt b/lab-3/CMakeLists.txt new file mode 100644 index 0000000..426120d --- /dev/null +++ b/lab-3/CMakeLists.txt @@ -0,0 +1,76 @@ +cmake_minimum_required(VERSION 3.16) + +project(lab-3 VERSION 0.1 LANGUAGES CXX) + +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets Network LinguistTools) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets Network LinguistTools) + +set(TS_FILES lab-3_ru_RU.ts) + +set(PROJECT_SOURCES + main.cpp + mainwindow.cpp + mainwindow.h + mainwindow.ui + quiz.h + iquiz_adapter.h + quiz_json_adapter.h + quiz_json_adapter.cpp + api_client.h + api_client.cpp + resources.qrc + ${TS_FILES} +) + +if(${QT_VERSION_MAJOR} GREATER_EQUAL 6) + qt_add_executable(lab-3 + MANUAL_FINALIZATION + ${PROJECT_SOURCES} + ) + qt_create_translation(QM_FILES ${CMAKE_SOURCE_DIR} ${TS_FILES}) +else() + if(ANDROID) + add_library(lab-3 SHARED + ${PROJECT_SOURCES} + ) + else() + add_executable(lab-3 + ${PROJECT_SOURCES} + ) + endif() + qt5_create_translation(QM_FILES ${CMAKE_SOURCE_DIR} ${TS_FILES}) +endif() + +target_link_libraries(lab-3 PRIVATE + Qt${QT_VERSION_MAJOR}::Widgets + Qt${QT_VERSION_MAJOR}::Network +) + +if(${QT_VERSION} VERSION_LESS 6.1.0) + set(BUNDLE_ID_OPTION MACOSX_BUNDLE_GUI_IDENTIFIER com.example.lab-3) +endif() +set_target_properties(lab-3 PROPERTIES + ${BUNDLE_ID_OPTION} + MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} + MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR} + MACOSX_BUNDLE TRUE + WIN32_EXECUTABLE TRUE +) + +include(GNUInstallDirs) +install(TARGETS lab-3 + BUNDLE DESTINATION . + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) + +if(QT_VERSION_MAJOR EQUAL 6) + qt_finalize_executable(lab-3) +endif() diff --git a/lab-3/api_client.cpp b/lab-3/api_client.cpp new file mode 100644 index 0000000..d48fb8f --- /dev/null +++ b/lab-3/api_client.cpp @@ -0,0 +1,158 @@ +#include "api_client.h" +#include "quiz_json_adapter.h" + +#include +#include +#include +#include +#include +#include + +// Реализация паттерна Синглтон +ApiClient &ApiClient::instance() +{ + static ApiClient s_instance; + return s_instance; +} + +ApiClient::ApiClient(QObject *parent) + : QObject(parent) + , m_manager(new QNetworkAccessManager(this)) + , m_adapter(new QuizJsonAdapter()) + , m_baseUrl("http://localhost:80") +{ +} + +ApiClient::~ApiClient() +{ + delete m_adapter; +} + +// fetchQuizzes — GET /api/quiz/ +void ApiClient::fetchQuizzes() +{ + QNetworkRequest request(QUrl(m_baseUrl + "/api/quiz/")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QNetworkReply *reply = m_manager->get(request); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + emit errorOccurred(reply->errorString()); + return; + } + + // reply->readAll() — читаем весь буфер HTTP-ответа + QByteArray rawData = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(rawData); + + if (!doc.isArray()) { + emit errorOccurred("Unexpected JSON format: array expected"); + return; + } + + QList quizzes = m_adapter->toQuizList(doc.array()); + emit quizzesReceived(quizzes); + }); +} + +// fetchQuiz — GET /api/quiz/:id/ +void ApiClient::fetchQuiz(int id) +{ + QNetworkRequest request(QUrl(m_baseUrl + "/api/quiz/" + QString::number(id) + "/")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QNetworkReply *reply = m_manager->get(request); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + emit errorOccurred(reply->errorString()); + return; + } + + QByteArray rawData = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(rawData); + + if (!doc.isObject()) { + emit errorOccurred("Unexpected JSON format: object expected"); + return; + } + + emit quizReceived(m_adapter->toQuiz(doc.object())); + }); +} + +// createQuiz — POST /api/quiz/ +void ApiClient::createQuiz(const QJsonObject &data) +{ + QNetworkRequest request(QUrl(m_baseUrl + "/api/quiz/")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QByteArray body = QJsonDocument(data).toJson(QJsonDocument::Compact); + QNetworkReply *reply = m_manager->post(request, body); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + emit errorOccurred(reply->errorString()); + return; + } + QByteArray rawData = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(rawData); + if (!doc.isObject()) { + emit errorOccurred("POST: unexpected JSON format"); + return; + } + emit quizCreated(m_adapter->toQuiz(doc.object())); + }); +} + +// updateQuiz — PUT /api/quiz/:id/ +void ApiClient::updateQuiz(int id, const QJsonObject &data) +{ + QNetworkRequest request(QUrl(m_baseUrl + "/api/quiz/" + QString::number(id) + "/")); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + + QByteArray body = QJsonDocument(data).toJson(QJsonDocument::Compact); + QNetworkReply *reply = m_manager->put(request, body); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + emit errorOccurred(reply->errorString()); + return; + } + QByteArray rawData = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(rawData); + if (!doc.isObject()) { + emit errorOccurred("PUT: unexpected JSON format"); + return; + } + emit quizUpdated(m_adapter->toQuiz(doc.object())); + }); +} + +// deleteQuiz — DELETE /api/quiz/:id/ +void ApiClient::deleteQuiz(int id) +{ + QNetworkRequest request(QUrl(m_baseUrl + "/api/quiz/" + QString::number(id) + "/")); + QNetworkReply *reply = m_manager->deleteResource(request); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + if (reply->error() != QNetworkReply::NoError) { + emit errorOccurred(reply->errorString()); + return; + } + QByteArray rawData = reply->readAll(); + QJsonDocument doc = QJsonDocument::fromJson(rawData); + QString message = "Тест удалён"; + if (doc.isObject() && doc.object().contains("detail")) + message = doc.object()["detail"].toString(); + emit quizDeleted(message); + }); +} diff --git a/lab-3/api_client.h b/lab-3/api_client.h new file mode 100644 index 0000000..5518fdb --- /dev/null +++ b/lab-3/api_client.h @@ -0,0 +1,80 @@ +/* + * api_client.h — HTTP-клиент, реализующий паттерн «Синглтон» + * + * ╔══════════════════════════════════════════════════════════════════╗ + * ║ ПАТТЕРН «СИНГЛТОН» (Singleton) ║ + * ╠══════════════════════════════════════════════════════════════════╣ + * ║ Задача: гарантировать, что в приложении существует ровно ║ + * ║ ОДИН экземпляр QNetworkAccessManager. Создание нескольких ║ + * ║ менеджеров приводит к дублированию соединений и cookie-хранилищ.║ + * ║ ║ + * ║ Реализация (Meyers' Singleton): ║ + * ║ • статическая локальная переменная в instance() ║ + * ║ • конструктор объявлен private ║ + * ║ • копирующий конструктор и operator= удалены (= delete) ║ + * ╚══════════════════════════════════════════════════════════════════╝ + * + * ApiClient также является «клиентом» паттерна «Адаптер»: + * он хранит указатель на IQuizAdapter и использует его + * для преобразования ответов сервера в Quiz-объекты, + * не зная о деталях реализации парсинга. + * + * Коммуникация с вызывающим кодом — через механизм сигналов Qt: + * quizzesReceived — список тестов успешно получен и десериализован + * quizReceived — один тест успешно получен и десериализован + * quizCreated — тест создан (POST) + * quizUpdated — тест обновлён (PUT) + * quizDeleted — тест удалён (DELETE) + * errorOccurred — произошла сетевая ошибка или ошибка парсинга + */ + +#pragma once + +#include +#include +#include +#include +#include "quiz.h" +#include "iquiz_adapter.h" + +class ApiClient : public QObject +{ + Q_OBJECT + +public: + static ApiClient &instance(); + + // GET /api/quiz/ + void fetchQuizzes(); + + // GET /api/quiz/:id/ + void fetchQuiz(int id); + + // POST /api/quiz/ + void createQuiz(const QJsonObject &data); + + // PUT /api/quiz/:id/ + void updateQuiz(int id, const QJsonObject &data); + + // DELETE /api/quiz/:id/ + void deleteQuiz(int id); + +signals: + void quizzesReceived(QList quizzes); + void quizReceived(Quiz quiz); + void quizCreated(Quiz quiz); + void quizUpdated(Quiz quiz); + void quizDeleted(QString message); + void errorOccurred(QString error); + +private: + explicit ApiClient(QObject *parent = nullptr); + ~ApiClient() override; + + ApiClient(const ApiClient &) = delete; + ApiClient &operator=(const ApiClient &) = delete; + + QNetworkAccessManager *m_manager; + IQuizAdapter *m_adapter; + QString m_baseUrl; +}; diff --git a/lab-3/icons/app.svg b/lab-3/icons/app.svg new file mode 100644 index 0000000..20fbede --- /dev/null +++ b/lab-3/icons/app.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/lab-3/iquiz_adapter.h b/lab-3/iquiz_adapter.h new file mode 100644 index 0000000..467dee5 --- /dev/null +++ b/lab-3/iquiz_adapter.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include "quiz.h" + +class IQuizAdapter +{ +public: + virtual ~IQuizAdapter() = default; + + virtual Quiz toQuiz(const QJsonObject &json) const = 0; + + virtual QList toQuizList(const QJsonArray &json) const = 0; +}; diff --git a/lab-3/lab-3_ru_RU.ts b/lab-3/lab-3_ru_RU.ts new file mode 100644 index 0000000..743f57f --- /dev/null +++ b/lab-3/lab-3_ru_RU.ts @@ -0,0 +1,3 @@ + + + diff --git a/lab-3/main.cpp b/lab-3/main.cpp new file mode 100644 index 0000000..53b6e89 --- /dev/null +++ b/lab-3/main.cpp @@ -0,0 +1,29 @@ +#include "mainwindow.h" + +#include +#include +#include + +int main(int argc, char *argv[]) +{ + QApplication a(argc, argv); + + QTranslator translator; + const QStringList uiLanguages = QLocale::system().uiLanguages(); + for (const QString &locale : uiLanguages) { + const QString baseName = "lab-3_" + QLocale(locale).name(); + if (translator.load(":/i18n/" + baseName)) { + a.installTranslator(&translator); + break; + } + } + + int result = 0; + { + MainWindow w; + w.show(); + result = a.exec(); + } + + return result; +} diff --git a/lab-3/mainwindow.cpp b/lab-3/mainwindow.cpp new file mode 100644 index 0000000..ec631f3 --- /dev/null +++ b/lab-3/mainwindow.cpp @@ -0,0 +1,414 @@ +#include "mainwindow.h" +#include "ui_mainwindow.h" +#include "api_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const int COL_ID = 0; +static const int COL_TITLE = 1; +static const int COL_AUTHOR = 2; +static const int COL_TIME = 3; +static const int COL_PUBLISHED = 4; +static const int COL_CREATED = 5; +static const int COL_DESCRIPTION = 6; +static const int COL_COUNT = 7; + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) + , ui(new Ui::MainWindow) +{ + ui->setupUi(this); + setWindowIcon(QIcon(":/icons/app.svg")); + + setupTable(); + + ui->tabWidget->tabBar()->hide(); + + // Кнопки переключения вида + auto *btnTable = new QPushButton("Таблица", this); + auto *btnText = new QPushButton("Текст", this); + + QString activeStyle = "QPushButton{background:#1f2937;color:#f9fafb;border:1px solid #4b5563;border-radius:4px;padding:2px 12px;font-weight:bold;}"; + QString inactiveStyle = "QPushButton{background:#111827;color:#6b7280;border:1px solid #374151;border-radius:4px;padding:2px 12px;}"; + + btnTable->setStyleSheet(activeStyle); + btnText->setStyleSheet(inactiveStyle); + btnTable->setFixedHeight(22); + btnText->setFixedHeight(22); + + connect(btnTable, &QPushButton::clicked, this, [=]() { + ui->tabWidget->setCurrentIndex(0); + btnTable->setStyleSheet(activeStyle); + btnText->setStyleSheet(inactiveStyle); + }); + connect(btnText, &QPushButton::clicked, this, [=]() { + ui->tabWidget->setCurrentIndex(1); + btnText->setStyleSheet(activeStyle); + btnTable->setStyleSheet(inactiveStyle); + }); + + statusBar()->addPermanentWidget(btnTable); + statusBar()->addPermanentWidget(btnText); + statusBar()->setStyleSheet("QStatusBar::item { border: none; }"); + + ApiClient &client = ApiClient::instance(); + connect(&client, &ApiClient::quizzesReceived, this, &MainWindow::onQuizzesReceived); + connect(&client, &ApiClient::quizReceived, this, &MainWindow::onQuizReceived); + connect(&client, &ApiClient::quizCreated, this, &MainWindow::onQuizCreated); + connect(&client, &ApiClient::quizUpdated, this, &MainWindow::onQuizUpdated); + connect(&client, &ApiClient::quizDeleted, this, &MainWindow::onQuizDeleted); + connect(&client, &ApiClient::errorOccurred, this, &MainWindow::onError); +} + +MainWindow::~MainWindow() +{ + delete ui; +} + +void MainWindow::setupTable() +{ + ui->tableWidget->setColumnCount(COL_COUNT); + ui->tableWidget->setHorizontalHeaderLabels({ + "ID", "Название", "Автор", "Время (мин)", "Опубликован", "Создан", "Описание" + }); + + QHeaderView *header = ui->tableWidget->horizontalHeader(); + header->setSectionResizeMode(COL_ID, QHeaderView::ResizeToContents); + header->setSectionResizeMode(COL_TITLE, QHeaderView::Stretch); + header->setSectionResizeMode(COL_AUTHOR, QHeaderView::ResizeToContents); + header->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents); + header->setSectionResizeMode(COL_PUBLISHED, QHeaderView::ResizeToContents); + header->setSectionResizeMode(COL_CREATED, QHeaderView::ResizeToContents); + header->setSectionResizeMode(COL_DESCRIPTION, QHeaderView::Stretch); + + ui->tableWidget->verticalHeader()->setVisible(false); + ui->tableWidget->setWordWrap(true); + ui->tableWidget->setTextElideMode(Qt::ElideNone); + ui->tableWidget->verticalHeader()->setSectionResizeMode(QHeaderView::Interactive); + ui->tableWidget->verticalHeader()->setDefaultSectionSize(52); + ui->tableWidget->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + ui->tableWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); +} + +// ── Обработчики кнопок ──────────────────────────────────────────────────────── +void MainWindow::on_btnGetAll_clicked() +{ + ++m_requestCount; + setStatus(QString("GET /api/quiz/… | Запросов: %1").arg(m_requestCount)); + ApiClient::instance().fetchQuizzes(); +} + +void MainWindow::on_btnGetById_clicked() +{ + bool ok; + int id = QInputDialog::getInt(this, "Get Quiz by ID", "ID теста:", 1, 1, 99999, 1, &ok); + if (!ok) return; + ++m_requestCount; + setStatus(QString("GET /api/quiz/%1/… | Запросов: %2").arg(id).arg(m_requestCount)); + ApiClient::instance().fetchQuiz(id); +} + +void MainWindow::on_btnCreate_clicked() +{ + QDialog dlg(this); + dlg.setWindowTitle("Создать тест — POST /api/quiz/"); + dlg.setMinimumWidth(400); + + QLineEdit *titleEdit = new QLineEdit(&dlg); + QTextEdit *descEdit = new QTextEdit(&dlg); + QLineEdit *authorEdit = new QLineEdit(&dlg); + QSpinBox *timeLimitSpin = new QSpinBox(&dlg); + QCheckBox *publishedCheck = new QCheckBox("Опубликован", &dlg); + + descEdit->setMaximumHeight(80); + timeLimitSpin->setRange(1, 600); + timeLimitSpin->setValue(30); + timeLimitSpin->setSuffix(" мин"); + + { + auto *layout = new QVBoxLayout(); + auto *form = new QFormLayout(); + form->addRow("Название:", titleEdit); + form->addRow("Описание:", descEdit); + form->addRow("Автор:", authorEdit); + form->addRow("Время:", timeLimitSpin); + form->addRow("", publishedCheck); + layout->addLayout(form); + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); + layout->addWidget(buttons); + dlg.setLayout(layout); + } + + if (dlg.exec() != QDialog::Accepted) return; + + QJsonObject data; + data["title"] = titleEdit->text(); + data["description"] = descEdit->toPlainText(); + data["author"] = authorEdit->text(); + data["time_limit"] = timeLimitSpin->value(); + data["is_published"] = publishedCheck->isChecked(); + + ++m_requestCount; + setStatus(QString("POST /api/quiz/… | Запросов: %1").arg(m_requestCount)); + ApiClient::instance().createQuiz(data); +} + +void MainWindow::on_btnUpdate_clicked() +{ + bool ok; + int id = QInputDialog::getInt(this, "Update Quiz", "ID теста для обновления:", 1, 1, 99999, 1, &ok); + if (!ok) return; + + QDialog dlg(this); + dlg.setWindowTitle(QString("Обновить тест ID=%1 — PUT /api/quiz/%1/").arg(id)); + dlg.setMinimumWidth(400); + + QLineEdit *titleEdit = new QLineEdit(&dlg); + QTextEdit *descEdit = new QTextEdit(&dlg); + QLineEdit *authorEdit = new QLineEdit(&dlg); + QSpinBox *timeLimitSpin = new QSpinBox(&dlg); + QCheckBox *publishedCheck = new QCheckBox("Опубликован", &dlg); + + descEdit->setMaximumHeight(80); + timeLimitSpin->setRange(1, 600); + timeLimitSpin->setValue(30); + timeLimitSpin->setSuffix(" мин"); + + { + auto *layout = new QVBoxLayout(); + auto *form = new QFormLayout(); + form->addRow("Название:", titleEdit); + form->addRow("Описание:", descEdit); + form->addRow("Автор:", authorEdit); + form->addRow("Время:", timeLimitSpin); + form->addRow("", publishedCheck); + layout->addLayout(form); + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); + layout->addWidget(buttons); + dlg.setLayout(layout); + } + + if (dlg.exec() != QDialog::Accepted) return; + + QJsonObject data; + data["title"] = titleEdit->text(); + data["description"] = descEdit->toPlainText(); + data["author"] = authorEdit->text(); + data["time_limit"] = timeLimitSpin->value(); + data["is_published"] = publishedCheck->isChecked(); + + ++m_requestCount; + setStatus(QString("PUT /api/quiz/%1/… | Запросов: %2").arg(id).arg(m_requestCount)); + ApiClient::instance().updateQuiz(id, data); +} + +void MainWindow::on_btnDelete_clicked() +{ + bool ok; + int id = QInputDialog::getInt(this, "Delete Quiz", "ID теста для удаления:", 1, 1, 99999, 1, &ok); + if (!ok) return; + ++m_requestCount; + setStatus(QString("DELETE /api/quiz/%1/… | Запросов: %2").arg(id).arg(m_requestCount)); + ApiClient::instance().deleteQuiz(id); +} + +void MainWindow::on_btnClear_clicked() +{ + ui->tableWidget->setRowCount(0); + ui->outputEdit->clear(); + ui->lblStatus->setText("Результат:"); + setStatus("Очищено"); +} + +// ── Слоты ответов ApiClient ─────────────────────────────────────────────────── +void MainWindow::onQuizzesReceived(const QList &quizzes) +{ + showQuizzes(quizzes); + + ui->outputEdit->clear(); + ui->outputEdit->append(QString("Тестов получено: %1").arg(quizzes.size())); + for (const Quiz &q : quizzes) { + ui->outputEdit->append("─────────────────────────────"); + appendQuizText(q); + } + + ui->lblStatus->setText(QString("Результат: получено тестов: %1").arg(quizzes.size())); + setStatus(QString("Готово — получено %1 тестов | Запросов: %2").arg(quizzes.size()).arg(m_requestCount)); + qDebug() << "[GET ALL] count:" << quizzes.size(); +} + +void MainWindow::onQuizReceived(const Quiz &q) +{ + showSingleQuiz(q, "GET"); + + ui->outputEdit->clear(); + ui->outputEdit->append("GET — тест по ID"); + ui->outputEdit->append("─────────────────────────────"); + appendQuizText(q); + + ui->lblStatus->setText(QString("Результат: тест ID=%1").arg(q.id)); + setStatus(QString("Готово — тест ID=%1 | Запросов: %2").arg(q.id).arg(m_requestCount)); + qDebug() << "[GET] ID:" << q.id << q.title; +} + +void MainWindow::onQuizCreated(const Quiz &q) +{ + showSingleQuiz(q, "POST"); + + ui->outputEdit->clear(); + ui->outputEdit->append("POST — тест создан"); + ui->outputEdit->append("─────────────────────────────"); + appendQuizText(q); + + ui->lblStatus->setText(QString("Результат: тест создан, ID=%1").arg(q.id)); + setStatus(QString("Тест создан, ID=%1 | Запросов: %2").arg(q.id).arg(m_requestCount)); + qDebug() << "[CREATE] ID:" << q.id << q.title; +} + +void MainWindow::onQuizUpdated(const Quiz &q) +{ + showSingleQuiz(q, "PUT"); + + ui->outputEdit->clear(); + ui->outputEdit->append("PUT — тест обновлён"); + ui->outputEdit->append("─────────────────────────────"); + appendQuizText(q); + + ui->lblStatus->setText(QString("Результат: тест обновлён, ID=%1").arg(q.id)); + setStatus(QString("Тест обновлён, ID=%1 | Запросов: %2").arg(q.id).arg(m_requestCount)); + qDebug() << "[UPDATE] ID:" << q.id << q.title; +} + +void MainWindow::onQuizDeleted(const QString &message) +{ + ui->tableWidget->setRowCount(0); + ui->outputEdit->clear(); + ui->outputEdit->append("DELETE — успешно"); + ui->outputEdit->append(message); + + ui->lblStatus->setText(QString("Результат: %1").arg(message)); + setStatus(QString("Тест удалён | Запросов: %1").arg(m_requestCount)); + qDebug() << "[DELETE]" << message; +} + +void MainWindow::onError(const QString &error) +{ + ui->outputEdit->append( + QString("Ошибка: %1").arg(error)); + ui->lblStatus->setText(QString("Результат: Ошибка: %1").arg(error)); + setStatus(QString("Ошибка | Запросов: %1").arg(m_requestCount)); + qDebug() << "[ERROR]" << error; +} + +// ── Вспомогательные методы ──────────────────────────────────────────────────── +void MainWindow::showQuizzes(const QList &quizzes) +{ + ui->tableWidget->setRowCount(0); + ui->tableWidget->setRowCount(quizzes.size()); + + for (int i = 0; i < quizzes.size(); ++i) { + const Quiz &q = quizzes[i]; + + QColor rowColor = q.isPublished ? QColor("#052e16") : QColor("#1f2937"); + + auto makeItem = [&](const QString &text) { + auto *item = new QTableWidgetItem(text); + item->setBackground(rowColor); + item->setForeground(QColor("#f9fafb")); + item->setTextAlignment(Qt::AlignCenter); + return item; + }; + + ui->tableWidget->setItem(i, COL_ID, makeItem(QString::number(q.id))); + ui->tableWidget->setItem(i, COL_TITLE, makeItem(q.title)); + ui->tableWidget->setItem(i, COL_AUTHOR, makeItem(q.author)); + ui->tableWidget->setItem(i, COL_TIME, makeItem(QString::number(q.timeLimit))); + ui->tableWidget->setItem(i, COL_DESCRIPTION, makeItem(q.description)); + ui->tableWidget->setItem(i, COL_CREATED, makeItem(q.createdAt.toString("dd.MM.yyyy"))); + + auto *pubItem = new QTableWidgetItem(q.isPublished ? "Да" : "Нет"); + pubItem->setBackground(rowColor); + pubItem->setForeground(q.isPublished ? QColor("#4ade80") : QColor("#9ca3af")); + pubItem->setTextAlignment(Qt::AlignCenter); + QFont f = pubItem->font(); + f.setBold(true); + pubItem->setFont(f); + ui->tableWidget->setItem(i, COL_PUBLISHED, pubItem); + } +} + +void MainWindow::showSingleQuiz(const Quiz &q, const QString &operation) +{ + ui->tableWidget->setRowCount(1); + + QColor rowColor = QColor("#1f2937"); + if (operation == "POST") rowColor = QColor("#052e16"); + else if (operation == "PUT") rowColor = QColor("#422006"); + + auto makeItem = [&](const QString &text) { + auto *item = new QTableWidgetItem(text); + item->setBackground(rowColor); + item->setForeground(QColor("#f9fafb")); + item->setTextAlignment(Qt::AlignCenter); + return item; + }; + + ui->tableWidget->setItem(0, COL_ID, makeItem(QString::number(q.id))); + ui->tableWidget->setItem(0, COL_TITLE, makeItem(q.title)); + ui->tableWidget->setItem(0, COL_AUTHOR, makeItem(q.author)); + ui->tableWidget->setItem(0, COL_TIME, makeItem(QString::number(q.timeLimit))); + ui->tableWidget->setItem(0, COL_DESCRIPTION, makeItem(q.description)); + ui->tableWidget->setItem(0, COL_CREATED, makeItem(q.createdAt.toString("dd.MM.yyyy"))); + + auto *pubItem = new QTableWidgetItem(q.isPublished ? "Да" : "Нет"); + pubItem->setBackground(rowColor); + pubItem->setForeground(q.isPublished ? QColor("#4ade80") : QColor("#9ca3af")); + pubItem->setTextAlignment(Qt::AlignCenter); + QFont f = pubItem->font(); + f.setBold(true); + pubItem->setFont(f); + ui->tableWidget->setItem(0, COL_PUBLISHED, pubItem); +} + +void MainWindow::appendQuizText(const Quiz &q) +{ + ui->outputEdit->append(QString( + "ID: %1\n" + "Название: %2\n" + "Описание: %3\n" + "Автор: %4\n" + "Создан: %5\n" + "Время (мин): %6\n" + "Опубликован: %7" + ).arg(q.id) + .arg(q.title) + .arg(q.description) + .arg(q.author) + .arg(q.createdAt.toString(Qt::ISODate)) + .arg(q.timeLimit) + .arg(q.isPublished ? "Да" : "Нет")); +} + +void MainWindow::setStatus(const QString &msg) +{ + statusBar()->showMessage(msg); +} diff --git a/lab-3/mainwindow.h b/lab-3/mainwindow.h new file mode 100644 index 0000000..1a3506f --- /dev/null +++ b/lab-3/mainwindow.h @@ -0,0 +1,46 @@ +#ifndef MAINWINDOW_H +#define MAINWINDOW_H + +#include +#include +#include "quiz.h" + +QT_BEGIN_NAMESPACE +namespace Ui { class MainWindow; } +QT_END_NAMESPACE + +class MainWindow : public QMainWindow +{ + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr); + ~MainWindow() override; + +private slots: + void on_btnGetAll_clicked(); + void on_btnGetById_clicked(); + void on_btnCreate_clicked(); + void on_btnUpdate_clicked(); + void on_btnDelete_clicked(); + void on_btnClear_clicked(); + + void onQuizzesReceived(const QList &quizzes); + void onQuizReceived(const Quiz &quiz); + void onQuizCreated(const Quiz &quiz); + void onQuizUpdated(const Quiz &quiz); + void onQuizDeleted(const QString &message); + void onError(const QString &error); + +private: + void setupTable(); + void showQuizzes(const QList &quizzes); + void showSingleQuiz(const Quiz &q, const QString &operation); + void appendQuizText(const Quiz &q); + void setStatus(const QString &msg); + + Ui::MainWindow *ui; + int m_requestCount = 0; +}; + +#endif // MAINWINDOW_H diff --git a/lab-3/mainwindow.ui b/lab-3/mainwindow.ui new file mode 100644 index 0000000..57cd9b6 --- /dev/null +++ b/lab-3/mainwindow.ui @@ -0,0 +1,157 @@ + + + MainWindow + + + + 0 + 0 + 1000 + 620 + + + + Quiz Client — ЛР3 + + + + + 8 + + + + + 6 + + + + 048 + Все тесты +GET /api/quiz/ + QPushButton { background:#2d4a6b; color:#bfdbfe; border-radius:6px; font-weight:bold; border:1px solid #3b5f8a; } +QPushButton:hover { background:#3b5f8a; } + + + + + 048 + По ID +GET /api/quiz/:id/ + QPushButton { background:#2d4a6b; color:#bfdbfe; border-radius:6px; font-weight:bold; border:1px solid #3b5f8a; } +QPushButton:hover { background:#3b5f8a; } + + + + + 048 + Создать +POST /api/quiz/ + QPushButton { background:#1a3d2b; color:#86efac; border-radius:6px; font-weight:bold; border:1px solid #256040; } +QPushButton:hover { background:#256040; } + + + + + 048 + Обновить +PUT /api/quiz/:id/ + QPushButton { background:#3d2e0a; color:#fcd34d; border-radius:6px; font-weight:bold; border:1px solid #5a4210; } +QPushButton:hover { background:#5a4210; } + + + + + 048 + Удалить +DELETE /api/quiz/:id/ + QPushButton { background:#4a1a1a; color:#fca5a5; border-radius:6px; font-weight:bold; border:1px solid #6b2424; } +QPushButton:hover { background:#6b2424; } + + + + + 048 + Очистить + QPushButton { background:#2d3748; color:#9ca3af; border-radius:6px; border:1px solid #4b5563; font-weight:bold; } +QPushButton:hover { background:#374151; } + + + + + + + <b>Результат:</b> + + + + + 0 + QTabWidget::TabPosition::North + false + QTabWidget::pane { border: 1px solid #374151; border-radius: 0 4px 4px 4px; } +QTabBar::tab { background:#1f2937; color:#9ca3af; padding:6px 20px; border: 1px solid #374151; border-bottom: none; border-radius: 4px 4px 0 0; font-weight:bold; } +QTabBar::tab:selected { background:#111827; color:#f9fafb; border-color:#4b5563; } +QTabBar::tab:hover { background:#374151; color:#e5e7eb; } + + Таблица + + 0 + 0 + 0 + 0 + 0 + + + QAbstractItemView::SelectionBehavior::SelectRows + QAbstractItemView::SelectionMode::SingleSelection + QAbstractItemView::EditTrigger::NoEditTriggers + true + QTableWidget { gridline-color: #374151; border: none; color: #f9fafb; background: #111827; alternate-background-color: #1f2937; } +QHeaderView::section { background: #1f2937; color: #e5e7eb; font-weight: bold; padding: 6px 4px; border: none; border-bottom: 2px solid #4b5563; border-right: 1px solid #374151; } +QScrollBar:vertical { background: #1f2937; width: 10px; border-radius: 5px; } +QScrollBar::handle:vertical { background: #4b5563; border-radius: 5px; min-height: 20px; } +QScrollBar::handle:vertical:hover { background: #6b7280; } +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } +QScrollBar:horizontal { background: #1f2937; height: 10px; border-radius: 5px; } +QScrollBar::handle:horizontal { background: #4b5563; border-radius: 5px; min-width: 20px; } +QScrollBar::handle:horizontal:hover { background: #6b7280; } +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0px; } + + + + + + Текст + + 0 + 0 + 0 + 0 + 0 + + + true + + + Courier New + 10 + + + + + + + + + + + + + 00100021 + + + + + + + diff --git a/lab-3/quiz.h b/lab-3/quiz.h new file mode 100644 index 0000000..f167d8a --- /dev/null +++ b/lab-3/quiz.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +class Quiz +{ +public: + Quiz() = default; + + int id = 0; + + QString title; + QString description; + QString author; + QDateTime createdAt; + + int timeLimit = 0; + bool isPublished = false; +}; diff --git a/lab-3/quiz_json_adapter.cpp b/lab-3/quiz_json_adapter.cpp new file mode 100644 index 0000000..6ae6445 --- /dev/null +++ b/lab-3/quiz_json_adapter.cpp @@ -0,0 +1,39 @@ +#include "quiz_json_adapter.h" +#include + +// toQuiz — десериализация одного объекта +Quiz QuizJsonAdapter::toQuiz(const QJsonObject &json) const +{ + Quiz q; + + q.id = json["id"].toInt(); + + q.title = json["title"].toString(); + q.description = json["description"].toString(); + q.author = json["author"].toString(); + + q.createdAt = QDateTime::fromString(json["created_at"].toString(), + Qt::ISODateWithMs); + + q.timeLimit = json["time_limit"].toInt(); + + q.isPublished = json["is_published"].toBool(); + + return q; +} + +// toQuizList — десериализация массива объектов +QList QuizJsonAdapter::toQuizList(const QJsonArray &json) const +{ + QList list; + + list.reserve(json.size()); + + for (const QJsonValue &value : json) { + if (value.isObject()) { + list.append(toQuiz(value.toObject())); + } + } + + return list; +} diff --git a/lab-3/quiz_json_adapter.h b/lab-3/quiz_json_adapter.h new file mode 100644 index 0000000..f496282 --- /dev/null +++ b/lab-3/quiz_json_adapter.h @@ -0,0 +1,10 @@ +#pragma once + +#include "iquiz_adapter.h" + +class QuizJsonAdapter : public IQuizAdapter +{ +public: + Quiz toQuiz (const QJsonObject &json) const override; + QList toQuizList(const QJsonArray &json) const override; +}; diff --git a/lab-3/resources.qrc b/lab-3/resources.qrc new file mode 100644 index 0000000..94bf543 --- /dev/null +++ b/lab-3/resources.qrc @@ -0,0 +1,5 @@ + + + icons/app.svg + +