From b8da23fa6cedf716a1f4f6d18d484ca8005b9781 Mon Sep 17 00:00:00 2001 From: slava Date: Thu, 28 May 2026 19:12:27 +0500 Subject: [PATCH] Add `DesktopSelectionWindow` with UI logic for selecting or creating desktops. Update `NetworkService` to handle desktop selection flow. Improve desktop synchronization and settings management. Enhance mock server to support desktop device data in tests. --- .../dushanbe/GetLastTransactionsScript.cpp | 18 +- services/net/NetworkService.cpp | 155 ++++++++++++------ services/net/NetworkService.h | 19 +++ tests/mock_server/mock_server.py | 13 +- views/MainWindow.cpp | 6 +- .../widget/loader/DesktopSelectionWindow.cpp | 69 ++++++++ views/widget/loader/DesktopSelectionWindow.h | 27 +++ views/widget/loader/LoaderWindow.cpp | 20 +++ views/widget/loader/RegistrationWindow.cpp | 21 +++ 9 files changed, 291 insertions(+), 57 deletions(-) create mode 100644 views/widget/loader/DesktopSelectionWindow.cpp create mode 100644 views/widget/loader/DesktopSelectionWindow.h diff --git a/android/dushanbe/GetLastTransactionsScript.cpp b/android/dushanbe/GetLastTransactionsScript.cpp index ba46dfe..2a78ce9 100644 --- a/android/dushanbe/GetLastTransactionsScript.cpp +++ b/android/dushanbe/GetLastTransactionsScript.cpp @@ -482,7 +482,11 @@ void GetLastTransactionsScript::doStart() { static const QRegularExpression reDateOrToday( QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$") .arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера"))); - static const QRegularExpression reAmountLine(R"(^-?\d+\.\d{2}$)"); + // Сумма: либо с десятичной частью ("-5577.95", "109,50"), либо целая + // ("109" — P2P-зачисления показываются без копеек). Целую часть + // ограничиваем 7 цифрами, чтобы не спутать с 12–16-значным номером + // карты, если он окажется на отдельной строке без пробелов/дефисов. + static const QRegularExpression reAmountLine(R"(^-?(?:\d+[.,]\d{1,2}|\d{1,7})$)"); QList items; for (const Node &n : xmlScreenParser.findAllNodes()) { if (n.className != "android.view.View") continue; @@ -498,12 +502,14 @@ void GetLastTransactionsScript::doStart() { VypiskaItem it; bool ok = false; - // Ищем строку вида "-?\d+\.\d{2}" — устойчиво и к 5-, и к 6-строчному - // DCWallet, и к P2P. Карты с пробелами ("9762 0002 ...") и текстовые - // поля не подпадают под паттерн. + // Ищем строку-сумму — устойчиво и к 5-, и к 6-строчному DCWallet, + // и к P2P. Карты с пробелами ("9762 0002 ...") и дефисами, а также + // текстовые поля не подпадают под паттерн. for (const QString &line : lines) { - if (!reAmountLine.match(line.trimmed()).hasMatch()) continue; - it.amount = line.trimmed().toDouble(&ok); + const QString t = line.trimmed(); + if (!reAmountLine.match(t).hasMatch()) continue; + QString v = t; v.replace(',', '.'); + it.amount = v.toDouble(&ok); if (ok) break; } if (!ok) continue; diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 2546d20..0d0ca0f 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -370,69 +370,126 @@ void NetworkService::loginAndSetup() { qDebug() << "[loginAndSetup] profile info: country=" << country << "currency=" << currency; } - // Шаг 2: получаем актуальный desktop с сервера - QString desktopId = SettingsDAO::get("desktop_id"); - - // Всегда проверяем список десктопов — desktop_id мог устареть или быть удалён + // Шаг 2: получаем список десктопов с сервера + const QString savedDesktopId = SettingsDAO::get("desktop_id"); const QJsonValue existingDesktops = getJson(m_apiBase + m_pathDesktopCreate, true); - if (!existingDesktops.isUndefined() && !existingDesktops.isNull() && existingDesktops.isArray()) { - const QJsonArray desktopsArray = existingDesktops.toArray(); - QString foundId; - QString foundProfileId; - for (const QJsonValue &dVal : desktopsArray) { - const QJsonObject d = dVal.toObject(); - if (d["hidden"].toBool(false)) continue; - foundId = d["id"].toString(); - foundProfileId = d["profile_id"].toString(); - if (!foundId.isEmpty()) break; - } - - if (!foundId.isEmpty() && foundId != desktopId) { - // Нашли актуальный десктоп, отличается от сохранённого — обновляем - qDebug() << "[loginAndSetup] desktop_id updated:" << desktopId << "->" << foundId; - desktopId = foundId; - SettingsDAO::set("desktop_id", desktopId); - SettingsDAO::set("profile_id", foundProfileId); - } else if (!foundId.isEmpty()) { - qDebug() << "[loginAndSetup] desktop_id confirmed:" << desktopId; - } - } - - if (desktopId.isEmpty()) { - // Десктопов на сервере нет — создаём новый - QJsonObject desktopBody; - desktopBody["blue_token"] = QJsonValue::Null; - desktopBody["name"] = QJsonValue::Null; - desktopBody["contact"] = QJsonValue::Null; - const QSettings settings("config.ini", QSettings::IniFormat); - desktopBody["app_version"] = settings.value("common/version").toString(); - const QJsonValue desktopResult = postJson(m_apiBase + m_pathDesktopCreate, desktopBody, true); - - if (desktopResult.isUndefined() || desktopResult.isNull()) { + if (existingDesktops.isUndefined()) { + // Сеть/сервер недоступны + if (savedDesktopId.isEmpty()) { emit finishedWithResult(-1); emit finished(); return; } - - const QJsonObject desktopData = desktopResult.toObject(); - desktopId = desktopData["id"].toString(); - SettingsDAO::set("desktop_id", desktopId); - SettingsDAO::set("profile_id", desktopData["profile_id"].toString()); + // Есть сохранённый выбор — продолжаем с ним (best effort) + qDebug() << "[loginAndSetup] desktop list unavailable, using saved" << savedDesktopId; + finishSetupWithDesktop(savedDesktopId); + return; } - // Шаг 3: синхронизация устройств с сервера + // Собираем не скрытые десктопы + QJsonArray visibleDesktops; + if (existingDesktops.isArray()) { + for (const QJsonValue &dVal : existingDesktops.toArray()) { + const QJsonObject d = dVal.toObject(); + if (d["hidden"].toBool(false)) continue; + visibleDesktops.append(d); + } + } + + // 1) Сохранённый выбор всё ещё валиден — используем молча (как раньше) + if (!savedDesktopId.isEmpty()) { + for (const QJsonValue &dVal : visibleDesktops) { + const QJsonObject d = dVal.toObject(); + if (d["id"].toString() == savedDesktopId) { + SettingsDAO::set("profile_id", d["profile_id"].toString()); + SettingsDAO::set("desktop_unique_name", d["unique_name"].toString()); + qDebug() << "[loginAndSetup] desktop_id confirmed:" << savedDesktopId; + finishSetupWithDesktop(savedDesktopId); + return; + } + } + } + + // 2) Валидного сохранённого выбора нет + if (visibleDesktops.isEmpty()) { + // Десктопов на сервере нет — создаём автоматически и запускаемся + const QString newId = createDesktopOnServer(); + if (newId.isEmpty()) { + emit finishedWithResult(-1); + emit finished(); + return; + } + finishSetupWithDesktop(newId); + return; + } + + // 3) Есть из чего выбирать — спрашиваем пользователя. + // НЕ эмитим finished(): поток ждёт continueWithDesktop() из UI. + m_desktops = visibleDesktops; + qDebug() << "[loginAndSetup] desktop selection required, count:" << visibleDesktops.size(); + emit desktopSelectionRequired(visibleDesktops); +} + +QString NetworkService::createDesktopOnServer() { + QJsonObject desktopBody; + desktopBody["blue_token"] = QJsonValue::Null; + desktopBody["name"] = QJsonValue::Null; + desktopBody["contact"] = QJsonValue::Null; + const QSettings settings("config.ini", QSettings::IniFormat); + desktopBody["app_version"] = settings.value("common/version").toString(); + const QJsonValue desktopResult = postJson(m_apiBase + m_pathDesktopCreate, desktopBody, true); + + if (desktopResult.isUndefined() || desktopResult.isNull()) { + qWarning() << "[createDesktopOnServer] failed to create desktop"; + return QString(); + } + + const QJsonObject desktopData = desktopResult.toObject(); + const QString id = desktopData["id"].toString(); + SettingsDAO::set("desktop_id", id); + SettingsDAO::set("profile_id", desktopData["profile_id"].toString()); + SettingsDAO::set("desktop_unique_name", desktopData["unique_name"].toString()); + qDebug() << "[createDesktopOnServer] created desktop" << id; + return id; +} + +void NetworkService::continueWithDesktop(const QString &desktopId) { + QString resolvedId = desktopId; + if (resolvedId.isEmpty()) { + // Пользователь выбрал «Создать новый» + resolvedId = createDesktopOnServer(); + if (resolvedId.isEmpty()) { + emit finishedWithResult(-1); + emit finished(); + return; + } + } else { + SettingsDAO::set("desktop_id", resolvedId); + for (const QJsonValue &dVal : m_desktops) { + const QJsonObject d = dVal.toObject(); + if (d["id"].toString() == resolvedId) { + SettingsDAO::set("profile_id", d["profile_id"].toString()); + SettingsDAO::set("desktop_unique_name", d["unique_name"].toString()); + break; + } + } + qDebug() << "[continueWithDesktop] selected desktop" << resolvedId; + } + finishSetupWithDesktop(resolvedId); +} + +void NetworkService::finishSetupWithDesktop(const QString &desktopId) { + // Шаг 3: синхронизация устройств с сервера (scoped по desktopId) syncDevicesFromServer(desktopId); // Шаг 4: синхронизация банковских профилей и материалов с сервера syncBankProfilesFromServer(); - // Шаг 5: убран (bank_participator_name_ru приходит в задаче) - - // Шаг 6: синхронизация статусов профилей/материалов с учётом онлайн-устройств + // Шаг 5: синхронизация статусов профилей/материалов с учётом онлайн-устройств syncCurrentProfile(); - // Шаг 7: отправляем OFFLINE статус всех устройств (DeviceScreener обновит на ONLINE при обнаружении) + // Шаг 6: отправляем OFFLINE статус всех устройств (DeviceScreener обновит на ONLINE при обнаружении) { const QList allDevices = DeviceDAO::getAllDevices(false); for (const DeviceInfo &d : allDevices) { @@ -443,7 +500,7 @@ void NetworkService::loginAndSetup() { body["battery"] = d.battery; body["update_time"] = QDateTime::currentSecsSinceEpoch(); patchJson(m_apiBase + m_pathDevice + d.apiId, body, true); - qDebug() << "[loginAndSetup] sent OFFLINE for device" << d.id << d.apiId; + qDebug() << "[finishSetupWithDesktop] sent OFFLINE for device" << d.id << d.apiId; } } diff --git a/services/net/NetworkService.h b/services/net/NetworkService.h index 53f4734..f39adc5 100644 --- a/services/net/NetworkService.h +++ b/services/net/NetworkService.h @@ -71,6 +71,10 @@ public slots: void updateTransactionData(); void loginAndSetup(); + // Продолжение loginAndSetup после выбора десктопа пользователем. + // Пустой desktopId — выбран пункт «Создать новый». Вызывать через + // QueuedConnection: слот исполняется в потоке NetworkService. + void continueWithDesktop(const QString &desktopId); // true — GET current_profile прошёл; false — таймаут/сеть. Используется // health-счётчиком в runPeriodicSyncTick (правило #9). bool syncCurrentProfile(); @@ -121,6 +125,10 @@ public slots: signals: void finished(); void finishedWithResult(int value); + // Эмитится из loginAndSetup, когда на сервере есть десктопы, но локального + // выбора нет — UI показывает окно выбора. После выбора UI вызывает + // continueWithDesktop(). До этого finished() НЕ эмитится (поток ждёт). + void desktopSelectionRequired(const QJsonArray &desktops); void tokenRefreshFailed(); void tasksReceived(QJsonArray tasks); // Эмитится при HTTP-ошибке PATCH-запроса (например, 409 'материал заблокирован'). @@ -208,6 +216,17 @@ private: void syncBankProfilesFromServer(); void syncBanksIfEmpty(); + // POST /api/v1/desktop/ — создаёт новый десктоп, сохраняет desktop_id и + // profile_id в настройки. Возвращает id или пустую строку при ошибке. + QString createDesktopOnServer(); + // Завершающая часть loginAndSetup: синхронизация устройств/профилей/статусов + // для выбранного desktopId и эмит finishedWithResult(1)+finished(). + void finishSetupWithDesktop(const QString &desktopId); + + // Не скрытые десктопы с сервера, сохранённые между loginAndSetup и + // continueWithDesktop (для поиска profile_id по выбранному id). + QJsonArray m_desktops; + public: // Возвращает (лениво создавая) shared mutex для пары (deviceId, bankProfileId). // Используется через QMutexLocker для сериализации PATCH-ов по одному профилю. diff --git a/tests/mock_server/mock_server.py b/tests/mock_server/mock_server.py index 4c273a7..3a0c00e 100644 --- a/tests/mock_server/mock_server.py +++ b/tests/mock_server/mock_server.py @@ -272,11 +272,18 @@ def auth_refresh(): # ---------------------------------------------------------------- desktop @app.get("/api/v1/desktop/") def desktop_list(): + if os.environ.get("MOCK_EMPTY_DESKTOPS"): + return ok([]) return ok([{ "id": _desktop_id, "profile_id": _profile_id, "hidden": False, "name": "mock-desktop", + "unique_name": "desktop_" + _desktop_id.replace("-", "")[:8], + "devices": [ + {"id": str(uuid.uuid4()), "name": "Infinix X6525D-da7b22b87a0da954"}, + {"id": str(uuid.uuid4()), "name": "samsung SM-A155F-1a2b3c4d5e6f7a8b"}, + ], }]) @@ -284,7 +291,11 @@ def desktop_list(): def desktop_create(): body = request.get_json(silent=True) or {} log("POST /desktop/", body) - return ok({"id": _desktop_id, "profile_id": _profile_id}) + return ok({ + "id": _desktop_id, + "profile_id": _profile_id, + "unique_name": "desktop_" + _desktop_id.replace("-", "")[:8], + }) # ---------------------------------------------------------------- device diff --git a/views/MainWindow.cpp b/views/MainWindow.cpp index 5dde497..d20dba8 100644 --- a/views/MainWindow.cpp +++ b/views/MainWindow.cpp @@ -17,6 +17,7 @@ #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" @@ -32,7 +33,10 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { const QSettings settings("config.ini", QSettings::IniFormat); const QString version = settings.value("common/version").toString(); - setWindowTitle(version.isEmpty() ? "ARC" : QString("ARC v%1").arg(version)); + 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); diff --git a/views/widget/loader/DesktopSelectionWindow.cpp b/views/widget/loader/DesktopSelectionWindow.cpp new file mode 100644 index 0000000..2a19ce4 --- /dev/null +++ b/views/widget/loader/DesktopSelectionWindow.cpp @@ -0,0 +1,69 @@ +#include "DesktopSelectionWindow.h" + +#include +#include +#include +#include +#include +#include + +namespace { +// Имена устройств десктопа (поле devices ответа /api/v1/desktop/). +// Отрезаем суффикс после последнего тире: "Infinix X6525D-da7b22..." -> "Infinix X6525D". +QStringList deviceNamesOf(const QJsonObject &d) { + QStringList names; + for (const QJsonValue &devVal : d["devices"].toArray()) { + const QJsonObject dev = devVal.toObject(); + QString name = dev["name"].toString(); + if (name.isEmpty()) name = dev["unique_name"].toString(); + const int dash = name.lastIndexOf('-'); + if (dash > 0) name = name.left(dash); + if (!name.isEmpty()) names << name; + } + return names; +} +} // namespace + +DesktopSelectionWindow::DesktopSelectionWindow(const QJsonArray &desktops, QWidget *parent) + : QWidget(parent, Qt::Window) { + setWindowTitle("Выбор десктопа"); + setMinimumWidth(360); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(new QLabel("Выберите десктоп для синхронизации:")); + + for (const QJsonValue &dVal : desktops) { + const QJsonObject d = dVal.toObject(); + const QString id = d["id"].toString(); + QString title = d["unique_name"].toString(); + if (title.isEmpty()) title = id; + + const QStringList devices = deviceNamesOf(d); + QString text = title; + if (!devices.isEmpty()) text += "\n" + devices.join("\n"); + + auto *btn = new QPushButton(text); + btn->setStyleSheet("text-align: left; padding: 8px;"); + connect(btn, &QPushButton::clicked, this, [this, id]() { + m_chosen = true; + emit desktopChosen(id); + close(); + }); + layout->addWidget(btn); + } + + auto *createBtn = new QPushButton("Создать новый"); + createBtn->setStyleSheet("padding: 8px;"); + connect(createBtn, &QPushButton::clicked, this, [this]() { + m_chosen = true; + emit desktopChosen(QString()); + close(); + }); + layout->addWidget(createBtn); +} + +void DesktopSelectionWindow::closeEvent(QCloseEvent *event) { + // Закрытие окна без выбора (крестик) трактуем как отмену. + if (!m_chosen) emit cancelled(); + QWidget::closeEvent(event); +} diff --git a/views/widget/loader/DesktopSelectionWindow.h b/views/widget/loader/DesktopSelectionWindow.h new file mode 100644 index 0000000..d279ab0 --- /dev/null +++ b/views/widget/loader/DesktopSelectionWindow.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +class QCloseEvent; + +// Окно выбора десктопа при старте. Показывается, когда на сервере есть десктопы, +// но локального выбора нет (см. NetworkService::desktopSelectionRequired). +// Десктопы выводятся кнопками: название и под ним список устройств. +class DesktopSelectionWindow final : public QWidget { + Q_OBJECT + +public: + explicit DesktopSelectionWindow(const QJsonArray &desktops, QWidget *parent = nullptr); + +signals: + // Пустой desktopId — выбрана кнопка «Создать новый». + void desktopChosen(const QString &desktopId); + void cancelled(); + +protected: + void closeEvent(QCloseEvent *event) override; + +private: + bool m_chosen = false; +}; diff --git a/views/widget/loader/LoaderWindow.cpp b/views/widget/loader/LoaderWindow.cpp index cce138a..37df49c 100644 --- a/views/widget/loader/LoaderWindow.cpp +++ b/views/widget/loader/LoaderWindow.cpp @@ -2,7 +2,9 @@ #include #include #include +#include +#include "DesktopSelectionWindow.h" #include "net/NetworkService.h" LoaderWindow::LoaderWindow(QWidget *parent) : QWidget(parent) { @@ -42,6 +44,24 @@ LoaderWindow::LoaderWindow(QWidget *parent) : QWidget(parent) { emit loadingFinished(m_checkResult); } }, Qt::QueuedConnection); + + // На сервере есть десктопы, но локального выбора нет — показываем окно выбора. + // loginAndSetup продолжится в continueWithDesktop() после выбора пользователя. + connect(networkService, &NetworkService::desktopSelectionRequired, this, + [this, networkService](const QJsonArray &desktops) { + auto *dialog = new DesktopSelectionWindow(desktops); + dialog->setAttribute(Qt::WA_DeleteOnClose); + // Контекст this (UI-поток): слот не должен исполняться в воркер-потоке + // networkService — любые UI-операции вне main thread на macOS падают. + connect(dialog, &DesktopSelectionWindow::desktopChosen, this, + [networkService](const QString &id) { + QMetaObject::invokeMethod(networkService, "continueWithDesktop", + Qt::QueuedConnection, Q_ARG(QString, id)); + }); + connect(dialog, &DesktopSelectionWindow::cancelled, qApp, &QApplication::quit); + dialog->show(); + }, Qt::QueuedConnection); + thread->start(); } diff --git a/views/widget/loader/RegistrationWindow.cpp b/views/widget/loader/RegistrationWindow.cpp index 650ea27..4b872ca 100644 --- a/views/widget/loader/RegistrationWindow.cpp +++ b/views/widget/loader/RegistrationWindow.cpp @@ -9,6 +9,7 @@ #include #include +#include "DesktopSelectionWindow.h" #include "LoaderDialog.h" #include "dao/SettingsDAO.h" #include "net/NetworkService.h" @@ -73,6 +74,26 @@ RegistrationWindow::RegistrationWindow(QWidget *parent): QWidget(parent) { } }, Qt::QueuedConnection); }); + + // На сервере есть десктопы, но локального выбора нет — показываем окно выбора. + // loginAndSetup продолжится в continueWithDesktop() после выбора пользователя. + connect(networkService, &NetworkService::desktopSelectionRequired, this, + [this, networkService, loader](const QJsonArray &desktops) { + loader->close(); + auto *dialog = new DesktopSelectionWindow(desktops, this); + dialog->setAttribute(Qt::WA_DeleteOnClose); + // Контекст this (UI-поток): loader->show() обязан выполняться + // в главном потоке, иначе создание NSWindow вне main thread → краш. + connect(dialog, &DesktopSelectionWindow::desktopChosen, this, + [networkService, loader](const QString &id) { + loader->show(); + QMetaObject::invokeMethod(networkService, "continueWithDesktop", + Qt::QueuedConnection, Q_ARG(QString, id)); + }); + connect(dialog, &DesktopSelectionWindow::cancelled, qApp, &QApplication::quit); + dialog->show(); + }, Qt::QueuedConnection); + thread->start(); }); }