#include "MainWindow.h" #include "bank/AccountWindow.h" #include #include #include #include #include #include #include #include #include #include "TutorialWindow.h" #include "bank/BankListWindow.h" #include "event/EventWindow.h" #include "log/FileLogWindow.h" #include "dao/GeneralLogDAO.h" #include "dao/SettingsDAO.h" #include "net/NetworkService.h" #include "db/DeviceInfo.h" #include "device/DeviceWidget.h" #include "dao/BankProfileDAO.h" #include "DatabaseManager.h" #include "dao/DeviceDAO.h" #include "dao/MaterialDAO.h" #include "dao/SettingsDAO.h" #include #include #include #include #include #include "widget/common/FlowLayout.h" #include "update/UpdateService.h" #include "update/UpdatePause.h" MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { const QSettings settings("config.ini", QSettings::IniFormat); const QString version = settings.value("common/version").toString(); QString title = version.isEmpty() ? "ARC" : QString("ARC v%1").arg(version); const QString desktopName = SettingsDAO::get("desktop_unique_name"); if (!desktopName.isEmpty()) title += QString(" [%1]").arg(desktopName); setWindowTitle(title); // Инициализируем таймер обновления timer = new QTimer(this); connect(timer, &QTimer::timeout, this, &MainWindow::loadDevices); stackedWidget = new QStackedWidget(this); setCentralWidget(stackedWidget); createDevicePage(); stackedWidget->addWidget(m_devicesWindow); stackedWidget->setCurrentWidget(m_devicesWindow); // FIXME переделать на ленивое создание // m_accountsWindow = new AccountWindow(this); // stackedWidget->addWidget(m_accountsWindow); // stackedWidget->setCurrentWidget(accountPage); resize(800, 600); // Работа с меню QMenu *menu = menuBar()->addMenu("Меню"); auto *devicesPageAction = new QAction("Активные устройства", this); // auto *accountPageAction = new QAction("Аккаунты", this); auto *tutorialPageAction = new QAction("Инструкция", this); auto *eventAction = new QAction("События", this); auto *fileLogAction = new QAction("Логи", this); auto *updateAction = new QAction("Проверить обновления", this); m_updateAction = updateAction; auto *exitAction = new QAction("Выйти", this); menu->addAction(devicesPageAction); // menu->addAction(accountPageAction); menu->addAction(eventAction); menu->addAction(tutorialPageAction); menu->addAction(fileLogAction); menu->addAction(updateAction); menu->addAction(exitAction); connect(devicesPageAction, &QAction::triggered, this, [=]() { if (!m_devicesWindow) { createDevicePage(); } stackedWidget->setCurrentWidget(m_devicesWindow); }); // connect(accountPageAction, &QAction::triggered, this, [=]() { // stackedWidget->setCurrentWidget(m_accountsWindow); // deleteDevicePage(); // освобождаем ресурсы // }); connect(tutorialPageAction, &QAction::triggered, this, [=]() { if (!m_tutorialWindow) { m_tutorialWindow = new TutorialWindow(this); } m_tutorialWindow->show(); m_tutorialWindow->raise(); m_tutorialWindow->activateWindow(); }); connect(eventAction, &QAction::triggered, this, [=]() { if (!m_eventWindow) { m_eventWindow = new EventWindow(this); } m_eventWindow->show(); m_eventWindow->raise(); m_eventWindow->activateWindow(); }); connect(fileLogAction, &QAction::triggered, this, [this]() { QMessageBox msgBox(this); msgBox.setWindowTitle("Логи"); msgBox.setText("Отправить логи на сервер?"); auto *yesBtn = msgBox.addButton("Отправить", QMessageBox::YesRole); msgBox.addButton("Отмена", QMessageBox::NoRole); msgBox.exec(); if (msgBox.clickedButton() == yesBtn) { if (!m_fileLogWindow) { m_fileLogWindow = new FileLogWindow(this); } m_fileLogWindow->sendLogsToBackend(); } }); connect(updateAction, &QAction::triggered, this, &MainWindow::checkForUpdates); connect(exitAction, &QAction::triggered, this, [this]() { QMessageBox msgBox(this); msgBox.setWindowTitle("Выход"); msgBox.setText("Удалить локальную базу данных и выйти из приложения?"); auto *yesBtn = msgBox.addButton("Выйти", QMessageBox::YesRole); msgBox.addButton("Отмена", QMessageBox::NoRole); msgBox.exec(); if (msgBox.clickedButton() == yesBtn) { DatabaseManager::instance().setWipeOnExit(true); qApp->quit(); // штатное завершение фоновых потоков (aboutToQuit) } }); if (SettingsDAO::get("tutorial_shown").isEmpty()) { QTimer::singleShot(0, this, [this]() { m_tutorialWindow = new TutorialWindow(this); m_tutorialWindow->show(); m_tutorialWindow->raise(); m_tutorialWindow->activateWindow(); SettingsDAO::set("tutorial_shown", "1"); }); } // Sync-health индикатор в статус-баре (правило #9). По умолчанию скрыт — // показывается только когда NetworkService сообщил unhealthy. m_syncHealthLabel = new QLabel(this); m_syncHealthLabel->setVisible(false); m_syncHealthLabel->setStyleSheet("color: #d9534f; padding: 0 8px;"); statusBar()->addPermanentWidget(m_syncHealthLabel); } void MainWindow::setSyncHealth(bool healthy, int consecutiveFailures) { if (!m_syncHealthLabel) return; if (healthy) { m_syncHealthLabel->setVisible(false); m_syncHealthLabel->setToolTip(QString()); return; } m_syncHealthLabel->setText(QStringLiteral("⚠ Нет связи с сервером")); m_syncHealthLabel->setToolTip( QStringLiteral("Подряд %1 неудачных синхронизаций. Проверьте подключение.") .arg(consecutiveFailures)); m_syncHealthLabel->setVisible(true); } void MainWindow::checkForUpdates() { if (m_updateAction) m_updateAction->setEnabled(false); auto reEnable = [this]() { if (m_updateAction) m_updateAction->setEnabled(true); }; // UpdateService живёт в своём потоке (как NetworkService). Сигналы приходят // в главный поток через QueuedConnection — диалоги показываем здесь. auto *thread = new QThread; auto *svc = new UpdateService; svc->moveToThread(thread); connect(thread, &QThread::started, svc, &UpdateService::checkForUpdates); connect(svc, &QObject::destroyed, thread, &QThread::quit); connect(thread, &QThread::finished, thread, &QObject::deleteLater); connect(svc, &UpdateService::upToDate, this, [this, svc, reEnable](const QString &ver) { QMessageBox::information(this, "Обновление", QString("У вас последняя версия (%1).").arg(ver)); reEnable(); svc->deleteLater(); }); connect(svc, &UpdateService::checkFailed, this, [this, svc, reEnable](const QString &reason) { QMessageBox::warning(this, "Обновление", "Не удалось проверить обновления:\n" + reason); reEnable(); svc->deleteLater(); }); connect(svc, &UpdateService::updateAvailable, this, [this, svc, reEnable](const QString &ver, const QString &zipUrl, const QString &sha) { const QString text = QString("Доступна новая версия: %1.\nУстановить сейчас?\n\n" "Приложение перезапустится автоматически.").arg(ver); // Кнопки задаём вручную — стандартные Yes/No берут английские подписи // из локализации Qt (как в остальных диалогах приложения). QMessageBox confirmBox(this); confirmBox.setWindowTitle("Обновление"); confirmBox.setText(text); confirmBox.setIcon(QMessageBox::Question); auto *yesBtn = confirmBox.addButton("Обновить", QMessageBox::YesRole); confirmBox.addButton("Позже", QMessageBox::NoRole); confirmBox.exec(); if (confirmBox.clickedButton() != yesBtn) { reEnable(); svc->deleteLater(); return; } // Ставим паузу: EventHandler перестаёт опрашивать задачи, а // периодический sync — перепушивать "active" на сервер. Затем сразу // выключаем все профили на сервере (на выходе stop() повторит идемпотентно). UpdatePause::setPaused(true); if (m_network) { QMetaObject::invokeMethod(m_network, "disableAllProfilesOnServer", Qt::QueuedConnection); } auto *progress = new QProgressDialog("Загрузка обновления…", QString(), 0, 100, this); progress->setWindowTitle("Обновление"); progress->setWindowModality(Qt::WindowModal); progress->setMinimumDuration(0); progress->setAutoClose(false); progress->setAutoReset(false); progress->setCancelButton(nullptr); progress->setValue(0); connect(svc, &UpdateService::downloadProgress, progress, [progress](qint64 r, qint64 t) { if (t > 0) progress->setValue(static_cast(r * 100 / t)); }); connect(svc, &UpdateService::stageFailed, this, [this, svc, progress, reEnable](const QString &reason) { progress->close(); progress->deleteLater(); // Обновление не удалось — снимаем паузу, приложение продолжает // работу. Периодический sync восстановит профили на сервере. UpdatePause::setPaused(false); QMessageBox::warning(this, "Обновление", "Ошибка установки:\n" + reason); reEnable(); svc->deleteLater(); }); connect(svc, &UpdateService::updateStaged, this, [progress]() { progress->setLabelText("Перезапуск…"); progress->setValue(100); qApp->quit(); // штатное завершение: aboutToQuit → stop() → disable }); QMetaObject::invokeMethod(svc, "downloadAndApply", Qt::QueuedConnection, Q_ARG(QString, zipUrl), Q_ARG(QString, sha), Q_ARG(QString, ver)); }); thread->start(); } void MainWindow::createDevicePage() { if (m_devicesWindow) { return; } auto *container = new QWidget(this); auto *containerLayout = new QVBoxLayout(container); containerLayout->setContentsMargins(0, 0, 0, 0); m_noDevicesLabel = new QLabel(container); m_noDevicesPixmap = QPixmap(QCoreApplication::applicationDirPath() + "/no_devices.png"); m_noDevicesLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); m_noDevicesLabel->setAlignment(Qt::AlignCenter); containerLayout->addWidget(m_noDevicesLabel); m_scrollArea = new QScrollArea(container); auto *contentWidget = new QWidget; flowLayout = new FlowLayout(contentWidget, 0, 0, 0); QVector devices = DeviceDAO::getAllDevices(false); for (const auto &device: devices) { flowLayout->addWidget(new DeviceWidget(device)); } contentWidget->setLayout(flowLayout); static_cast(m_scrollArea)->setWidget(contentWidget); static_cast(m_scrollArea)->setWidgetResizable(true); containerLayout->addWidget(m_scrollArea); m_noDevicesLabel->setVisible(devices.isEmpty()); m_scrollArea->setVisible(!devices.isEmpty()); // Панель логов внизу m_logList = new QListWidget(container); m_logList->setMaximumHeight(120); m_logList->setStyleSheet( "QListWidget { background-color: #1e1e1e; color: #cccccc; font-family: monospace; font-size: 11px; border-top: 1px solid #444; }" "QListWidget::item { padding: 1px 4px; }" ); m_logList->setSelectionMode(QAbstractItemView::NoSelection); m_logList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); containerLayout->addWidget(m_logList); updateLogPanel(); m_devicesWindow = container; stackedWidget->addWidget(m_devicesWindow); timer->start(1000); if (devices.isEmpty()) { QTimer::singleShot(0, this, [this]() { if (m_noDevicesLabel && !m_noDevicesPixmap.isNull()) { m_noDevicesLabel->setPixmap(m_noDevicesPixmap.scaled( m_noDevicesLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); } }); } } void MainWindow::deleteDevicePage() { if (!m_devicesWindow) { return; } timer->stop(); stackedWidget->removeWidget(m_devicesWindow); m_devicesWindow->deleteLater(); m_devicesWindow = nullptr; flowLayout = nullptr; } /** * Однако, в нем вы вызываете delete для элементов, что изменяет память. * Для того, чтобы не нарушать константность, этот метод должен быть неконстантным (без const в сигнатуре). */ void MainWindow::loadDevices() { const auto devices = DeviceDAO::getAllDevices(false); // Очистить старые виджеты QLayoutItem *item; while ((item = flowLayout->takeAt(0)) != nullptr) { if (item->widget()) { item->widget()->deleteLater(); } delete item; } // Добавляем новые виджеты для каждого устройства for (const auto &device: devices) { flowLayout->addWidget(new DeviceWidget(device)); } m_noDevicesLabel->setVisible(devices.isEmpty()); m_scrollArea->setVisible(!devices.isEmpty()); updateLogPanel(); } void MainWindow::updateLogPanel() { if (!m_logList) return; const QList logs = GeneralLogDAO::getLogs(0, 5, {"WARNING", "CRITICAL", "FATAL"}, 10); m_logList->clear(); for (const auto &entry : logs) { // Преобразуем в человекопонятные сообщения QString msg = entry.message; // Извлекаем контекст: банк, устройство QString bank; QString device; { // bank=ozon QRegularExpression bankRe(R"(bank=(\w+))"); if (auto m = bankRe.match(msg); m.hasMatch()) { const QString b = m.captured(1); if (b == "ozon") bank = "Ozon"; else if (b == "black") bank = "Black"; else if (b == "dushanbe_city_bank") bank = "Dushanbe"; else bank = b; } // Устройство: device=ID или "SERIAL" QRegularExpression devEqRe(R"(device=(\S+))"); if (auto m = devEqRe.match(msg); m.hasMatch()) { const QString devId = m.captured(1); const DeviceInfo dev = DeviceDAO::getDeviceById(devId); if (!dev.name.isEmpty()) device = dev.name; else { const DeviceInfo dev2 = DeviceDAO::findByAdbSerial(devId); device = dev2.name.isEmpty() ? devId.left(10) + "..." : dev2.name; } } if (device.isEmpty()) { QRegularExpression devRe(R"x("?([A-Z0-9]{12,})"?)x"); if (auto m = devRe.match(msg); m.hasMatch()) { const DeviceInfo dev = DeviceDAO::findByAdbSerial(m.captured(1)); device = dev.name.isEmpty() ? m.captured(1).left(8) + "..." : dev.name; } } // UUID в URL — ищем bank_profile или material по ID if (bank.isEmpty() || device.isEmpty()) { QRegularExpression uuidRe(R"(([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))"); auto it = uuidRe.globalMatch(msg); while (it.hasNext() && (bank.isEmpty() || device.isEmpty())) { const QString uuid = it.next().captured(1); if (bank.isEmpty()) { const QList bpList = BankProfileDAO::findAllByBankProfileId(uuid); const BankProfileInfo bp = bpList.isEmpty() ? BankProfileInfo() : bpList.first(); if (bp.id >= 0) { if (bank.isEmpty()) { const QString b = bp.code; if (b == "ozon") bank = "Ozon"; else if (b == "black") bank = "Black"; else if (b == "dushanbe_city_bank") bank = "Dushanbe"; else bank = b; } if (device.isEmpty() && !bp.deviceId.isEmpty()) { const DeviceInfo dev = DeviceDAO::getDeviceById(bp.deviceId); device = dev.name.isEmpty() ? bp.deviceId.left(8) + "..." : dev.name; } } } if (bank.isEmpty()) { const MaterialInfo mat = MaterialDAO::findByMaterialId(uuid); if (mat.id >= 0) { const QString b = mat.appCode; if (b == "ozon") bank = "Ozon"; else if (b == "black") bank = "Black"; else if (b == "dushanbe_city_bank") bank = "Dushanbe"; else bank = b; if (device.isEmpty() && !mat.deviceId.isEmpty()) { const DeviceInfo dev = DeviceDAO::getDeviceById(mat.deviceId); device = dev.name.isEmpty() ? mat.deviceId.left(8) + "..." : dev.name; } } } } } } // Формируем префикс [девайс | банк] QString ctxPrefix; if (!device.isEmpty() || !bank.isEmpty()) { QStringList parts; if (!device.isEmpty()) parts << device; if (!bank.isEmpty()) parts << bank; ctxPrefix = "[" + parts.join(" | ") + "] "; } if (msg.contains("Failed to send result to server")) msg = ctxPrefix + "Не удалось отправить результат на сервер"; else if (msg.contains("Device is offline")) msg = ctxPrefix + "Устройство не подключено"; else if (msg.contains("bank_transaction_id is empty")) msg = ctxPrefix + "Не удалось распознать ID транзакции"; else if (msg.contains("Transaction not found")) msg = ctxPrefix + "Транзакция не найдена"; else if (msg.contains("Cannot reach home screen") || msg.contains("Cant open the home screen")) msg = ctxPrefix + "Не удалось открыть приложение"; else if (msg.contains("Card input screen not loaded")) msg = ctxPrefix + "Экран ввода карты не загрузился"; else if (msg.contains("Payment result not received")) msg = ctxPrefix + "Результат платежа не получен (таймаут)"; else if (msg.contains("Transfer failed")) msg = ctxPrefix + "Перевод не удался"; else if (msg.contains("PIN code") || msg.contains("Пин-код")) msg = ctxPrefix + "Ошибка проверки пин-кода"; else if (msg.contains("postTaskResult failed")) msg = ctxPrefix + "Ошибка отправки результата задачи"; else if (msg.contains("Network error") || msg.contains("network error")) { QRegularExpression urlRe(R"((https?://[^\s\"]+))"); QRegularExpression codeRe(R"(server replied:\s*(.+?)[\"\n])"); QString url, code; if (auto m = urlRe.match(msg); m.hasMatch()) url = m.captured(1); if (auto m = codeRe.match(msg); m.hasMatch()) code = m.captured(1).trimmed(); // Преобразуем путь API в читаемое название QString endpoint; if (url.contains("/bank_profile")) endpoint = "обновление профиля банка"; else if (url.contains("/material")) endpoint = "обновление карты/счёта"; else if (url.contains("/task_result")) endpoint = "отправка результата задачи"; else if (url.contains("/get_tasks")) endpoint = "получение задач"; else if (url.contains("/device")) endpoint = "обновление устройства"; else if (url.contains("/auth")) endpoint = "авторизация"; else if (url.contains("/current_profile")) endpoint = "получение профиля"; else if (url.contains("/monitoring")) endpoint = "отправка логов"; else { const int pathIdx = url.indexOf('/', url.indexOf("//") + 2); endpoint = pathIdx > 0 ? url.mid(pathIdx) : url; } msg = "Ошибка: " + endpoint; if (!code.isEmpty()) msg += " [" + code + "]"; msg = ctxPrefix + msg; } else if (msg.contains("App restarted")) msg = ctxPrefix + "Приложение перезапущено, задача отменена"; else if (msg.contains("Task expired")) msg = ctxPrefix + "Задача просрочена"; else if (msg.contains("Bank profile disabled") || msg.contains("Bank profile is disabled")) msg = ctxPrefix + "Банковский профиль выключен"; else if (msg.contains("Скриншот пустой")) msg = ctxPrefix + "Скриншот устройства пустой"; const QString time = entry.timestamp.toLocalTime().toString("HH:mm:ss"); const QString line = QStringLiteral("[%1] %2").arg(time, msg); auto *item = new QListWidgetItem(line, m_logList); if (entry.level == "CRITICAL" || entry.level == "FATAL") { item->setForeground(QColor("#ff6b6b")); } else if (entry.level == "WARNING") { item->setForeground(QColor("#ffa94d")); } else { item->setForeground(QColor("#cccccc")); } } } void MainWindow::resizeEvent(QResizeEvent *event) { QMainWindow::resizeEvent(event); if (m_noDevicesLabel && m_noDevicesLabel->isVisible() && !m_noDevicesPixmap.isNull()) { m_noDevicesLabel->setPixmap(m_noDevicesPixmap.scaled( m_noDevicesLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); } }