1
0
forked from BRT/arc

Add Ozon pay_to_new_number and pay_to_old_number XML assets

Included XML files for the "pay to new number" and "pay to old number" features in the Ozon platform.
This commit is contained in:
trnsmkot 2026-03-01 12:02:37 +07:00
parent 80be0ee7e9
commit d7886dbdcf
23 changed files with 492 additions and 23 deletions

View File

@ -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 = {

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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]

View File

@ -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() {

Binary file not shown.

View File

@ -0,0 +1,70 @@
#include "AppLogDAO.h"
#include <QSqlQuery>
#include <QSqlError>
#include <QDebug>
#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<AppLogEntry> AppLogDAO::getLogs(int page, int pageSize) {
QList<AppLogEntry> 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();
}

17
database/dao/AppLogDAO.h Normal file
View File

@ -0,0 +1,17 @@
#pragma once
#include <QByteArray>
#include <QList>
#include "db/AppLogEntry.h"
class AppLogDAO {
public:
[[nodiscard]] static bool insert(const AppLogEntry &entry);
// Возвращает записи без BLOB-скриншота (лёгкая загрузка для таблицы)
[[nodiscard]] static QList<AppLogEntry> getLogs(int page, int pageSize);
[[nodiscard]] static int getTotalCount();
// Загружает скриншот отдельно по id (по клику в UI)
[[nodiscard]] static QByteArray getScreenshot(int id);
};

104
docs/api/network.md Normal file
View File

@ -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 <access_token>` из `config.ini [common/access_token]`
- **Legacy** — поле `token` в multipart-теле (из `config.ini [common/token]`)

View File

@ -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"
}
}
```

View File

@ -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`.
Гарантирует что одна и та же задача не попадёт в очередь дважды
пока не завершится предыдущее выполнение.

View File

@ -20,6 +20,7 @@
| [Profile & Material](api/profile.md) | Банковские профили и карты (материалы) |
| [Device & Desktop](api/device.md) | Регистрация десктопов и Android-устройств, скриншоты |
| [Task](api/task.md) | Получение задач, отправка результатов |
| [NetworkService](api/network.md) | Полная карта всех HTTP-вызовов приложения |
---

13
models/db/AppLogEntry.h Normal file
View File

@ -0,0 +1,13 @@
#pragma once
#include <QString>
#include <QByteArray>
#include <QDateTime>
struct AppLogEntry {
int id = -1;
QString source; // "ozon/LoginAndCheck", "ozon/GetLastTransactions", etc.
QString deviceId; // ADB device id, может быть пустым
QString message; // текст ошибки
QByteArray screenshot; // PNG-байты скриншота (может быть пустым)
QDateTime timestamp;
};

17
services/AppLogger.cpp Normal file
View File

@ -0,0 +1,17 @@
#include "AppLogger.h"
#include <QDateTime>
#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);
}

13
services/AppLogger.h Normal file
View File

@ -0,0 +1,13 @@
#pragma once
#include <QString>
#include <QByteArray>
class AppLogger {
public:
static void log(
const QString &source,
const QString &deviceId,
const QString &message,
const QByteArray &screenshot = {}
);
};

View File

@ -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;

View File

@ -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();

View File

@ -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]() {

View File

@ -27,6 +27,7 @@ private:
QWidget *m_accountsWindow = nullptr;
QWidget *m_tutorialWindow = nullptr;
QWidget *m_bankListWindow = nullptr;
QWidget *m_logWindow = nullptr;
void createDevicePage();

142
views/log/LogWindow.cpp Normal file
View File

@ -0,0 +1,142 @@
#include "LogWindow.h"
#include <QHBoxLayout>
#include <QHeaderView>
#include <QPixmap>
#include <QScrollArea>
#include <QSplitter>
#include <QVBoxLayout>
#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<AppLogEntry> entries = AppLogDAO::getLogs(m_page, m_pageSize);
m_table->setRowCount(static_cast<int>(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)
);
}

27
views/log/LogWindow.h Normal file
View File

@ -0,0 +1,27 @@
#pragma once
#include <QDialog>
#include <QLabel>
#include <QPushButton>
#include <QTableWidget>
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);
};