diff --git a/src/app/ProjectListDialog.cpp b/src/app/ProjectListDialog.cpp index 950df35..0324196 100644 --- a/src/app/ProjectListDialog.cpp +++ b/src/app/ProjectListDialog.cpp @@ -15,6 +15,8 @@ #include #include "Theme.hpp" +#include "api/NavLoads.hpp" +#include "api/NavRequest.hpp" namespace geopro::app { @@ -37,7 +39,7 @@ QColor statusColor(int s) { } } // namespace -ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* parent) +ProjectListDialog::ProjectListDialog(data::IAsyncProjectRepository& repo, QWidget* parent) : QDialog(parent), repo_(repo) { setWindowTitle(QStringLiteral("全部项目")); resize(980, 560); @@ -125,57 +127,85 @@ ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* pa query(); } +ProjectListDialog::~ProjectListDialog() { + // 退出契约:模态 exec 关窗后本对话框析构 → abort 在飞请求,防回调打到已析构窗口。 + if (typesReq_) typesReq_->abort(); + if (queryReq_) queryReq_->abort(); +} + void ProjectListDialog::fillTypeFilter() { typeCombo_->addItem(QStringLiteral("全部类型"), QString()); - const auto r = repo_.listProjectTypes(); - if (!r.ok) return; - for (const auto& t : r.value) - typeCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id)); + // abort-and-replace:最新一次过滤填充为准。 + if (typesReq_) typesReq_->abort(); + auto* r = repo_.listProjectTypesAsync(); + typesReq_ = r; + QObject::connect(r, &data::NavRequest::done, this, [this, r](const QVariant& v) { + if (r != typesReq_) return; // 身份比对:丢弃迟到/被替换信号 + typesReq_.clear(); + const auto types = qvariant_cast>(v); + for (const auto& t : types) + typeCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id)); + }); + QObject::connect(r, &data::NavRequest::failed, this, [this, r](const QString&) { + if (r != typesReq_) return; + typesReq_.clear(); + // 失败时仅保留“全部类型”项(与原同步版失败仅 return 一致)。 + }); } void ProjectListDialog::query() { const std::string name = nameEdit_->text().trimmed().toStdString(); const std::string typeId = typeCombo_->currentData().toString().toStdString(); - const auto r = repo_.pageProjects(name, typeId, pageNo_, pageSize_); - if (!r.ok) { + // abort-and-replace:丢弃上一查询,仅最新结果落表。 + if (queryReq_) queryReq_->abort(); + auto* r = repo_.pageProjectsAsync(name, typeId, pageNo_, pageSize_); + queryReq_ = r; + QObject::connect(r, &data::NavRequest::done, this, [this, r](const QVariant& v) { + if (r != queryReq_) return; // 身份比对 + queryReq_.clear(); + const auto page = qvariant_cast(v); + total_ = page.total; + const auto& rows = page.rows; + table_->setRowCount(static_cast(rows.size())); + for (int i = 0; i < static_cast(rows.size()); ++i) { + const auto& p = rows[i]; + auto set = [&](int col, const QString& text) { + table_->setItem(i, col, new QTableWidgetItem(text)); + }; + set(0, QString::number((pageNo_ - 1) * pageSize_ + i + 1)); + auto* nameItem = new QTableWidgetItem(QString::fromStdString(p.name)); + nameItem->setData(Qt::UserRole, QString::fromStdString(p.id)); + nameItem->setForeground(tokenColor("accent/primary")); + table_->setItem(i, 1, nameItem); + set(2, QString::fromStdString(p.code)); + // 状态列语义着色:颜色承载“未开始/进行中”分类,进行中加粗强调(不只靠颜色)。 + auto* statusItem = new QTableWidgetItem(statusText(p.status)); + statusItem->setForeground(statusColor(p.status)); + if (p.status == 2) { + QFont f = statusItem->font(); + f.setBold(true); + statusItem->setFont(f); + } + table_->setItem(i, 3, statusItem); + set(4, QString::fromStdString(p.typeName)); + set(5, QString::fromStdString(p.ownerCompany)); + set(6, QString::fromStdString(p.responsiblePerson)); + set(7, QString::fromStdString(p.createTime)); + } + const int pages = total_ > 0 ? (total_ + pageSize_ - 1) / pageSize_ : 1; + pageLabel_->setText( + QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages)); + prevBtn_->setEnabled(pageNo_ > 1); + nextBtn_->setEnabled(pageNo_ < pages); + }); + QObject::connect(r, &data::NavRequest::failed, this, [this, r](const QString& msg) { + if (r != queryReq_) return; + queryReq_.clear(); table_->setRowCount(0); - pageLabel_->setText(QStringLiteral("加载失败:%1").arg(QString::fromStdString(r.error))); + pageLabel_->setText(QStringLiteral("加载失败:%1").arg(msg)); prevBtn_->setEnabled(false); nextBtn_->setEnabled(false); - return; - } - total_ = r.value.total; - const auto& rows = r.value.rows; - table_->setRowCount(static_cast(rows.size())); - for (int i = 0; i < static_cast(rows.size()); ++i) { - const auto& p = rows[i]; - auto set = [&](int col, const QString& text) { - table_->setItem(i, col, new QTableWidgetItem(text)); - }; - set(0, QString::number((pageNo_ - 1) * pageSize_ + i + 1)); - auto* nameItem = new QTableWidgetItem(QString::fromStdString(p.name)); - nameItem->setData(Qt::UserRole, QString::fromStdString(p.id)); - nameItem->setForeground(tokenColor("accent/primary")); - table_->setItem(i, 1, nameItem); - set(2, QString::fromStdString(p.code)); - // 状态列语义着色:颜色承载“未开始/进行中”分类,进行中加粗强调(不只靠颜色)。 - auto* statusItem = new QTableWidgetItem(statusText(p.status)); - statusItem->setForeground(statusColor(p.status)); - if (p.status == 2) { - QFont f = statusItem->font(); - f.setBold(true); - statusItem->setFont(f); - } - table_->setItem(i, 3, statusItem); - set(4, QString::fromStdString(p.typeName)); - set(5, QString::fromStdString(p.ownerCompany)); - set(6, QString::fromStdString(p.responsiblePerson)); - set(7, QString::fromStdString(p.createTime)); - } - const int pages = total_ > 0 ? (total_ + pageSize_ - 1) / pageSize_ : 1; - pageLabel_->setText(QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages)); - prevBtn_->setEnabled(pageNo_ > 1); - nextBtn_->setEnabled(pageNo_ < pages); + }); } } // namespace geopro::app diff --git a/src/app/ProjectListDialog.hpp b/src/app/ProjectListDialog.hpp index c0675a8..eda588f 100644 --- a/src/app/ProjectListDialog.hpp +++ b/src/app/ProjectListDialog.hpp @@ -1,7 +1,8 @@ #pragma once #include +#include -#include "repo/IProjectRepository.hpp" +#include "repo/IAsyncProjectRepository.hpp" class QLineEdit; class QComboBox; @@ -9,13 +10,16 @@ class QTableWidget; class QLabel; class QPushButton; +namespace geopro::data { class NavRequest; } + namespace geopro::app { // 项目列表弹窗:名称/类型过滤 + 分页表格;点项目名 → 切换项目并关闭。 class ProjectListDialog : public QDialog { Q_OBJECT public: - explicit ProjectListDialog(data::IProjectRepository& repo, QWidget* parent = nullptr); + explicit ProjectListDialog(data::IAsyncProjectRepository& repo, QWidget* parent = nullptr); + ~ProjectListDialog() override; signals: void projectChosen(const QString& projectId, const QString& projectName); @@ -24,7 +28,9 @@ private: void query(); void fillTypeFilter(); - data::IProjectRepository& repo_; + data::IAsyncProjectRepository& repo_; + QPointer typesReq_; + QPointer queryReq_; QLineEdit* nameEdit_ = nullptr; QComboBox* typeCombo_ = nullptr; QTableWidget* table_ = nullptr; diff --git a/src/app/main.cpp b/src/app/main.cpp index 6e989a7..1dafd25 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -193,7 +193,7 @@ constexpr const char* kWgs84 = "EPSG:4326"; // 在给定 QMainWindow 上构建 M1 工作台。 // repo 生命周期须覆盖到事件循环结束(由调用方保证)。 void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo, - geopro::data::IProjectRepository& projectRepo, + geopro::data::IAsyncProjectRepository& projectRepo, geopro::controller::WorkbenchNavController& nav, geopro::controller::DatasetDetailController& detailCtrl) { diff --git a/src/data/README.md b/src/data/README.md index 4e706df..733dbb1 100644 --- a/src/data/README.md +++ b/src/data/README.md @@ -3,7 +3,7 @@ Repository 抽象(**异步契约**:QFuture/回调 + 取消 + 分页),DTO 与领域模型分离。 子目录(设计 §3、§6): -- `repo/` — IProjectRepository, IDatasetRepository +- `repo/` — IAsyncProjectRepository, IDatasetRepository - `local/` — LocalSampleRepository(M1,QtConcurrent 跑解析)+ 各格式解析器 - `api/` — ApiRepository(M1 骨架,签名对齐 pop-api) - `dto/` — 后端 JSON DTO + → model 映射 diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index 193863b..c822806 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -15,17 +15,9 @@ namespace geopro::data { namespace { constexpr int kCodeSuccess = 200; -bool ok(const net::ApiResponse& r) { return r.code == kCodeSuccess; } - -// 异步失败谓词(与同步 ok() 同口径:业务码非 200 或网络错误)。 +// 异步失败谓词:业务码非 200 或网络错误。 bool isFailureA(const net::ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); } -std::string errorOf(const net::ApiResponse& r, const char* fallback) { - if (!r.msg.isEmpty()) return r.msg.toStdString(); - if (!r.rawError.isEmpty()) return r.rawError.toStdString(); - return fallback; -} - // 后端 id 进 URL 前做百分号编码(不可信外部数据:防 ? # & / 空格 破坏路径/查询)。 QString enc(const std::string& s) { return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); @@ -34,97 +26,7 @@ QString enc(const std::string& s) { ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {} -RepoResult> ApiProjectRepository::listWorkspaces() { - const net::ApiResponse r = - api_.get(QStringLiteral("/business/system/tenant/enterprise/joined/list")); - if (!ok(r)) return {false, {}, errorOf(r, "listWorkspaces failed")}; - return {true, dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray()), {}}; -} - -RepoResult ApiProjectRepository::switchWorkspace(const std::string& tenantId) { - const QString path = - QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId)); - const net::ApiResponse r = api_.postJson(path, QJsonObject{}); - if (!ok(r)) return {false, false, errorOf(r, "switchWorkspace failed")}; - // 切换空间返回新 accessToken:必须重新注入,后续请求才落到新空间。 - const QString token = r.data.value(QStringLiteral("accessToken")).toString(); - if (!token.isEmpty()) api_.setToken(token); - return {true, true, {}}; -} - -RepoResult ApiProjectRepository::pageProjects(const std::string& nameFilter, - const std::string& typeId, int pageNo, - int pageSize) { - QJsonObject body{{QStringLiteral("projectName"), QString::fromStdString(nameFilter)}, - {QStringLiteral("pageNo"), pageNo}, - {QStringLiteral("pageSize"), pageSize}}; - if (!typeId.empty()) body[QStringLiteral("projectTypeId")] = QString::fromStdString(typeId); - const net::ApiResponse r = api_.postJson(QStringLiteral("/business/my/profile/project/page"), body); - if (!ok(r)) return {false, {}, errorOf(r, "pageProjects failed")}; - return {true, dto::parseProjectPage(r.data), {}}; -} - -RepoResult> ApiProjectRepository::listProjectTypes() { - const net::ApiResponse r = api_.get(QStringLiteral("/business/project/type/list")); - if (!ok(r)) return {false, {}, errorOf(r, "listProjectTypes failed")}; - return {true, dto::parseProjectTypes(r.data.value(QStringLiteral("value")).toArray()), {}}; -} - -RepoResult> ApiProjectRepository::loadStructure(const std::string& projectId) { - // 项目结构(项目根 + GS + TM;不含 DS)。比 projectWorkbench 干净。 - const QString path = - QStringLiteral("/business/projectStruct/queryProjectStruct/%1").arg(enc(projectId)); - const net::ApiResponse r = api_.get(path); - if (!ok(r)) return {false, {}, errorOf(r, "loadStructure failed")}; - return {true, dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()), {}}; -} - -RepoResult ApiProjectRepository::loadRows(const std::string& projectId, - const std::string& parentId, int parentConfType, - int classifyType, int pageNo) { - const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") - : QStringLiteral("/business/dsObject/data/page"); - const QJsonObject body{ - {QStringLiteral("projectId"), QString::fromStdString(projectId)}, - {QStringLiteral("structParentId"), QString::fromStdString(parentId)}, - {QStringLiteral("structParentConfType"), parentConfType}, - {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, - {QStringLiteral("pageNo"), pageNo}, - {QStringLiteral("pageSize"), 5}}; - const net::ApiResponse r = api_.postJson(path, body); - if (!ok(r)) return {false, {}, errorOf(r, "loadRows failed")}; - return {true, dto::parseDsPage(r.data), {}}; -} - -RepoResult ApiProjectRepository::loadObjectDetail(const std::string& objectId, - int confType) { - const QString path = - (confType == 1) - ? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId)) - : QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId)); - const net::ApiResponse r = api_.get(path); - if (!ok(r)) return {false, {}, errorOf(r, "loadObjectDetail failed")}; - return {true, dto::parseDynamicForm(r.data), {}}; -} - -RepoResult ApiProjectRepository::loadDatasetForm(const std::string& dsObjectId) { - const QString path = - QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId)); - const net::ApiResponse r = api_.get(path); - if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetForm failed")}; - return {true, dto::parseDynamicForm(r.data), {}}; -} - -RepoResult> ApiProjectRepository::loadExceptionsByTm( - const std::string& tmObjectId) { - const QString path = - QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId)); - const net::ApiResponse r = api_.get(path); - if (!ok(r)) return {false, {}, errorOf(r, "loadExceptionsByTm failed")}; - return {true, dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()), {}}; -} - -// ── 异步实现(薄封装:endpoint/body/parse 与同步版逐一对齐;解析器在 try 内见 ApiNavRequest)── +// ── 异步实现(薄封装:解析器在 try 内见 ApiNavRequest)── NavRequest* ApiProjectRepository::listWorkspacesAsync() { auto* call = api_.getAsync(QStringLiteral("/business/system/tenant/enterprise/joined/list")); diff --git a/src/data/api/ApiProjectRepository.hpp b/src/data/api/ApiProjectRepository.hpp index 1845e00..ed391e8 100644 --- a/src/data/api/ApiProjectRepository.hpp +++ b/src/data/api/ApiProjectRepository.hpp @@ -1,5 +1,4 @@ #pragma once -#include "repo/IProjectRepository.hpp" #include "repo/IAsyncProjectRepository.hpp" namespace geopro::net { class ApiClient; } @@ -8,26 +7,12 @@ namespace geopro::data { class NavRequest; -// 用共享会话 ApiClient 实现导航仓储。过渡期同时实现同步(旧)+异步(新)两接口。 -// token 由调用方注入 ApiClient。 -class ApiProjectRepository : public IProjectRepository, public IAsyncProjectRepository { +// 用共享会话 ApiClient 实现导航异步仓储。token 由调用方注入 ApiClient。 +class ApiProjectRepository : public IAsyncProjectRepository { public: explicit ApiProjectRepository(net::ApiClient& api); - // ── 同步(旧,A6 删除) ── - RepoResult> listWorkspaces() override; - RepoResult switchWorkspace(const std::string& tenantId) override; - RepoResult pageProjects(const std::string& nameFilter, const std::string& typeId, - int pageNo, int pageSize) override; - RepoResult> listProjectTypes() override; - RepoResult> loadStructure(const std::string& projectId) override; - RepoResult loadRows(const std::string& projectId, const std::string& parentId, - int parentConfType, int classifyType, int pageNo) override; - RepoResult loadObjectDetail(const std::string& objectId, int confType) override; - RepoResult loadDatasetForm(const std::string& dsObjectId) override; - RepoResult> loadExceptionsByTm(const std::string& tmObjectId) override; - - // ── 异步(新) ── 返回 NavRequest*(薄封装,一方法一请求)。 + // ── 异步 ── 返回 NavRequest*(薄封装,一方法一请求)。 NavRequest* listWorkspacesAsync() override; NavRequest* switchWorkspaceAsync(const std::string& tenantId) override; NavRequest* pageProjectsAsync(const std::string& nameFilter, const std::string& typeId, diff --git a/src/data/repo/IProjectRepository.hpp b/src/data/repo/IProjectRepository.hpp deleted file mode 100644 index 0c7c0af..0000000 --- a/src/data/repo/IProjectRepository.hpp +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once -#include -#include -#include "repo/RepoTypes.hpp" - -namespace geopro::data { - -// 仓储结果信封:网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态。 -template -struct RepoResult { - bool ok = false; - T value{}; - std::string error; -}; - -// 导航仓储抽象(同步;呼应既有 IDatasetRepository 风格)。 -class IProjectRepository { -public: - virtual ~IProjectRepository() = default; - virtual RepoResult> listWorkspaces() = 0; - virtual RepoResult switchWorkspace(const std::string& tenantId) = 0; - // 项目分页:nameFilter 名称模糊(可空)、typeId 类型过滤(空=不限)、pageNo 从 1 起。 - virtual RepoResult pageProjects(const std::string& nameFilter, - const std::string& typeId, int pageNo, - int pageSize) = 0; - // 项目类型列表(弹窗类型过滤下拉)。 - virtual RepoResult> listProjectTypes() = 0; - virtual RepoResult> loadStructure(const std::string& projectId) = 0; - // 按结构父节点分页拉数据/文件行:parentConfType 1=GS 2=TM;classifyType 3=数据 1=文件; - // pageNo 从 1 起,pageSize 固定 5。 - virtual RepoResult loadRows(const std::string& projectId, const std::string& parentId, - int parentConfType, int classifyType, int pageNo) = 0; - // 对象详情:confType 1=GS(getGsObjectDetail) 2=TM(tmObject/getDetail) → 动态表单。 - virtual RepoResult loadObjectDetail(const std::string& objectId, int confType) = 0; - // 数据集详情:dsObject/dynamicForm/{dsObjectId} → 动态表单。 - virtual RepoResult loadDatasetForm(const std::string& dsObjectId) = 0; - // 单 TM 异常列表(含异常体归属字段)。 - virtual RepoResult> loadExceptionsByTm(const std::string& tmObjectId) = 0; -}; - -} // namespace geopro::data diff --git a/src/net/ApiClient.cpp b/src/net/ApiClient.cpp index f9e9f93..8945278 100644 --- a/src/net/ApiClient.cpp +++ b/src/net/ApiClient.cpp @@ -1,9 +1,7 @@ #include "ApiClient.hpp" #include "ApiCall.hpp" -#include "ApiResponseParse.hpp" -#include #include #include #include @@ -34,14 +32,6 @@ struct ApiClient::Impl { } return req; } - - // 阻塞等待 reply 完成,解析为 ApiResponse。调用方负责 reply->deleteLater()。 - static ApiResponse await(QNetworkReply* reply) { - QEventLoop loop; - QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - loop.exec(); - return buildResponse(reply); - } }; ApiClient::ApiClient(QString baseUrl) : impl_(std::make_unique(std::move(baseUrl))) {} @@ -50,23 +40,6 @@ ApiClient::~ApiClient() = default; void ApiClient::setToken(const QString& token) { impl_->token = token; } -ApiResponse ApiClient::get(const QString& path) { - QNetworkRequest req = impl_->buildRequest(path); - QNetworkReply* reply = impl_->nam.get(req); - ApiResponse resp = Impl::await(reply); - reply->deleteLater(); - return resp; -} - -ApiResponse ApiClient::postJson(const QString& path, const QJsonObject& body) { - QNetworkRequest req = impl_->buildRequest(path); - const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact); - QNetworkReply* reply = impl_->nam.post(req, payload); - ApiResponse resp = Impl::await(reply); - reply->deleteLater(); - return resp; -} - IApiCall* ApiClient::getAsync(const QString& path) { QNetworkRequest req = impl_->buildRequest(path); QNetworkReply* reply = impl_->nam.get(req); diff --git a/src/net/ApiClient.hpp b/src/net/ApiClient.hpp index b3b65ec..64091cb 100644 --- a/src/net/ApiClient.hpp +++ b/src/net/ApiClient.hpp @@ -21,7 +21,7 @@ struct ApiResponse { QString rawError; }; -// QtNetwork 的同步 HTTP 封装。 +// QtNetwork 的异步 HTTP 封装。 // 内部持有【唯一一个】QNetworkAccessManager 成员,默认共享 cookie jar, // 因此同一 ApiClient 实例发出的多次请求处于同一会话(共享 JSESSIONID)。 // 这是登录流程 getImageCode -> verifyCodeCheck -> login2 串联的关键。 @@ -36,10 +36,6 @@ public: // 设置令牌;注入请求头 geomativeauthorization。token 值本身已含 "Geomative " 前缀。 void setToken(const QString& token); - // 同步 GET / POST(JSON)。用 QNetworkReply + QEventLoop 阻塞等待响应。 - ApiResponse get(const QString& path); - ApiResponse postJson(const QString& path, const QJsonObject& body); - // 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。 IApiCall* getAsync(const QString& path); IApiCall* postJsonAsync(const QString& path, const QJsonObject& body);