#include "DeviceWidget.h" #include #include #include #include #include #include #include #include #include #include #include #include #include "bank/AccountSettingsWindow.h" #include "bank/BankSetupWizard.h" #include "dao/BankProfileDAO.h" #include "bank/BankSettingsWindow.h" #include "DeviceSettingsWindow.h" #include "dao/BankProfileDAO.h" #include "dao/MaterialDAO.h" #include "dao/DeviceDAO.h" #include "dao/EventDAO.h" #include "dao/SettingsDAO.h" #include "db/BankProfileInfo.h" #include "net/NetworkService.h" #include "adb/AdbUtils.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include QString getColorByStatus(const BankProfileInfo &app) { if (!app.install) { return "black"; } if (app.status == "active") { if (!app.pinCodeChecked) { return "orange"; } return "green"; } else if (app.status == "off") { return "red"; } else { return "red"; } } static void sendTaskErrorToServer(const QString &type, const QString &materialId, const QString &bankName) { QJsonObject errData; errData["status"] = "error"; errData["event_type"] = type; errData["description"] = "Bank profile disabled"; QJsonObject obj; obj["type"] = "ERROR"; obj["material_id"] = materialId; obj["bank_name"] = bankName; obj["data"] = errData; auto *thread = new QThread; auto *net = new NetworkService; net->moveToThread(thread); net->setPayload(QJsonDocument(obj).toJson()); QObject::connect(thread, &QThread::started, net, &NetworkService::postTaskResult); QObject::connect(net, &NetworkService::finished, thread, &QThread::quit); QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater); QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); thread->start(); } void sendBankStatus(const QString &deviceId, const QString &code, const QString &status) { const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, code); if (app.bankProfileId.isEmpty()) { qWarning() << "[DeviceWidget] sendBankStatus: no bankProfileId for" << code; return; } // При выключении — отменяем ожидающие задачи if (status != "active") { const QList cancelled = EventDAO::cancelWaitingByBank(deviceId, code); for (const EventInfo &e : cancelled) { sendTaskErrorToServer(eventTypeToString(e.type), e.externalId, e.bankName); EventDAO::markSentToServer(e.id); } } const bool enable = (status == "active"); auto *thread = new QThread; auto *net = new NetworkService; net->moveToThread(thread); net->addExtra("bank_profile_id", app.bankProfileId); net->addExtra("enable", enable); QObject::connect(thread, &QThread::started, net, &NetworkService::toggleBankProfile); QObject::connect(net, &NetworkService::finished, thread, &QThread::quit); QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater); QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); thread->start(); } void DeviceWidget::buildBankRow(const BankProfileInfo &app, QWidget *rowWidget, QWidget *parent, bool isOffline) { rowWidget->setContentsMargins(0, 0, 0, 0); rowWidget->setStyleSheet( "background-color: rgba(0, 0, 0, 0.6);" // полупрозрачный фон "border-radius: 4px;" // скруглённые углы, если нужно ); // вешаем на контейнер ваш QHBoxLayout auto *hbox = new QHBoxLayout(rowWidget); hbox->setContentsMargins(2, 1, 1, 1); hbox->setSpacing(0); auto *icon = new QLabel(parent); QPixmap pixmap(QString("%1/images/%2_icon.png").arg(QCoreApplication::applicationDirPath(), app.code)); icon->setPixmap(pixmap); icon->setPixmap(pixmap.scaled(18, 18, Qt::KeepAspectRatio, Qt::SmoothTransformation)); icon->setStyleSheet( "background-color: transparent;" "margin: 0px;" ); QString labelText; if (!app.fullName.isEmpty()) { labelText = app.fullName; if (!app.phone.isEmpty()) { labelText += "\n" + app.phone; } } else { labelText = app.name; } auto *label = new QLabel(labelText, parent); label->setStyleSheet( "background-color: transparent;" "margin: 0px;" "font-size: 10px;" "color: white;" ); auto *circle = new QLabel(parent); circle->setFixedSize(12, 12); const QString color = getColorByStatus(app); circle->setStyleSheet( "margin: 0px;" "background-color: " + color + "; " "border-radius: 6px;" ); // собираем if (app.install && app.pinCodeChecked && (app.status == "active" || app.status == "off")) { auto *btn1 = new QPushButton(parent); bool turnOn = app.pinCodeChecked && app.status == "active"; QIcon btnIcon( QCoreApplication::applicationDirPath() + (turnOn ? "/images/turn_off.png" : "/images/turn_on.png")); btn1->setIcon(btnIcon); btn1->setIconSize(QSize(16, 16)); btn1->setFlat(true); btn1->setStyleSheet( "margin: 0px;" "background-color: transparent;" "border: none;" ); if (isOffline) { btn1->setEnabled(false); } connect(btn1, &QPushButton::clicked, this, [app]() { const QString newStatus = app.status == "active" ? "off" : "active"; QMessageBox msgBox(nullptr); msgBox.setWindowTitle(newStatus == "off" ? "Отключить банк?" : "Включить банк?"); msgBox.setText( "Вы уверены, что хотите " + QString(newStatus == "off" ? "отключить от работы" : "включить") + " данный банк: " + app.name + "?"); QPushButton *confirmBtn = msgBox.addButton(newStatus == "off" ? "Отключить" : "Включить", QMessageBox::AcceptRole); QPushButton *cancelBtn = msgBox.addButton("Отмена", QMessageBox::RejectRole); msgBox.setDefaultButton(cancelBtn); msgBox.setModal(true); msgBox.exec(); if (msgBox.clickedButton() == confirmBtn) { // При включении — проверяем что есть хотя бы одна активная карта if (newStatus == "active") { const QList materials = MaterialDAO::getAccounts(app.deviceId, app.code); bool hasActive = false; for (const auto &m : materials) { if (m.status == MaterialStatus::Active) { hasActive = true; break; } } if (!hasActive) { QMessageBox::warning(nullptr, "Невозможно включить", "Ни одна карта не активна.\nВключите хотя бы одну карту перед включением профиля."); return; } } if (!BankProfileDAO::update(app.id, app.pinCode, app.comment, newStatus)) { qDebug() << "Cant update bank data"; } else { sendBankStatus(app.deviceId, app.code, newStatus); // При включении — включаем на сервере все локально активные материалы if (newStatus == "active") { const QList materials = MaterialDAO::getAccounts(app.deviceId, app.code); for (const auto &mat : materials) { if (mat.status == MaterialStatus::Active && !mat.materialId.isEmpty()) { auto *thr = new QThread; auto *ns = new NetworkService; ns->moveToThread(thr); ns->addExtra("material_id", mat.materialId); ns->addExtra("enable", true); QObject::connect(thr, &QThread::started, ns, &NetworkService::toggleMaterial); QObject::connect(ns, &NetworkService::finished, thr, &QThread::quit); QObject::connect(ns, &NetworkService::finished, ns, &QObject::deleteLater); QObject::connect(thr, &QThread::finished, thr, &QThread::deleteLater); thr->start(); } } } } } }); hbox->addWidget(btn1); } auto *btn2 = new QPushButton(parent); QIcon btn2Icon(QCoreApplication::applicationDirPath() + "/images/cogwheel.png"); btn2->setIcon(btn2Icon); btn2->setIconSize(QSize(16, 16)); btn2->setFlat(true); btn2->setStyleSheet( "margin: 0px;" "background-color: transparent;" "border: none;" ); // в конструкторе окна или сразу после создания btn2: connect(btn2, &QPushButton::pressed, this, [btn2] { auto anim = new QPropertyAnimation(btn2, "geometry"); anim->setDuration(120); QRect orig = btn2->geometry(); QRect smaller = orig.adjusted(orig.width() * 0.05, orig.height() * 0.05, -orig.width() * 0.05, -orig.height() * 0.05); anim->setStartValue(orig); anim->setKeyValueAt(0.5, smaller); anim->setEndValue(orig); anim->setEasingCurve(QEasingCurve::InOutQuad); anim->start(QAbstractAnimation::DeleteWhenStopped); }); setOpenBanksSettings(btn2, app); hbox->addWidget(icon); hbox->addWidget(label, 1); hbox->addWidget(circle); hbox->addWidget(btn2); // hbox->addStretch(); } void DeviceWidget::setOpenBanksSettings(QPushButton *button, const BankProfileInfo &app) { QString deviceId = m_device.id; QString deviceName = m_device.name; connect(button, &QPushButton::clicked, this, [deviceId, deviceName, app]() { const BankProfileInfo dbApp = BankProfileDAO::getApplication(deviceId, app.code); if (dbApp.id < 0 || (!dbApp.pinCodeChecked && dbApp.bankProfileId.isEmpty())) { auto *wizard = new BankSetupWizard(nullptr, deviceId, dbApp.id >= 0 ? dbApp : app); wizard->setAttribute(Qt::WA_DeleteOnClose); wizard->show(); } else { static QPointer bankSettingsWindow; if (!bankSettingsWindow) { bankSettingsWindow = new AccountSettingsWindow(nullptr, deviceId, deviceName, dbApp); bankSettingsWindow->setWindowFlags(bankSettingsWindow->windowFlags() | Qt::Window); bankSettingsWindow->setAttribute(Qt::WA_DeleteOnClose); } bankSettingsWindow->show(); bankSettingsWindow->raise(); bankSettingsWindow->activateWindow(); } }); } DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(parent), m_device(device) { auto *layout = new QGridLayout(this); layout->setContentsMargins(2, 2, 2, 2); layout->setSpacing(0); this->setStyleSheet("background-color: lightgray;padding: 2px;"); auto *imageLabel = new QLabel(this); if (!device.image.isEmpty()) { QImage img; img.loadFromData(device.image, "JPG"); imageLabel->setPixmap(QPixmap::fromImage(img).scaled(300, 450, Qt::KeepAspectRatio, Qt::SmoothTransformation)); } else { imageLabel->setFixedSize(300, 450); imageLabel->setStyleSheet("background-color: #555;"); } imageLabel->setAlignment(Qt::AlignCenter); layout->addWidget(imageLabel, 0, 0); auto *statusLabel = new QLabel(QString("%1 %2%").arg(device.name).arg(device.battery), this); statusLabel->setWordWrap(true); statusLabel->setMaximumWidth(300); statusLabel->setStyleSheet( "background-color: rgba(0, 0, 0, 0.5);" "color: white;" "font-size: 10px;" ); layout->addWidget(statusLabel, 0, 0, Qt::AlignTop | Qt::AlignLeft); // Кнопки настроек и удаления (справа сверху) auto *topRightWidget = new QWidget(this); topRightWidget->setStyleSheet("background: transparent;"); auto *topRightLayout = new QVBoxLayout(topRightWidget); topRightLayout->setContentsMargins(0, 0, 0, 0); topRightLayout->setSpacing(2); auto *settingsBtn = new QPushButton(this); settingsBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/cogwheel.png")); settingsBtn->setIconSize(QSize(20, 20)); settingsBtn->setFixedSize(28, 28); settingsBtn->setFlat(true); settingsBtn->setStyleSheet( "background-color: rgba(0, 0, 0, 0.5);" "border: none;" "border-radius: 4px;" ); connect(settingsBtn, &QPushButton::clicked, this, [this]() { auto *window = new DeviceSettingsWindow(nullptr, m_device.id, m_device.name); window->setAttribute(Qt::WA_DeleteOnClose); window->show(); window->raise(); window->activateWindow(); }); auto *deleteBtn = new QPushButton(this); deleteBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/delete.png")); deleteBtn->setIconSize(QSize(20, 20)); deleteBtn->setFixedSize(28, 28); deleteBtn->setFlat(true); deleteBtn->setStyleSheet( "background-color: rgba(0, 0, 0, 0.5);" "border: none;" "border-radius: 4px;" ); connect(deleteBtn, &QPushButton::clicked, this, [this]() { const QString deviceId = m_device.id; const QString deviceName = m_device.name; QPointer guard(this); QMessageBox msgBox(nullptr); msgBox.setWindowTitle(tr("Удалить устройство?")); msgBox.setText( "Будет удалено устройство " + deviceName + " и все связанные данные: банки, аккаунты, транзакции и события."); QPushButton *confirmBtn = msgBox.addButton(tr("Удалить"), QMessageBox::AcceptRole); QPushButton *cancelBtn = msgBox.addButton(tr("Отмена"), QMessageBox::RejectRole); msgBox.setDefaultButton(cancelBtn); msgBox.setModal(true); msgBox.exec(); if (msgBox.clickedButton() == confirmBtn) { auto *thread = new QThread; auto *net = new NetworkService; net->setDeviceId(deviceId); net->moveToThread(thread); QObject::connect(thread, &QThread::started, net, &NetworkService::deleteDeviceFromServer); QObject::connect(net, &NetworkService::finished, thread, &QThread::quit); QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater); QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); thread->start(); } }); auto *codeBtn = new QPushButton(this); codeBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/code.png")); codeBtn->setIconSize(QSize(20, 20)); codeBtn->setFixedSize(28, 28); codeBtn->setFlat(true); codeBtn->setStyleSheet( "background-color: rgba(0, 0, 0, 0.5);" "border: none;" "border-radius: 4px;" ); connect(codeBtn, &QPushButton::clicked, this, [this, codeBtn]() { if (m_device.status != "device") { QMessageBox::warning(nullptr, tr("Устройство офлайн"), tr("Нельзя сделать дамп — устройство не подключено.")); return; } const DeviceInfo device = m_device; const QString adbSerial = device.adbSerial.isEmpty() ? device.id : device.adbSerial; QSettings settings(QCoreApplication::applicationDirPath() + "/config.ini", QSettings::IniFormat); const QString apiBase = settings.value("net/apiBase").toString(); const QString path = settings.value("net/pathMonitoringDump", "/monitoring/dump").toString(); if (apiBase.isEmpty()) { QMessageBox::warning(nullptr, tr("Ошибка"), tr("В config.ini не задан net/apiBase")); return; } codeBtn->setEnabled(false); qDebug() << "[Dump] start for device" << adbSerial; auto *progress = new QProgressDialog(tr("Сбор дампа..."), QString(), 0, 0, nullptr); progress->setWindowTitle(tr("Дамп")); progress->setWindowModality(Qt::ApplicationModal); progress->setCancelButton(nullptr); progress->setMinimumDuration(0); progress->setAutoClose(false); progress->setAutoReset(false); progress->setWindowFlags(progress->windowFlags() & ~Qt::WindowCloseButtonHint & ~Qt::WindowContextHelpButtonHint); progress->show(); // ADB-захват блокирует поток на несколько секунд — выносим в воркер, // чтобы прогресс-диалог не замораживался. struct DumpData { QByteArray screenshot; QString xml; }; auto *data = new DumpData; auto *worker = QThread::create([data, adbSerial]() { qDebug() << "[Dump] worker: capturing screenshot"; data->screenshot = AdbUtils::captureScreenshotBytes(adbSerial); qDebug() << "[Dump] worker: screenshot size=" << data->screenshot.size(); qDebug() << "[Dump] worker: capturing xml"; data->xml = AdbUtils::getRawScreenDumpAsXml(adbSerial); qDebug() << "[Dump] worker: xml size=" << data->xml.size(); }); // Общий флаг "уже обработано" — чтобы страховочный таймаут не сработал // одновременно с finished. Шарится через shared_ptr, чтобы пережить лямбды. auto handled = std::make_shared(false); QPointer btnGuard(codeBtn); QPointer progressGuard(progress); auto closeAndReenable = [progressGuard, btnGuard]() { if (progressGuard) { progressGuard->reset(); progressGuard->hide(); progressGuard->deleteLater(); } if (btnGuard) btnGuard->setEnabled(true); }; // Страховочный таймаут на весь процесс (ADB + upload). Если что-то висит — // диалог гарантированно закроется, и пользователь получит ошибку. // Важно: владелец таймера и приёмник колбэков — qApp, а НЕ `this`. // DeviceWidget пересоздаётся DeviceScreener-ом при каждом опросе устройств, // и если повесить connect(this, ...), при пересборке списка соединение // тихо рвётся, колбэк не запускается, диалог живёт вечно. auto *safetyTimer = new QTimer; safetyTimer->setSingleShot(true); QObject::connect(safetyTimer, &QTimer::timeout, qApp, [handled, closeAndReenable, worker, safetyTimer]() { if (*handled) return; *handled = true; qWarning() << "[Dump] timeout — aborting"; if (worker && worker->isRunning()) { worker->terminate(); worker->wait(2000); } if (worker) worker->deleteLater(); safetyTimer->deleteLater(); closeAndReenable(); QMessageBox::warning(nullptr, QObject::tr("Таймаут"), QObject::tr("Сбор/отправка дампа заняли больше 90 секунд")); }); safetyTimer->start(90000); QObject::connect(worker, &QThread::finished, qApp, [worker, data, device, adbSerial, apiBase, path, handled, closeAndReenable, safetyTimer, progressGuard]() { qDebug() << "[Dump] worker finished"; if (*handled) { delete data; worker->deleteLater(); return; } const QByteArray screenshot = data->screenshot; const QString xml = data->xml; delete data; worker->deleteLater(); if (screenshot.isEmpty() && xml.isEmpty()) { *handled = true; safetyTimer->stop(); safetyTimer->deleteLater(); closeAndReenable(); QMessageBox::warning(nullptr, QObject::tr("Ошибка"), QObject::tr("Не удалось получить дамп экрана")); return; } if (progressGuard) progressGuard->setLabelText(QObject::tr("Отправка дампа...")); qDebug() << "[Dump] uploading: screenshot=" << screenshot.size() << "xml=" << xml.size(); const QString desktopId = SettingsDAO::get("desktop_id"); const QString text = QString("Screen dump\nDevice: %1 (%2)\nDesktop: %3") .arg(device.name, adbSerial, desktopId); auto *mp = new QHttpMultiPart(QHttpMultiPart::FormDataType); QHttpPart textPart; textPart.setHeader(QNetworkRequest::ContentDispositionHeader, R"(form-data; name="text")"); textPart.setBody(text.toUtf8()); mp->append(textPart); if (!xml.isEmpty()) { QString safeName = device.name; safeName.replace(QRegularExpression(R"([^\w\-.])"), "_"); const QString ts = QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss"); const QString xmlFilename = safeName + "_" + ts + ".xml"; QHttpPart filePart; filePart.setHeader(QNetworkRequest::ContentTypeHeader, "application/xml"); filePart.setHeader(QNetworkRequest::ContentDispositionHeader, QString(R"(form-data; name="file"; filename="%1")").arg(xmlFilename).toUtf8()); filePart.setBody(xml.toUtf8()); mp->append(filePart); } if (!screenshot.isEmpty()) { QHttpPart imagePart; imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/jpeg"); imagePart.setHeader(QNetworkRequest::ContentDispositionHeader, R"(form-data; name="image"; filename="screen.jpg")"); imagePart.setBody(screenshot); mp->append(imagePart); } const QUrl url(apiBase + path); QNetworkRequest req(url); const QString accessToken = SettingsDAO::get("access_token"); if (!accessToken.isEmpty()) { req.setRawHeader("Authorization", ("Bearer " + accessToken).toUtf8()); } auto *manager = new QNetworkAccessManager; QNetworkReply *reply = manager->post(req, mp); mp->setParent(reply); QObject::connect(reply, &QNetworkReply::finished, reply, [reply, manager, closeAndReenable, handled, safetyTimer]() { if (*handled) { reply->deleteLater(); manager->deleteLater(); return; } *handled = true; safetyTimer->stop(); safetyTimer->deleteLater(); const QByteArray body = reply->readAll(); bool ok = reply->error() == QNetworkReply::NoError; QString errorDetail; if (!ok) { errorDetail = reply->errorString(); } else { const QJsonDocument doc = QJsonDocument::fromJson(body); if (doc.isObject() && !doc.object().value("success").toBool()) { ok = false; errorDetail = QString::fromUtf8(body.left(200)); } } qDebug() << "[Dump] reply finished: ok=" << ok << "detail=" << errorDetail; reply->deleteLater(); manager->deleteLater(); closeAndReenable(); if (ok) { QMessageBox::information(nullptr, QObject::tr("Готово"), QObject::tr("Дамп отправлен")); } else { QMessageBox::warning(nullptr, QObject::tr("Ошибка отправки"), QObject::tr("Не удалось отправить дамп:\n%1").arg(errorDetail)); } }); }); worker->start(); }); topRightLayout->addWidget(settingsBtn); topRightLayout->addWidget(deleteBtn); topRightLayout->addWidget(codeBtn); layout->addWidget(topRightWidget, 0, 0, Qt::AlignTop | Qt::AlignRight); if (device.status != "device") { auto *offlineLabel = new QLabel("Офлайн", this); offlineLabel->setAlignment(Qt::AlignCenter); offlineLabel->setStyleSheet( "background-color: rgba(0, 0, 0, 0.6);" "color: white;" "font-size: 24px;" "font-weight: bold;" "padding: 10px 20px;" "border-radius: 8px;" ); layout->addWidget(offlineLabel, 0, 0, Qt::AlignCenter); } auto *appsView = new QWidget(this); appsView->setAttribute(Qt::WA_TransparentForMouseEvents, false); appsView->setStyleSheet("background: transparent;"); auto *appsBoxLayout = new QVBoxLayout(appsView); appsBoxLayout->setContentsMargins(2, 0, 0, 2); appsBoxLayout->setSpacing(2); const bool isOffline = device.status != "device"; // Пока устройство не зарегистрировано на сервере (нет apiId) — банки не показываем QList apps; if (!device.apiId.isEmpty()) { apps = BankProfileDAO::getApplicationsByDeviceId(device.id); } // Фильтр по стране профиля const QString profileCountry = SettingsDAO::get("country"); if (!profileCountry.isEmpty()) { const QSettings configSettings("config.ini", QSettings::IniFormat); for (auto it = apps.begin(); it != apps.end();) { const QString bankCountry = configSettings.value(it->code + "/country").toString(); if (bankCountry.compare(profileCountry, Qt::CaseInsensitive) != 0) { it = apps.erase(it); } else { ++it; } } } for (BankProfileInfo &app: apps) { if (app.install) { auto *rowWidget = new QWidget(appsView); buildBankRow(app, rowWidget, appsView, isOffline); appsBoxLayout->addWidget(rowWidget, 1); } } // appsBoxLayout->addStretch(); // кладём overlay в ту же ячейку, что и картинку, // но выравниваем его по левому-нижнему углу layout->setColumnStretch(0, 1); layout->addWidget(appsView, 0, 0, Qt::AlignLeft | Qt::AlignBottom); // layout->addWidget(appsView, 0, 0, Qt::AlignLeft); TODO так на всю ширину // auto *addBankBtn = new QPushButton(this); // addBankBtn->setMaximumWidth(300); // addBankBtn->setText("Добавить банк +"); // addBankBtn->setStyleSheet( // "background-color: #fff;" // "margin-top: 2px;" // "padding: 4px;" // "color: #000;" // ); // setOpenBanksSettings(addBankBtn); // layout->addWidget(addBankBtn, 1, 0); }