diff --git a/android/adb/AdbUtils.cpp b/android/adb/AdbUtils.cpp index 3632941..c4a4880 100644 --- a/android/adb/AdbUtils.cpp +++ b/android/adb/AdbUtils.cpp @@ -194,6 +194,21 @@ bool AdbUtils::takeScreenshot(const QString &deviceId, const QString &name) { return true; } +QByteArray AdbUtils::captureScreenshotBytes(const QString &deviceId) { + const QString cmd = QString("%1 -s %2 exec-out screencap -p").arg(adbPath(), deviceId); + QProcess process; +#ifdef Q_OS_WIN + process.start("cmd.exe", {"/C", cmd}); +#else + process.start("sh", {"-c", cmd}); +#endif + if (!process.waitForFinished(10000) || process.exitCode() != 0) { + qWarning() << "[AdbUtils] captureScreenshotBytes failed for" << deviceId; + return {}; + } + return process.readAllStandardOutput(); +} + bool AdbUtils::takeXmlDump(const QString &deviceId, const QString &name) { const QString adb = adbPath(); QStringList commands = { diff --git a/android/adb/AdbUtils.h b/android/adb/AdbUtils.h index 7e26b65..0dc639d 100644 --- a/android/adb/AdbUtils.h +++ b/android/adb/AdbUtils.h @@ -23,6 +23,9 @@ public: static bool takeScreenshot(const QString &deviceId, const QString &name); + // Захватывает скриншот напрямую в память (PNG-байты), без временного файла + static QByteArray captureScreenshotBytes(const QString &deviceId); + static bool takeXmlDump(const QString &deviceId, const QString &name); static bool makeTap(const QString &deviceId, int x, int y); diff --git a/android/ozon/GetCardInfoScript.cpp b/android/ozon/GetCardInfoScript.cpp index 874b5ec..d41dcd2 100644 --- a/android/ozon/GetCardInfoScript.cpp +++ b/android/ozon/GetCardInfoScript.cpp @@ -11,6 +11,7 @@ #include "dao/ApplicationDAO.h" #include "dao/DeviceDAO.h" #include "net/NetworkService.h" +#include "AppLogger.h" namespace Ozon { @@ -59,6 +60,8 @@ void GetCardInfoScript::doStart() { device.screenWidth, device.screenHeight)) { m_error = "Cannot reach home screen: " + m_deviceId; qWarning() << m_error; + AppLogger::log("ozon/GetCardInfo", m_deviceId, m_error, + AdbUtils::captureScreenshotBytes(m_deviceId)); AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); return; diff --git a/android/ozon/GetLastTransactionsScript.cpp b/android/ozon/GetLastTransactionsScript.cpp index f1358b5..e2737c4 100644 --- a/android/ozon/GetLastTransactionsScript.cpp +++ b/android/ozon/GetLastTransactionsScript.cpp @@ -10,6 +10,7 @@ #include "dao/DeviceDAO.h" #include "dao/TransactionDAO.h" #include "time/DateUtils.h" +#include "AppLogger.h" namespace Ozon { @@ -53,6 +54,8 @@ void GetLastTransactionsScript::doStart() { device.screenWidth, device.screenHeight)) { m_error = "Cannot reach home screen: " + m_deviceId; qWarning() << m_error; + AppLogger::log("ozon/GetLastTransactions", m_deviceId, m_error, + AdbUtils::captureScreenshotBytes(m_deviceId)); AdbUtils::tryToKillApplication(m_deviceId, app.package); emit finishedWithResult(m_error); return; diff --git a/android/ozon/LoginAndCheckAccountsScript.cpp b/android/ozon/LoginAndCheckAccountsScript.cpp index c59ecda..583bab3 100644 --- a/android/ozon/LoginAndCheckAccountsScript.cpp +++ b/android/ozon/LoginAndCheckAccountsScript.cpp @@ -6,6 +6,7 @@ #include "dao/AccountDAO.h" #include "dao/ApplicationDAO.h" #include "dao/DeviceDAO.h" +#include "AppLogger.h" #include "GetCardInfoScript.h" namespace Ozon { @@ -38,6 +39,8 @@ void LoginAndCheckAccountsScript::doStart() { if (!runAppAndGoToHomeScreen(device.id, app.package, app.pinCode, device.screenWidth, device.screenHeight)) { m_error = "Cant open the home screen: " + m_deviceId + " " + m_appCode; qWarning() << m_error; + AppLogger::log("ozon/LoginAndCheck", m_deviceId, m_error, + AdbUtils::captureScreenshotBytes(m_deviceId)); if (!ApplicationDAO::updatePinCodeStatus(app.id, "no", "Не прошла проверка [дата]")) { qWarning() << "Cant update the pincode: " << m_deviceId << " " << m_appCode; } diff --git a/config.ini b/config.ini index 9437b48..162eedf 100644 --- a/config.ini +++ b/config.ini @@ -2,13 +2,13 @@ list=ozon [common] -access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjNDU1MjE1Ni1kMTc5LTRhNzktOWU5MS01ODcwMzhjOTVjZjAiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcyNDE4MDUwfQ.26k6Z22gMOzNFuS17Mx0d4dt8pnJQh9TIv0rrAk2Xro +access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjNDU1MjE1Ni1kMTc5LTRhNzktOWU5MS01ODcwMzhjOTVjZjAiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcyNDI3Njk5fQ.OYKZQ6znq88jCyKhHVFjfowkpYTMHlDNP8shh4nGxJE api_key=0eee77b62013253e02e1a69bc6a505bb37757fb7 currencies=USD, KRW, AZN, RUB desktop_id=b86eac8e-dc89-4a9e-a5e8-7f85db011391 profile_id=c4552156-d179-4a79-9e91-587038c95cf0 -refresh_token=ff49cde7-4f3a-4efa-ad46-84514fc03bf6 -token_expires_at=1772418050 +refresh_token=f9016b28-4dd5-429f-868e-8a8e0b246498 +token_expires_at=1772427699 version=0.0.1 [db] diff --git a/database/DatabaseManager.cpp b/database/DatabaseManager.cpp index 2b24146..b0f4870 100644 --- a/database/DatabaseManager.cpp +++ b/database/DatabaseManager.cpp @@ -300,6 +300,26 @@ void DatabaseManager::initSchema() { q.exec("PRAGMA user_version = 9"); qDebug() << "[DB] Migration 9 applied"; } + + // ── Версия 10: таблица app_logs ─────────────────────────────────────────── + if (version < 10) { + db.transaction(); + + q.exec(R"(CREATE TABLE IF NOT EXISTS app_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL DEFAULT '', + device_id TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL, + screenshot BLOB, + timestamp TEXT NOT NULL + ))"); + + q.exec("CREATE INDEX IF NOT EXISTS idx_app_logs_ts ON app_logs (id DESC)"); + + db.commit(); + q.exec("PRAGMA user_version = 10"); + qDebug() << "[DB] Migration 10 applied"; + } } void DatabaseManager::closeConnection() { diff --git a/database/app_data.db b/database/app_data.db index 9b787cd..2fcaa97 100644 Binary files a/database/app_data.db and b/database/app_data.db differ diff --git a/database/dao/AppLogDAO.cpp b/database/dao/AppLogDAO.cpp new file mode 100644 index 0000000..0375054 --- /dev/null +++ b/database/dao/AppLogDAO.cpp @@ -0,0 +1,70 @@ +#include "AppLogDAO.h" + +#include +#include +#include + +#include "DatabaseManager.h" +#include "time/DateUtils.h" + +bool AppLogDAO::insert(const AppLogEntry &entry) { + QSqlQuery q(DatabaseManager::instance().database()); + q.prepare(R"( + INSERT INTO app_logs (source, device_id, message, screenshot, timestamp) + VALUES (:source, :device_id, :message, :screenshot, :timestamp) + )"); + q.bindValue(":source", entry.source); + q.bindValue(":device_id", entry.deviceId); + q.bindValue(":message", entry.message); + q.bindValue(":screenshot", entry.screenshot.isEmpty() + ? QVariant(QMetaType(QMetaType::QByteArray)) + : QVariant(entry.screenshot)); + q.bindValue(":timestamp", DateUtils::toUtcString(entry.timestamp)); + if (!q.exec()) { + qWarning() << "[AppLogDAO] insert failed:" << q.lastError().text(); + return false; + } + return true; +} + +QList AppLogDAO::getLogs(int page, int pageSize) { + QList result; + QSqlQuery q(DatabaseManager::instance().database()); + q.prepare(R"( + SELECT id, source, device_id, message, timestamp + FROM app_logs + ORDER BY id DESC + LIMIT :limit OFFSET :offset + )"); + q.bindValue(":limit", pageSize); + q.bindValue(":offset", page * pageSize); + if (!q.exec()) { + qWarning() << "[AppLogDAO] getLogs failed:" << q.lastError().text(); + return result; + } + while (q.next()) { + AppLogEntry e; + e.id = q.value("id").toInt(); + e.source = q.value("source").toString(); + e.deviceId = q.value("device_id").toString(); + e.message = q.value("message").toString(); + e.timestamp = DateUtils::fromUtcString(q.value("timestamp").toString()); + result.append(e); + } + return result; +} + +int AppLogDAO::getTotalCount() { + QSqlQuery q(DatabaseManager::instance().database()); + q.exec("SELECT COUNT(*) FROM app_logs"); + if (q.next()) return q.value(0).toInt(); + return 0; +} + +QByteArray AppLogDAO::getScreenshot(int id) { + QSqlQuery q(DatabaseManager::instance().database()); + q.prepare("SELECT screenshot FROM app_logs WHERE id = :id"); + q.bindValue(":id", id); + if (!q.exec() || !q.next()) return {}; + return q.value(0).toByteArray(); +} diff --git a/database/dao/AppLogDAO.h b/database/dao/AppLogDAO.h new file mode 100644 index 0000000..dec1863 --- /dev/null +++ b/database/dao/AppLogDAO.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#include "db/AppLogEntry.h" + +class AppLogDAO { +public: + [[nodiscard]] static bool insert(const AppLogEntry &entry); + + // Возвращает записи без BLOB-скриншота (лёгкая загрузка для таблицы) + [[nodiscard]] static QList getLogs(int page, int pageSize); + + [[nodiscard]] static int getTotalCount(); + + // Загружает скриншот отдельно по id (по клику в UI) + [[nodiscard]] static QByteArray getScreenshot(int id); +}; diff --git a/docs/api/network.md b/docs/api/network.md new file mode 100644 index 0000000..0a9258b --- /dev/null +++ b/docs/api/network.md @@ -0,0 +1,104 @@ +# NetworkService — полная карта API-вызовов + +[← Главная](../index.md) + +Все HTTP-вызовы, выполняемые приложением. Класс `NetworkService` (`services/net/NetworkService.h/cpp`). + +--- + +## Аутентификация + +| Метод | Путь | Слот | Описание | +|-------|------|------|----------| +| POST | `/api/v1/auth/login` | `loginAndSetup()` | Вход по `api_key`, получение токенов | +| POST | `/api/v1/auth/refresh_token` | `refreshToken()` (private) | Обновление access_token | + +Токены сохраняются в `config.ini`. Авто-рефреш за 30 минут до истечения — `scheduleNextRefresh()`. + +--- + +## Desktop + +| Метод | Путь | Слот | Описание | +|-------|------|------|----------| +| POST | `/api/v1/desktop/` | `loginAndSetup()` | Создание desktop при первом запуске (если `desktop_id` не сохранён) | + +Ответ: сохраняет `desktop_id` и `profile_id` в `config.ini`. + +--- + +## Device + +| Метод | Путь | Слот | Описание | +|-------|------|------|----------| +| POST | `/api/v1/device/` | `createDevice()` | Регистрация нового Android устройства | +| PATCH | `/api/v1/device/{api_id}` | `updateDevice()` | Обновление статуса/батареи устройства | +| POST | `/api/v1/device/add_screenshot/{api_id}` | `postDeviceScreenshot()` | Загрузка скриншота (`multipart/form-data`, `image/jpeg`) | + +`api_id` — UUID, получаемый при создании и сохраняемый в таблице `devices`. + +--- + +## Profile / Material + +| Метод | Путь | Слот | Описание | +|-------|------|------|----------| +| POST | `/api/v1/profile/bank_profile` | `postBankProfile()` | Создать банк-профиль | +| PATCH | `/api/v1/profile/bank_profile/{id}` | `patchBankProfile()` | Обновить банк-профиль | +| POST | `/api/v1/profile/material` | `postMaterial()` | Создать материал (карту) | + +`postBankProfile` сохраняет `bank_profile_id` в таблице `applications`. +`postMaterial` сохраняет `material_id` (UUID) в таблице `accounts`. + +--- + +## Tasks + +| Метод | Путь | Слот | Auth | Описание | +|-------|------|------|------|----------| +| GET | `/api/v1/task/get_tasks` | `getTasks()` | Bearer | Получить очередь задач (поллинг каждые 5 сек) | +| POST | `/api/v1/task/task_result` | `postTaskResult()` | Bearer | Отправить результат задачи | + +`getTasks()` — асинхронный (без QEventLoop), использует сигнал `tasksReceived(QJsonArray)`. +`postTaskResult()` — вызывается из `sendTaskResult()` в `EventHandler` в отдельном QThread. + +--- + +## Устаревшие эндпоинты (legacy) + +Используют старый `multipart/form-data` API с токеном в теле. Пути задаются в `config.ini` напрямую как полные URL. + +| Слот | Ключ в config.ini | Статус | +|------|-------------------|--------| +| `postEventStatus()` | `net/eventStatusUpdate` | ⚠️ устарел, используется в `updateEventThread` | +| `postAppStatus()` | `net/appStatusUpdate` | используется при смене статуса приложения | +| `insertTransactionData()` | `net/transactionInsert` | создание транзакции | +| `updateTransactionData()` | `net/transactionUpdate` | обновление транзакции | +| `getPayments()` | `net/paymentsGet` | ⚠️ старый поллинг задач (заменён на `getTasks`) | + +--- + +## Конфигурация путей (`config.ini`) + +```ini +[net] +apiBase = http://localhost:9999 +pathAuthLogin = /api/v1/auth/login +pathAuthRefresh = /api/v1/auth/refresh_token +pathDesktopCreate = /api/v1/desktop/ +pathDevice = /api/v1/device/ +pathDeviceScreenshot = /api/v1/device/add_screenshot/ +pathBankProfile = /api/v1/profile/bank_profile +pathMaterial = /api/v1/profile/material +pathGetTasks = /api/v1/task/get_tasks +pathTaskResult = /api/v1/task/task_result +``` + +Если ключ отсутствует — используется значение по умолчанию (захардкожено в конструкторе `NetworkService`). + +--- + +## Авторизация в запросах + +- **Bearer** — `Authorization: Bearer ` из `config.ini [common/access_token]` +- **Legacy** — поле `token` в multipart-теле (из `config.ini [common/token]`) diff --git a/docs/api/task.md b/docs/api/task.md index 5ff6d04..fb4a7a2 100644 --- a/docs/api/task.md +++ b/docs/api/task.md @@ -17,19 +17,19 @@ "data": [ { "bank_name": "ozon", - "material_id": 42, + "material_id": "c340cd2e-cefd-48d6-a813-c4be097ae493", "type": "FETCH_PROFILE", "body": {} }, { "bank_name": "birbank", - "material_id": 7, + "material_id": "a1b2c3d4-0000-0000-0000-000000000001", "type": "FETCH_TRANSACTIONS", "body": {} }, { "bank_name": "ozon", - "material_id": 5, + "material_id": "a1b2c3d4-0000-0000-0000-000000000002", "type": "CREATE_TRANSACTION", "body": { "amount": 1000, @@ -43,6 +43,9 @@ } ``` +> `material_id` — UUID строка. Числовой формат поддерживается для обратной совместимости. +``` + **Типы задач:** | `type` | Описание | `body` | @@ -78,7 +81,7 @@ ```json { "type": "FETCH_PROFILE", - "material_id": 42, + "material_id": "c340cd2e-cefd-48d6-a813-c4be097ae493", "bank_name": "ozon", "data": { "bank_account_id": "string", @@ -94,7 +97,7 @@ ```json { "type": "FETCH_TRANSACTIONS", - "material_id": 7, + "material_id": "a1b2c3d4-0000-0000-0000-000000000001", "bank_name": "birbank", "data": [ { @@ -116,7 +119,7 @@ ```json { "type": "CREATE_TRANSACTION", - "material_id": 5, + "material_id": "a1b2c3d4-0000-0000-0000-000000000002", "bank_name": "ozon", "data": { "bank_transaction_id": "string", @@ -160,11 +163,11 @@ ```json { "type": "FETCH_PROFILE", - "material_id": 42, + "material_id": "c340cd2e-cefd-48d6-a813-c4be097ae493", "bank_name": "ozon", "data": { "status": "error", - "description": "Account not found for material_id: 42" + "description": "Account not found for material_id: c340cd2e-cefd-48d6-a813-c4be097ae493" } } ``` diff --git a/docs/events.md b/docs/events.md index 0e7961f..89c0259 100644 --- a/docs/events.md +++ b/docs/events.md @@ -61,8 +61,8 @@ QTimer (5s) │ YES → пропустить (дедупликация) │ NO → продолжить │ - ├─ AccountDAO::getAccountsByAppCode(bank_name) - │ найти аккаунт где materialId == material_id + ├─ AccountDAO::findByMaterialId(material_id) + │ прямой SELECT WHERE material_id = ? │ НЕ НАЙДЕН → sendTaskResult(status=error) и пропустить │ └─ EventDAO::insertEvent(status=Wait) @@ -128,11 +128,11 @@ result != "" → EventDAO::updateEvent(Error, message) ```json { "type": "FETCH_PROFILE", - "material_id": 42, + "material_id": "c340cd2e-cefd-48d6-a813-c4be097ae493", "bank_name": "ozon", "data": { "status": "error", - "description": "Account not found for material_id: 42" + "description": "Account not found for material_id: c340cd2e-cefd-48d6-a813-c4be097ae493" } } ``` @@ -142,7 +142,7 @@ result != "" → EventDAO::updateEvent(Error, message) ```json { "type": "FETCH_PROFILE", - "material_id": 42, + "material_id": "c340cd2e-cefd-48d6-a813-c4be097ae493", "bank_name": "ozon", "data": { "bank_account_id": "string", @@ -158,7 +158,7 @@ result != "" → EventDAO::updateEvent(Error, message) ```json { "type": "FETCH_TRANSACTIONS", - "material_id": 7, + "material_id": "a1b2c3d4-0000-0000-0000-000000000001", "bank_name": "birbank", "data": [ { @@ -180,7 +180,7 @@ result != "" → EventDAO::updateEvent(Error, message) ```json { "type": "FETCH_PROFILE", - "material_id": 42, + "material_id": "c340cd2e-cefd-48d6-a813-c4be097ae493", "bank_name": "ozon", "data": { "status": "error", @@ -196,7 +196,7 @@ result != "" → EventDAO::updateEvent(Error, message) ```cpp struct EventInfo { int id; // локальный ID в SQLite - int externalId; // material_id из API + QString externalId; // material_id из API (UUID строка) int accountId; // FK → accounts QString deviceId; // ADB device id @@ -220,8 +220,8 @@ struct EventInfo { ## Дедупликация -`EventDAO::existsActiveByExternalId(int externalId)` — проверяет наличие записи -с `status IN ('wait', 'iPprogress')` для данного `external_id`. +`EventDAO::existsActiveByExternalId(const QString &externalId)` — проверяет наличие записи +с `status IN ('wait', 'inprogress')` для данного `external_id`. Гарантирует что одна и та же задача не попадёт в очередь дважды пока не завершится предыдущее выполнение. diff --git a/docs/index.md b/docs/index.md index 64f9d85..ec2194c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,6 +20,7 @@ | [Profile & Material](api/profile.md) | Банковские профили и карты (материалы) | | [Device & Desktop](api/device.md) | Регистрация десктопов и Android-устройств, скриншоты | | [Task](api/task.md) | Получение задач, отправка результатов | +| [NetworkService](api/network.md) | Полная карта всех HTTP-вызовов приложения | --- diff --git a/models/db/AppLogEntry.h b/models/db/AppLogEntry.h new file mode 100644 index 0000000..7656e57 --- /dev/null +++ b/models/db/AppLogEntry.h @@ -0,0 +1,13 @@ +#pragma once +#include +#include +#include + +struct AppLogEntry { + int id = -1; + QString source; // "ozon/LoginAndCheck", "ozon/GetLastTransactions", etc. + QString deviceId; // ADB device id, может быть пустым + QString message; // текст ошибки + QByteArray screenshot; // PNG-байты скриншота (может быть пустым) + QDateTime timestamp; +}; diff --git a/services/AppLogger.cpp b/services/AppLogger.cpp new file mode 100644 index 0000000..1da7e73 --- /dev/null +++ b/services/AppLogger.cpp @@ -0,0 +1,17 @@ +#include "AppLogger.h" + +#include + +#include "dao/AppLogDAO.h" +#include "db/AppLogEntry.h" + +void AppLogger::log(const QString &source, const QString &deviceId, + const QString &message, const QByteArray &screenshot) { + AppLogEntry entry; + entry.source = source; + entry.deviceId = deviceId; + entry.message = message; + entry.screenshot = screenshot; + entry.timestamp = QDateTime::currentDateTimeUtc(); + AppLogDAO::insert(entry); +} diff --git a/services/AppLogger.h b/services/AppLogger.h new file mode 100644 index 0000000..23dd387 --- /dev/null +++ b/services/AppLogger.h @@ -0,0 +1,13 @@ +#pragma once +#include +#include + +class AppLogger { +public: + static void log( + const QString &source, + const QString &deviceId, + const QString &message, + const QByteArray &screenshot = {} + ); +}; diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index dd457d2..89e1a51 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -130,7 +130,7 @@ static void sendTaskResult(const QString &type, const QString &materialId, const thread->start(); } -static void sendEventStatus(const QString &deviceId, const int externalId, const int eventId, +static void sendEventStatus(const QString &deviceId, const QString &externalId, const int eventId, const QString &status, const QString &error) { QJsonObject obj; obj["id"] = externalId; diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 3980f54..efa30c1 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -152,7 +152,9 @@ void NetworkService::getPayments() { EventInfo eventInfo; eventInfo.status = EventStatus::Wait; eventInfo.type = eventTypeFromString(obj["type"].toString()); - eventInfo.externalId = obj["id"].toInt(); + eventInfo.externalId = obj["id"].isString() + ? obj["id"].toString() + : QString::number(obj["id"].toInt()); eventInfo.timestamp = QDateTime::fromMSecsSinceEpoch(obj["timestamp"].toDouble(), QTimeZone::utc()); eventInfo.amount = obj["amount"].toDouble(); eventInfo.bankName = obj["bankName"].toString(); diff --git a/views/MainWindow.cpp b/views/MainWindow.cpp index d642bd5..4906b23 100644 --- a/views/MainWindow.cpp +++ b/views/MainWindow.cpp @@ -8,6 +8,7 @@ #include "TutorialWindow.h" #include "bank/BankListWindow.h" +#include "log/LogWindow.h" #include "db/DeviceInfo.h" #include "device/DeviceWidget.h" #include "dao/DeviceDAO.h" @@ -38,10 +39,12 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { // auto *accountPageAction = new QAction("Аккаунты", this); auto *bankListAction = new QAction("Банки", this); auto *tutorialPageAction = new QAction("Инструкция", this); + auto *logAction = new QAction("Логи", this); menu->addAction(devicesPageAction); // menu->addAction(accountPageAction); menu->addAction(bankListAction); menu->addAction(tutorialPageAction); + menu->addAction(logAction); connect(devicesPageAction, &QAction::triggered, this, [=]() { if (!m_devicesWindow) { @@ -73,6 +76,15 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { m_tutorialWindow->activateWindow(); }); + connect(logAction, &QAction::triggered, this, [=]() { + if (!m_logWindow) { + m_logWindow = new LogWindow(this); + } + m_logWindow->show(); + m_logWindow->raise(); + m_logWindow->activateWindow(); + }); + // m_tutorialWindow = new TutorialWindow(this); // m_tutorialWindow->show(); // QTimer::singleShot(0, m_tutorialWindow, [w = m_tutorialWindow]() { diff --git a/views/MainWindow.h b/views/MainWindow.h index c5bbf99..1b20565 100644 --- a/views/MainWindow.h +++ b/views/MainWindow.h @@ -27,6 +27,7 @@ private: QWidget *m_accountsWindow = nullptr; QWidget *m_tutorialWindow = nullptr; QWidget *m_bankListWindow = nullptr; + QWidget *m_logWindow = nullptr; void createDevicePage(); diff --git a/views/log/LogWindow.cpp b/views/log/LogWindow.cpp new file mode 100644 index 0000000..62efc1d --- /dev/null +++ b/views/log/LogWindow.cpp @@ -0,0 +1,142 @@ +#include "LogWindow.h" + +#include +#include +#include +#include +#include +#include + +#include "dao/AppLogDAO.h" + +static constexpr int COL_ID = 0; +static constexpr int COL_TIME = 1; +static constexpr int COL_SOURCE = 2; +static constexpr int COL_DEVICE = 3; +static constexpr int COL_MESSAGE = 4; + +LogWindow::LogWindow(QWidget *parent) : QDialog(parent) { + setWindowTitle("Логи ошибок"); + resize(1100, 650); + + // ── Таблица ─────────────────────────────────────────────────────────────── + m_table = new QTableWidget(this); + m_table->setColumnCount(5); + m_table->setHorizontalHeaderLabels({"#", "Время", "Источник", "Устройство", "Сообщение"}); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->setSelectionMode(QAbstractItemView::SingleSelection); + m_table->verticalHeader()->hide(); + m_table->horizontalHeader()->setStretchLastSection(true); + m_table->setColumnWidth(COL_ID, 45); + m_table->setColumnWidth(COL_TIME, 145); + m_table->setColumnWidth(COL_SOURCE, 170); + m_table->setColumnWidth(COL_DEVICE, 120); + + // ── Панель скриншота ────────────────────────────────────────────────────── + m_screenshotLabel = new QLabel("Выберите строку для просмотра скриншота"); + m_screenshotLabel->setAlignment(Qt::AlignCenter); + m_screenshotLabel->setMinimumWidth(320); + m_screenshotLabel->setWordWrap(true); + m_screenshotLabel->setStyleSheet("background: #1a1a1a; color: #888; border-radius: 4px;"); + + auto *scrollArea = new QScrollArea(this); + scrollArea->setWidget(m_screenshotLabel); + scrollArea->setWidgetResizable(true); + scrollArea->setMinimumWidth(320); + + // ── Сплиттер ───────────────────────────────────────────────────────────── + auto *splitter = new QSplitter(Qt::Horizontal, this); + splitter->addWidget(m_table); + splitter->addWidget(scrollArea); + splitter->setStretchFactor(0, 7); + splitter->setStretchFactor(1, 3); + + // ── Пагинация ───────────────────────────────────────────────────────────── + m_prevBtn = new QPushButton("←", this); + m_nextBtn = new QPushButton("→", this); + m_pageLabel = new QLabel(this); + m_pageLabel->setAlignment(Qt::AlignCenter); + m_pageLabel->setMinimumWidth(140); + + auto *refreshBtn = new QPushButton("Обновить", this); + + auto *paginationLayout = new QHBoxLayout; + paginationLayout->addWidget(m_prevBtn); + paginationLayout->addWidget(m_pageLabel); + paginationLayout->addWidget(m_nextBtn); + paginationLayout->addStretch(); + paginationLayout->addWidget(refreshBtn); + + auto *mainLayout = new QVBoxLayout(this); + mainLayout->addWidget(splitter, 1); + mainLayout->addLayout(paginationLayout); + + // ── Сигналы ─────────────────────────────────────────────────────────────── + connect(m_prevBtn, &QPushButton::clicked, this, [this]() { + if (m_page > 0) { --m_page; loadPage(); } + }); + connect(m_nextBtn, &QPushButton::clicked, this, [this]() { + if (m_page < m_totalPages - 1) { ++m_page; loadPage(); } + }); + connect(refreshBtn, &QPushButton::clicked, this, [this]() { + m_page = 0; + loadPage(); + }); + connect(m_table, &QTableWidget::currentCellChanged, this, + [this](int row, int, int, int) { + if (row < 0) return; + const int logId = m_table->item(row, COL_ID)->text().toInt(); + showScreenshot(logId); + }); + + loadPage(); +} + +void LogWindow::loadPage() { + const int total = AppLogDAO::getTotalCount(); + m_totalPages = qMax(1, (total + m_pageSize - 1) / m_pageSize); + m_page = qBound(0, m_page, m_totalPages - 1); + + const QList entries = AppLogDAO::getLogs(m_page, m_pageSize); + + m_table->setRowCount(static_cast(entries.size())); + for (int i = 0; i < entries.size(); ++i) { + const auto &e = entries[i]; + m_table->setItem(i, COL_ID, new QTableWidgetItem(QString::number(e.id))); + m_table->setItem(i, COL_TIME, new QTableWidgetItem( + e.timestamp.toLocalTime().toString("dd.MM.yy HH:mm:ss"))); + m_table->setItem(i, COL_SOURCE, new QTableWidgetItem(e.source)); + m_table->setItem(i, COL_DEVICE, new QTableWidgetItem(e.deviceId)); + m_table->setItem(i, COL_MESSAGE, new QTableWidgetItem(e.message)); + } + + // Сбрасываем скриншот при смене страницы + m_screenshotLabel->setPixmap({}); + m_screenshotLabel->setText("Выберите строку для просмотра скриншота"); + + updatePagination(); +} + +void LogWindow::updatePagination() { + m_pageLabel->setText(QString("Страница %1 из %2").arg(m_page + 1).arg(m_totalPages)); + m_prevBtn->setEnabled(m_page > 0); + m_nextBtn->setEnabled(m_page < m_totalPages - 1); +} + +void LogWindow::showScreenshot(int logId) { + const QByteArray bytes = AppLogDAO::getScreenshot(logId); + if (bytes.isEmpty()) { + m_screenshotLabel->setPixmap({}); + m_screenshotLabel->setText("Скриншот отсутствует"); + return; + } + QPixmap px; + if (!px.loadFromData(bytes)) { + m_screenshotLabel->setText("Не удалось загрузить скриншот"); + return; + } + m_screenshotLabel->setPixmap( + px.scaled(m_screenshotLabel->width(), 9999, Qt::KeepAspectRatio, Qt::SmoothTransformation) + ); +} diff --git a/views/log/LogWindow.h b/views/log/LogWindow.h new file mode 100644 index 0000000..23af751 --- /dev/null +++ b/views/log/LogWindow.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include +#include + +class LogWindow final : public QDialog { + Q_OBJECT + +public: + explicit LogWindow(QWidget *parent = nullptr); + +private: + QTableWidget *m_table; + QLabel *m_screenshotLabel; + QLabel *m_pageLabel; + QPushButton *m_prevBtn; + QPushButton *m_nextBtn; + + int m_page = 0; + int m_pageSize = 50; + int m_totalPages = 1; + + void loadPage(); + void updatePagination(); + void showScreenshot(int logId); +};