# ApiClient 异步化(DatasetDetail 路径)实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 把数据集详情路径(原数据 + 网格数据)从同步阻塞改为 Qt 原生异步,UI 全程不冻、慢请求可 abort、失败 fail-fast,作为全 App 异步化的模式样板。 **Architecture:** `ApiClient` 新增 `getAsync/postJsonAsync` 返回自管理 QObject 句柄 `ApiCall`(实现可测的 `IApiCall`);`ApiBatch` 并发汇聚多请求并 fail-fast;data 层 `ApiDatasetRepository` 用 batch 组装 `ChartLoad`/`GridLoad` 句柄(抽象基类 + Api 实现,供 stub 测试);`DatasetDetailController` 改 abort-and-replace + 句柄身份比对,对外信号面不变。核心安全靠「各层 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 deleteLater」杜绝迟到信号回灌。 **Tech Stack:** Qt6 (Core/Network/Test)、QNetworkAccessManager 原生异步、CMake + Ninja + MSVC、GoogleTest/CTest、QSignalSpy。 **权威设计:** `docs/superpowers/specs/2026-06-11-apiclient-async-design.md`(含 §5.0 安全不变量、§7 错误判定、§8 测试策略)。 **构建/测试命令:** - 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` - 若报 `LNK1104 ... geopro_desktop.exe`:先 `Get-Process geopro_desktop -ErrorAction SilentlyContinue | Stop-Process -Force` 再构建。 - 全量测试:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(基线 75/75 绿) - 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=ApiBatch.*` --- ## 文件结构 **新建:** - `src/net/IApiCall.hpp` — 抽象 QObject 缝:`finished(ApiResponse)` 信号 + 纯虚 `abort()`。 - `src/net/ApiCall.hpp` / `src/net/ApiCall.cpp` — 包一个 `QNetworkReply` 的 `IApiCall` 实现。 - `src/net/ApiResponseParse.hpp` / `src/net/ApiResponseParse.cpp` — `buildResponse(QNetworkReply*)`(从现 `Impl::await` 抽出解析部分,sync/async 共用,DRY)。 - `src/net/ApiBatch.hpp` / `src/net/ApiBatch.cpp` — 并发汇聚 + fail-fast 原语。 - `src/data/api/DatasetLoads.hpp` — `ChartParts` / `GridParts` 结果载体(纯结构体)。 - `src/data/api/DatasetLoadHandles.hpp` / `src/data/api/DatasetLoadHandles.cpp` — 抽象 `ChartLoad`/`GridLoad`(QObject 基类)+ 具体 `ApiChartLoad`/`ApiGridLoad`(包 ApiBatch + 解析)。 - `src/data/repo/IAsyncDatasetRepository.hpp` — 详情异步仓储抽象(返回 `ChartLoad*`/`GridLoad*`)。 - `tests/net/FakeApiCall.hpp` — 测试假 call(**不声明 Q_OBJECT**,发射继承的 `finished`、记录 `abort`)。 - `tests/net/test_api_batch.cpp` — ApiBatch 离线单测。 - `tests/data/test_dataset_load_handles.cpp` — ChartLoad/GridLoad 句柄离线单测。 - `src/app/panels/LoadingOverlay.hpp` / `src/app/panels/LoadingOverlay.cpp` — 轻量「加载中」遮罩(Task 8,可后置)。 **修改:** - `src/net/ApiClient.hpp` / `.cpp` — 加 `getAsync/postJsonAsync`;`await` 改用 `buildResponse`;`ApiResponse` 加 `Q_DECLARE_METATYPE`。 - `src/net/CMakeLists.txt` — AUTOMOC ON;加 ApiCall/ApiBatch/ApiResponseParse 源。 - `src/data/api/ApiDatasetRepository.hpp` / `.cpp` — 改实现 `IAsyncDatasetRepository`。 - `src/data/CMakeLists.txt` — AUTOMOC ON;加 DatasetLoadHandles 源。 - `src/controller/DatasetDetailController.hpp` / `.cpp` — 依赖 `IAsyncDatasetRepository`、abort-and-replace、`loadStarted` 信号。 - `tests/controller/test_dataset_detail_controller.cpp` — 改异步 stub + 回灌防护用例。 - `tests/CMakeLists.txt` — 加 test_api_batch / test_dataset_load_handles。 - `src/app/main.cpp` — `qRegisterMetaType()`;接 `loadStarted`(Task 8)。 - `src/app/CMakeLists.txt` — 加 LoadingOverlay(Task 8)。 **不动:** `src/data/repo/IDatasetRepository.hpp`(同步,留 LocalSampleRepository)、Nav/Auth/Project 路径、`src/net/AuthService.*`。 --- ## Task 1: net — 抽出 `buildResponse`(纯重构,行为不变) 把现 `ApiClient::Impl::await` 里「读状态 + 读体 + 解析」的部分抽成自由函数,供同步 `await` 与异步 `ApiCall` 复用(DRY)。 **Files:** - Create: `src/net/ApiResponseParse.hpp`, `src/net/ApiResponseParse.cpp` - Modify: `src/net/ApiClient.cpp`, `src/net/CMakeLists.txt` - [ ] **Step 1: 写 `ApiResponseParse.hpp`** ```cpp #pragma once #include "ApiClient.hpp" // for geopro::net::ApiResponse class QNetworkReply; namespace geopro::net { // 从一个【已完成】的 reply 读取状态码/响应体/错误并解析为 ApiResponse。 // 不等待、不删除 reply(调用方负责生命周期)。供同步 await 与异步 ApiCall 共用。 ApiResponse buildResponse(QNetworkReply* reply); } // namespace geopro::net ``` - [ ] **Step 2: 写 `ApiResponseParse.cpp`**(把现 ApiClient.cpp 的 parseBody + await 后半段搬来) ```cpp #include "ApiResponseParse.hpp" #include #include #include #include #include #include namespace geopro::net { namespace { constexpr int kHttpStatusUnset = 0; void parseBody(const QByteArray& body, ApiResponse& resp) { if (body.isEmpty()) { resp.rawError = QStringLiteral("empty response body"); return; } QJsonParseError perr{}; const QJsonDocument doc = QJsonDocument::fromJson(body, &perr); if (perr.error != QJsonParseError::NoError || !doc.isObject()) { resp.rawError = QStringLiteral("JSON parse error: %1; body: %2") .arg(perr.errorString(), QString::fromUtf8(body)); return; } const QJsonObject obj = doc.object(); resp.code = obj.value(QStringLiteral("code")).toInt(); resp.msg = obj.value(QStringLiteral("msg")).toString(); const QJsonValue dataVal = obj.value(QStringLiteral("data")); if (dataVal.isObject()) { resp.data = dataVal.toObject(); } else { resp.data = QJsonObject{{QStringLiteral("value"), dataVal}}; } } } // namespace ApiResponse buildResponse(QNetworkReply* reply) { ApiResponse resp; const QVariant statusVar = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); resp.httpStatus = statusVar.isValid() ? statusVar.toInt() : kHttpStatusUnset; const QByteArray body = reply->readAll(); if (reply->error() != QNetworkReply::NoError) { resp.rawError = reply->errorString(); } if (!body.isEmpty()) { parseBody(body, resp); } return resp; } } // namespace geopro::net ``` - [ ] **Step 3: 改 `ApiClient.cpp` 用 buildResponse** 删除 ApiClient.cpp 顶部 `parseBody` 匿名函数(已搬到 ApiResponseParse.cpp)与 `kHttpStatusUnset`、相关未用 include(`QJsonDocument/QJsonParseError/QJsonValue`)。在文件顶部 include 改为加 `#include "ApiResponseParse.hpp"`。把 `Impl::await` 改为: ```cpp static ApiResponse await(QNetworkReply* reply) { QEventLoop loop; QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); loop.exec(); return buildResponse(reply); } ``` 保留 `kContentTypeJson` / `kTokenHeader` / `buildRequest` 不动。 - [ ] **Step 4: 注册到 CMake** `src/net/CMakeLists.txt` 的 `add_library(geopro_net STATIC ...)` 列表加一行 `ApiResponseParse.cpp`: ```cmake add_library(geopro_net STATIC crypto/RsaEncryptor.cpp ApiClient.cpp ApiResponseParse.cpp AuthService.cpp) ``` - [ ] **Step 5: 构建 + 跑现有测试(回归安全网)** Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` Expected: 75/75 PASS(`AuthLiveTest.FullLoginFlowReturnsToken` 走 sync 路径会经过 buildResponse,验证重构无回归)。 - [ ] **Step 6: Commit** ```bash git add src/net/ApiResponseParse.hpp src/net/ApiResponseParse.cpp src/net/ApiClient.cpp src/net/CMakeLists.txt git commit -m "refactor(net): 抽出 buildResponse,sync/async 共用响应解析(DRY,行为不变)" ``` --- ## Task 2: net — `IApiCall` + `ApiCall` + AUTOMOC + getAsync/postJsonAsync **Files:** - Create: `src/net/IApiCall.hpp`, `src/net/ApiCall.hpp`, `src/net/ApiCall.cpp` - Modify: `src/net/ApiClient.hpp`, `src/net/ApiClient.cpp`, `src/net/CMakeLists.txt` - [ ] **Step 1: 写 `IApiCall.hpp`** ```cpp #pragma once #include #include "ApiClient.hpp" // geopro::net::ApiResponse namespace geopro::net { // 单个异步请求的抽象句柄(可测试缝)。ApiBatch 仅依赖此抽象,单测可注入假 call。 class IApiCall : public QObject { Q_OBJECT public: using QObject::QObject; ~IApiCall() override = default; virtual void abort() = 0; // 置 aborted_、中断、deleteLater;abort 后绝不 emit finished signals: void finished(const geopro::net::ApiResponse& resp); // 成功/错误均经此(错误写 rawError) }; } // namespace geopro::net ``` - [ ] **Step 2: 写 `ApiCall.hpp`** ```cpp #pragma once #include #include "IApiCall.hpp" class QNetworkReply; namespace geopro::net { // 包一个 QNetworkReply 的 IApiCall 实现。reply 完成 → buildResponse → emit finished → 自删。 // 安全不变量(spec §5.0):abort 后绝不 emit;销毁一律 deleteLater,禁止同步 delete。 class ApiCall : public IApiCall { Q_OBJECT public: explicit ApiCall(QNetworkReply* reply, QObject* parent = nullptr); // 接管 reply void abort() override; private: void onFinished(); QPointer reply_; bool aborted_ = false; }; } // namespace geopro::net ``` - [ ] **Step 3: 写 `ApiCall.cpp`** ```cpp #include "ApiCall.hpp" #include #include "ApiResponseParse.hpp" namespace geopro::net { ApiCall::ApiCall(QNetworkReply* reply, QObject* parent) : IApiCall(parent), reply_(reply) { QObject::connect(reply_, &QNetworkReply::finished, this, &ApiCall::onFinished); } void ApiCall::onFinished() { if (aborted_) return; // §5.0 入口守卫:迟到信号闸门 ApiResponse resp = buildResponse(reply_); if (reply_) reply_->deleteLater(); emit finished(resp); deleteLater(); } void ApiCall::abort() { if (aborted_) return; aborted_ = true; // 先置标志(权威闸门) if (reply_) { QObject::disconnect(reply_, nullptr, this, nullptr); // 尽力而为:断开未派发连接 reply_->abort(); // 会再触发一次 finished,已被 disconnect+aborted_ 双挡 reply_->deleteLater(); } deleteLater(); // 禁止同步 delete } } // namespace geopro::net ``` - [ ] **Step 4: 改 `ApiClient.hpp`** — 加异步方法 + 元类型声明 在 `ApiResponse` 结构体定义之后(`};` 之下、`class ApiClient` 之上)插入: ```cpp } // namespace geopro::net ←★ 不要重复;见下方完整片段 ``` 具体改法:①在 `class ApiClient` 内 `postJson` 声明后加两行;②文件末尾 `} // namespace geopro::net` 之后加 `Q_DECLARE_METATYPE`。 `class ApiClient` 内新增: ```cpp // 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。 net::IApiCall* getAsync(const QString& path); net::IApiCall* postJsonAsync(const QString& path, const QJsonObject& body); ``` (注意:此处在 `namespace geopro::net` 内,写 `IApiCall*` 即可,不必加 `net::` 前缀——按现文件风格用 `IApiCall*`。) 前置声明区加 `class IApiCall;`(与现有 `class QNetworkAccessManager;` 并列)。 文件最末(`} // namespace geopro::net` 之后)加: ```cpp #include Q_DECLARE_METATYPE(geopro::net::ApiResponse) ``` - [ ] **Step 5: 改 `ApiClient.cpp`** — 实现异步方法 顶部加 `#include "ApiCall.hpp"`。在 `postJson` 实现之后加: ```cpp IApiCall* ApiClient::getAsync(const QString& path) { QNetworkRequest req = impl_->buildRequest(path); QNetworkReply* reply = impl_->nam.get(req); return new ApiCall(reply); } IApiCall* ApiClient::postJsonAsync(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); return new ApiCall(reply); } ``` (`QJsonDocument` 已被 ApiClient.cpp 使用,保留其 include。) - [ ] **Step 6: 改 `src/net/CMakeLists.txt`** — AUTOMOC ON + 加源 ```cmake add_library(geopro_net STATIC crypto/RsaEncryptor.cpp ApiClient.cpp ApiResponseParse.cpp ApiCall.cpp AuthService.cpp) ``` 并把末行改为: ```cmake set_target_properties(geopro_net PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF) ``` (AUTOMOC ON 仅对含 Q_OBJECT 的新文件生效,现有文件无 Q_OBJECT,无副作用。`IApiCall.hpp`/`ApiCall.hpp` 经 `ApiCall.cpp` 的 `#include` 被 AUTOMOC 扫描到。) - [ ] **Step 7: 构建(无新测试,验证编译/链接/moc)** Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` Expected: 链接通过(moc 为 IApiCall/ApiCall 生成成功)。 > ApiCall 直接包 QNetworkReply,离线无法稳定单测,由 Task 3(ApiBatch + FakeApiCall)与 Task 6(控制器集成)+ Task 8 手动验证覆盖。 - [ ] **Step 8: Commit** ```bash git add src/net/IApiCall.hpp src/net/ApiCall.hpp src/net/ApiCall.cpp src/net/ApiClient.hpp src/net/ApiClient.cpp src/net/CMakeLists.txt git commit -m "feat(net): ApiClient.getAsync/postJsonAsync + IApiCall/ApiCall 异步句柄(abort+aborted_ 闸门,AUTOMOC ON)" ``` --- ## Task 3: net — `ApiBatch`(fail-fast 汇聚)+ 离线单测(TDD) **Files:** - Create: `src/net/ApiBatch.hpp`, `src/net/ApiBatch.cpp`, `tests/net/FakeApiCall.hpp`, `tests/net/test_api_batch.cpp` - Modify: `src/net/CMakeLists.txt`, `tests/CMakeLists.txt` - [ ] **Step 1: 写 `tests/net/FakeApiCall.hpp`**(不声明 Q_OBJECT:发射继承自 IApiCall 的 finished) ```cpp #pragma once #include "IApiCall.hpp" namespace geopro::net::test { // 测试假 call。不加 Q_OBJECT —— 仅发射继承自 IApiCall 的 finished 信号、override abort。 class FakeApiCall : public IApiCall { public: using IApiCall::IApiCall; bool aborted = false; void abort() override { aborted = true; } void fire(const ApiResponse& r) { emit finished(r); } // 发射继承的信号(无需自身 moc) }; } // namespace geopro::net::test ``` - [ ] **Step 2: 写失败测试 `tests/net/test_api_batch.cpp`** ```cpp #include #include #include "ApiBatch.hpp" #include "net/FakeApiCall.hpp" using namespace geopro::net; using geopro::net::test::FakeApiCall; namespace { ApiResponse ok(int code = 200) { ApiResponse r; r.code = code; r.httpStatus = 200; return r; } ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; } // repo 注入的失败谓词:业务码 != 200 或传输错误。 auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; } TEST(ApiBatch, SucceedsWhenAllOk) { auto* a = new FakeApiCall; auto* b = new FakeApiCall; auto* batch = new ApiBatch({a, b}, isFailure); QSignalSpy okSpy(batch, &ApiBatch::succeeded); QSignalSpy failSpy(batch, &ApiBatch::failed); a->fire(ok()); EXPECT_EQ(okSpy.count(), 0); // 还差 b b->fire(ok()); EXPECT_EQ(okSpy.count(), 1); // 全到齐 EXPECT_EQ(failSpy.count(), 0); } TEST(ApiBatch, FailFastAbortsOthers) { auto* a = new FakeApiCall; auto* b = new FakeApiCall; // 慢的(永不 fire) auto* batch = new ApiBatch({a, b}, isFailure); QSignalSpy failSpy(batch, &ApiBatch::failed); QSignalSpy okSpy(batch, &ApiBatch::succeeded); a->fire(bad()); // 首个失败 EXPECT_EQ(failSpy.count(), 1); EXPECT_EQ(okSpy.count(), 0); EXPECT_TRUE(b->aborted); // 其余在飞被 abort } TEST(ApiBatch, AbortGateSuppressesLateSignals) { auto* a = new FakeApiCall; auto* b = new FakeApiCall; auto* batch = new ApiBatch({a, b}, isFailure); QSignalSpy okSpy(batch, &ApiBatch::succeeded); QSignalSpy failSpy(batch, &ApiBatch::failed); batch->abort(); EXPECT_TRUE(a->aborted); EXPECT_TRUE(b->aborted); a->fire(ok()); // 迟到信号 b->fire(ok()); EXPECT_EQ(okSpy.count(), 0); // aborted_ 闸门:不 emit EXPECT_EQ(failSpy.count(), 0); } ``` - [ ] **Step 3: 注册测试到 CMake** `tests/CMakeLists.txt`,在 net 段(`target_sources(geopro_tests PRIVATE net/test_auth.cpp)` 之后)加: ```cmake target_sources(geopro_tests PRIVATE net/test_api_batch.cpp) ``` 并确保 net 段链接含 `Qt6::Test`(用于 QSignalSpy)。把第 50 行链接行改为: ```cmake target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test) ``` (`find_package(Qt6 COMPONENTS Test REQUIRED)` 在 controller 段已有;若 net 段在其之前编译报错,把该 find_package 上移到文件 net 段之前。) `tests/net/` 头由 `target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests)` 提供——若不存在则加: ```cmake target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests) ``` - [ ] **Step 4: 跑测试确认失败** Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` Expected: 编译失败(`ApiBatch.hpp` 不存在)。 - [ ] **Step 5: 写 `src/net/ApiBatch.hpp`** ```cpp #pragma once #include #include #include #include #include "IApiCall.hpp" namespace geopro::net { // 并发汇聚 N 个 IApiCall:全成功 → succeeded(按下标对齐);任一失败 → fail-fast: // failed(index,resp) + abort 其余在飞 call。安全不变量见 spec §5.0。 class ApiBatch : public QObject { Q_OBJECT public: using Predicate = std::function; ApiBatch(QList calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls void abort(); signals: void succeeded(const QList& responses); void failed(int index, const geopro::net::ApiResponse& resp); private: QList> calls_; QList responses_; Predicate isFailure_; int remaining_ = 0; bool aborted_ = false; }; } // namespace geopro::net ``` - [ ] **Step 6: 写 `src/net/ApiBatch.cpp`** ```cpp #include "ApiBatch.hpp" namespace geopro::net { ApiBatch::ApiBatch(QList calls, Predicate isFailure, QObject* parent) : QObject(parent), isFailure_(std::move(isFailure)) { responses_.resize(calls.size()); remaining_ = static_cast(calls.size()); for (int i = 0; i < calls.size(); ++i) { IApiCall* c = calls[i]; calls_.append(c); QObject::connect(c, &IApiCall::finished, this, [this, i](const ApiResponse& resp) { if (aborted_) return; // §5.0 入口守卫 if (isFailure_(resp)) { aborted_ = true; for (const auto& other : calls_) { // fail-fast:abort 其余在飞 if (other) other->abort(); } emit failed(i, resp); deleteLater(); return; } responses_[i] = resp; if (--remaining_ == 0) { emit succeeded(responses_); deleteLater(); } }); } } void ApiBatch::abort() { if (aborted_) return; aborted_ = true; for (const auto& c : calls_) { if (c) c->abort(); } deleteLater(); } } // namespace geopro::net ``` - [ ] **Step 7: 加 ApiBatch.cpp 到 net 库** `src/net/CMakeLists.txt` 的源列表加 `ApiBatch.cpp`: ```cmake add_library(geopro_net STATIC crypto/RsaEncryptor.cpp ApiClient.cpp ApiResponseParse.cpp ApiCall.cpp ApiBatch.cpp AuthService.cpp) ``` - [ ] **Step 8: 跑测试确认通过** Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` Expected: `ApiBatch.SucceedsWhenAllOk` / `FailFastAbortsOthers` / `AbortGateSuppressesLateSignals` PASS;总数 78/78。 - [ ] **Step 9: Commit** ```bash git add src/net/ApiBatch.hpp src/net/ApiBatch.cpp src/net/CMakeLists.txt tests/net/FakeApiCall.hpp tests/net/test_api_batch.cpp tests/CMakeLists.txt git commit -m "feat(net): ApiBatch 并发汇聚+fail-fast+abort闸门 + 离线单测" ``` --- ## Task 4: data — 结果载体 + 异步接口 + 句柄(抽象基 + Api 实现)+ 句柄单测(TDD) **Files:** - Create: `src/data/api/DatasetLoads.hpp`, `src/data/repo/IAsyncDatasetRepository.hpp`, `src/data/api/DatasetLoadHandles.hpp`, `src/data/api/DatasetLoadHandles.cpp`, `tests/data/test_dataset_load_handles.cpp` - Modify: `src/data/CMakeLists.txt`, `tests/CMakeLists.txt` - [ ] **Step 1: 写 `src/data/api/DatasetLoads.hpp`**(纯结构体) ```cpp #pragma once #include #include "model/Field.hpp" #include "model/ColorScale.hpp" #include "model/Anomaly.hpp" namespace geopro::data { // 原数据加载结果:scatter + 散点色阶(type1)。 struct ChartParts { geopro::core::ScatterField scatter; geopro::core::ColorScale scatterScale; }; // 网格数据加载结果:grid(rows) + 网格色阶(type2) + 异常。 struct GridParts { geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位 geopro::core::ColorScale gridScale; std::vector anomalies; }; } // namespace geopro::data ``` - [ ] **Step 2: 写 `src/data/api/DatasetLoadHandles.hpp`**(抽象基 + Api 实现) ```cpp #pragma once #include #include #include #include #include #include "ApiBatch.hpp" #include "DatasetLoads.hpp" namespace geopro::data { // ── 抽象句柄(可测试缝,类比 IApiCall):仓储返回基类指针,控制器/测试只依赖它 ── class ChartLoad : public QObject { Q_OBJECT public: using QObject::QObject; ~ChartLoad() override = default; virtual void abort() = 0; signals: void done(const geopro::data::ChartParts& parts); void failed(const QString& message); }; class GridLoad : public QObject { Q_OBJECT public: using QObject::QObject; ~GridLoad() override = default; virtual void abort() = 0; signals: void done(const geopro::data::GridParts& parts); void failed(const QString& message); }; // ── Api 实现:包一个 ApiBatch + 注入的解析函数。batch.succeeded→解析→done;failed→failed ── class ApiChartLoad : public ChartLoad { Q_OBJECT public: using Parser = std::function&)>; ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); void abort() override; private: QPointer batch_; Parser parse_; bool aborted_ = false; }; class ApiGridLoad : public GridLoad { Q_OBJECT public: using Parser = std::function&)>; ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr); void abort() override; private: QPointer batch_; Parser parse_; bool aborted_ = false; }; } // namespace geopro::data ``` - [ ] **Step 3: 写 `src/data/api/DatasetLoadHandles.cpp`** ```cpp #include "api/DatasetLoadHandles.hpp" #include namespace geopro::data { namespace { QString reasonOf(const geopro::net::ApiResponse& r) { return r.msg.isEmpty() ? r.rawError : r.msg; } } // namespace ApiChartLoad::ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) : ChartLoad(parent), batch_(batch), parse_(std::move(parse)) { QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, [this](const QList& resps) { if (aborted_) return; // §5.0 try { emit done(parse_(resps)); } catch (const std::exception& e) { emit failed(QString::fromUtf8(e.what())); } deleteLater(); }); QObject::connect(batch, &geopro::net::ApiBatch::failed, this, [this](int, const geopro::net::ApiResponse& r) { if (aborted_) return; emit failed(reasonOf(r)); deleteLater(); }); } void ApiChartLoad::abort() { if (aborted_) return; aborted_ = true; if (batch_) batch_->abort(); deleteLater(); } ApiGridLoad::ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent) : GridLoad(parent), batch_(batch), parse_(std::move(parse)) { QObject::connect(batch, &geopro::net::ApiBatch::succeeded, this, [this](const QList& resps) { if (aborted_) return; try { emit done(parse_(resps)); } catch (const std::exception& e) { emit failed(QString::fromUtf8(e.what())); } deleteLater(); }); QObject::connect(batch, &geopro::net::ApiBatch::failed, this, [this](int, const geopro::net::ApiResponse& r) { if (aborted_) return; emit failed(reasonOf(r)); deleteLater(); }); } void ApiGridLoad::abort() { if (aborted_) return; aborted_ = true; if (batch_) batch_->abort(); deleteLater(); } } // namespace geopro::data ``` - [ ] **Step 4: 写 `src/data/repo/IAsyncDatasetRepository.hpp`** ```cpp #pragma once #include namespace geopro::data { class ChartLoad; class GridLoad; // 数据集详情异步仓储抽象。返回自管理句柄(完成/失败后 deleteLater)。 class IAsyncDatasetRepository { public: virtual ~IAsyncDatasetRepository() = default; virtual ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + 散点色阶(type1) virtual GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + 色阶(type2) + 异常 }; } // namespace geopro::data ``` - [ ] **Step 5: 写失败测试 `tests/data/test_dataset_load_handles.cpp`**(用真 ApiBatch + FakeApiCall + 桩解析,验证句柄 done/failed/abort 接线) ```cpp #include #include #include "api/DatasetLoadHandles.hpp" #include "net/FakeApiCall.hpp" using namespace geopro::data; using geopro::net::ApiBatch; using geopro::net::ApiResponse; using geopro::net::test::FakeApiCall; namespace { ApiResponse ok() { ApiResponse r; r.code = 200; r.httpStatus = 200; return r; } ApiResponse bad() { ApiResponse r; r.code = 500; r.httpStatus = 200; r.msg = QStringLiteral("boom"); return r; } auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); }; } TEST(DatasetLoadHandles, ChartLoadEmitsDoneOnSuccess) { auto* a = new FakeApiCall; auto* b = new FakeApiCall; auto* batch = new ApiBatch({a, b}, isFailure); bool parsed = false; auto* load = new ApiChartLoad(batch, [&](const QList& resps) { parsed = (resps.size() == 2); return ChartParts{}; }); QSignalSpy doneSpy(load, &ChartLoad::done); a->fire(ok()); b->fire(ok()); EXPECT_EQ(doneSpy.count(), 1); EXPECT_TRUE(parsed); } TEST(DatasetLoadHandles, ChartLoadEmitsFailedOnBatchFailure) { auto* a = new FakeApiCall; auto* b = new FakeApiCall; auto* batch = new ApiBatch({a, b}, isFailure); auto* load = new ApiChartLoad(batch, [](const QList&) { return ChartParts{}; }); QSignalSpy failSpy(load, &ChartLoad::failed); a->fire(bad()); EXPECT_EQ(failSpy.count(), 1); } TEST(DatasetLoadHandles, GridLoadAbortSuppressesLateDone) { auto* a = new FakeApiCall; auto* batch = new ApiBatch({a}, isFailure); auto* load = new ApiGridLoad(batch, [](const QList&) { return GridParts{}; }); QSignalSpy doneSpy(load, &GridLoad::done); load->abort(); a->fire(ok()); // 迟到 EXPECT_EQ(doneSpy.count(), 0); // batch.aborted_ + load.aborted_ 双闸门 } ``` - [ ] **Step 6: 注册 data 源 + 测试 + AUTOMOC** `src/data/CMakeLists.txt`:源列表加 `api/DatasetLoadHandles.cpp`,末行 AUTOMOC 改 ON: ```cmake add_library(geopro_data STATIC parse/SampleParsers.cpp repo/LocalSampleRepository.cpp dto/NavDto.cpp dto/DatasetChartDto.cpp api/ApiProjectRepository.cpp api/ApiDatasetRepository.cpp api/DatasetLoadHandles.cpp) ... set_target_properties(geopro_data PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF) ``` `tests/CMakeLists.txt` data 段(`target_sources(... data/test_dataset_chart_dto.cpp)` 之后)加: ```cmake target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp) ``` (data 测试段已 `target_link_libraries(... geopro_data)`;`geopro_data` PUBLIC 链 `geopro_net`,故 ApiBatch 头/符号可见。QSignalSpy 需 `Qt6::Test`——Task 3 已加到链接行。) - [ ] **Step 7: 跑测试确认通过** Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` Expected: 3 个 `DatasetLoadHandles.*` PASS;总 81/81。 - [ ] **Step 8: Commit** ```bash git add src/data/api/DatasetLoads.hpp src/data/api/DatasetLoadHandles.hpp src/data/api/DatasetLoadHandles.cpp src/data/repo/IAsyncDatasetRepository.hpp src/data/CMakeLists.txt tests/data/test_dataset_load_handles.cpp tests/CMakeLists.txt git commit -m "feat(data): 异步仓储接口 + ChartLoad/GridLoad 句柄(抽象基+Api实现) + 离线单测" ``` --- ## Task 5: data — `ApiDatasetRepository` 改实现 `IAsyncDatasetRepository` **Files:** - Modify: `src/data/api/ApiDatasetRepository.hpp`, `src/data/api/ApiDatasetRepository.cpp` - [ ] **Step 1: 改 `ApiDatasetRepository.hpp`** ```cpp #pragma once #include "repo/IAsyncDatasetRepository.hpp" namespace geopro::net { class ApiClient; } namespace geopro::data { // 真实 API 实现 IAsyncDatasetRepository(ERT 反演)。每次加载返回自管理句柄。 class ApiDatasetRepository : public IAsyncDatasetRepository { public: explicit ApiDatasetRepository(net::ApiClient& api); ChartLoad* loadChartAsync(const std::string& dsId) override; GridLoad* loadGridAsync(const std::string& dsId) override; private: net::ApiClient& api_; }; } // namespace geopro::data ``` - [ ] **Step 2: 改 `ApiDatasetRepository.cpp`**(保留 `enc`;`must` 改为复用 `isFailure` 谓词;用 getAsync/postJsonAsync + ApiBatch + ApiChartLoad/ApiGridLoad) ```cpp #include "api/ApiDatasetRepository.hpp" #include #include #include #include #include "ApiClient.hpp" #include "ApiBatch.hpp" #include "api/DatasetLoadHandles.hpp" #include "dto/DatasetChartDto.hpp" namespace geopro::data { namespace { QString enc(const std::string& s) { return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s))); } // 失败判定(原 must() 口径):业务码 != 200 或传输错误。 bool isFailure(const geopro::net::ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); } QJsonObject colorBody(const std::string& dsId, int type) { return QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}}; } } // namespace ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {} ChartLoad* ApiDatasetRepository::loadChartAsync(const std::string& dsId) { // index 0 = scatter(GET),index 1 = 散点色阶 type1(POST) QList calls{ api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))), api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 1)), }; auto* batch = new net::ApiBatch(calls, &isFailure); return new ApiChartLoad(batch, [](const QList& r) { ChartParts p; p.scatter = dto::parseScatterGraph(r[0].data); p.scatterScale = dto::parseColorBar(r[1].data); return p; }); } GridLoad* ApiDatasetRepository::loadGridAsync(const std::string& dsId) { // index 0 = rows(GET,慢),1 = 色阶 type2(POST),2 = 异常(GET) QList calls{ api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))), api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)), api_.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))), }; auto* batch = new net::ApiBatch(calls, &isFailure); return new ApiGridLoad(batch, [](const QList& r) { GridParts p; p.grid = dto::parseInversionGrid(r[0].data); p.gridScale = dto::parseColorBar(r[1].data); p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray()); return p; }); } } // namespace geopro::data ``` > 解析函数名/用法与原同步实现逐一对齐:`parseScatterGraph`/`parseColorBar`/`parseInversionGrid`/`parseDatasetAnomalies`(见原 ApiDatasetRepository.cpp)。`GridParts.grid` 由 `parseInversionGrid` 返回值覆盖(赋值即可,Grid 无默认构造但成员赋值用拷贝赋值合法)。 - [ ] **Step 3: 构建(真实 API 方法离线不可单测,验证编译/链接)** Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` Expected: 链接通过。 > 真实端点行为由 Task 8 手动验证覆盖(与现有 ApiProjectRepository 同:无离线单测)。 - [ ] **Step 4: Commit** ```bash git add src/data/api/ApiDatasetRepository.hpp src/data/api/ApiDatasetRepository.cpp git commit -m "feat(data): ApiDatasetRepository 改异步(loadChartAsync/loadGridAsync, ApiBatch+句柄, code==200判定)" ``` --- ## Task 6: controller — `DatasetDetailController` 异步化(abort-and-replace + 身份比对)+ 改测试(TDD) **Files:** - Modify: `src/controller/DatasetDetailController.hpp`, `src/controller/DatasetDetailController.cpp`, `tests/controller/test_dataset_detail_controller.cpp` - [ ] **Step 1: 改 `DatasetDetailController.hpp`** ```cpp #pragma once #include #include #include #include #include "model/Field.hpp" #include "model/ColorScale.hpp" #include "model/Anomaly.hpp" namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; } namespace geopro::controller { class DatasetDetailController : public QObject { Q_OBJECT public: enum class LoadPhase { Chart, Grid }; Q_ENUM(LoadPhase) struct ChartData { QString dsId, ddCode; geopro::core::ScatterField scatter; geopro::core::ColorScale scatterScale; geopro::core::Grid grid{1, 1}; geopro::core::ColorScale gridScale; std::vector anomalies; }; struct GridData { QString dsId; geopro::core::Grid grid{1, 1}; geopro::core::ColorScale gridScale; std::vector anomalies; }; explicit DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent = nullptr); public slots: void openDataset(const QString& dsId, const QString& ddCode); void focusDataset(const QString& dsId); void loadGridData(const QString& dsId, const QString& ddCode); signals: void loadStarted(const QString& dsId, LoadPhase phase); void chartReady(const ChartData& data); void gridReady(const GridData& data); void focusRequested(const QString& dsId); void loadFailed(const QString& dsId, const QString& message); private: data::IAsyncDatasetRepository& repo_; QPointer chartLoad_; QPointer gridLoad_; }; } // namespace geopro::controller ``` - [ ] **Step 2: 改 `DatasetDetailController.cpp`** ```cpp #include "DatasetDetailController.hpp" #include "repo/IAsyncDatasetRepository.hpp" #include "api/DatasetLoadHandles.hpp" namespace geopro::controller { DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent) : QObject(parent), repo_(repo) {} void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { if (ddCode != QLatin1String("dd_inversion_data")) { emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); return; } if (chartLoad_) chartLoad_->abort(); // abort-and-replace data::ChartLoad* load = repo_.loadChartAsync(dsId.toStdString()); chartLoad_ = load; emit loadStarted(dsId, LoadPhase::Chart); QObject::connect(load, &data::ChartLoad::done, this, [this, load, dsId, ddCode](const data::ChartParts& parts) { if (load != chartLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 chartLoad_.clear(); ChartData d; d.dsId = dsId; d.ddCode = ddCode; d.scatter = parts.scatter; d.scatterScale = parts.scatterScale; emit chartReady(d); }); QObject::connect(load, &data::ChartLoad::failed, this, [this, load, dsId](const QString& msg) { if (load != chartLoad_) return; chartLoad_.clear(); emit loadFailed(dsId, msg); }); } void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { if (ddCode != QLatin1String("dd_inversion_data")) return; if (gridLoad_) gridLoad_->abort(); data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString()); gridLoad_ = load; emit loadStarted(dsId, LoadPhase::Grid); QObject::connect(load, &data::GridLoad::done, this, [this, load, dsId](const data::GridParts& parts) { if (load != gridLoad_) return; gridLoad_.clear(); GridData d; d.dsId = dsId; d.grid = parts.grid; d.gridScale = parts.gridScale; d.anomalies = parts.anomalies; emit gridReady(d); }); QObject::connect(load, &data::GridLoad::failed, this, [this, load, dsId](const QString& msg) { if (load != gridLoad_) return; gridLoad_.clear(); emit loadFailed(dsId, msg); }); } void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); } } // namespace geopro::controller ``` > 析构:`chartLoad_`/`gridLoad_` 是 `QPointer`,控制器析构时若句柄仍在飞,句柄自身在完成/abort 后 deleteLater;为稳妥可在析构中 abort(可选,Qt 退出会清理)。本期不显式写析构(栈析构逆序 ctrl→repo→api 已保证安全,见 spec §7)。 - [ ] **Step 3: 改测试 `tests/controller/test_dataset_detail_controller.cpp`**(异步 stub + 回灌防护) ```cpp #include #include #include "DatasetDetailController.hpp" #include "repo/IAsyncDatasetRepository.hpp" #include "api/DatasetLoadHandles.hpp" using namespace geopro; namespace { // 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。 struct StubChartLoad : data::ChartLoad { bool aborted = false; void abort() override { aborted = true; } void fireDone() { emit done(data::ChartParts{}); } void fireFailed() { emit failed(QStringLiteral("x")); } }; struct StubGridLoad : data::GridLoad { bool aborted = false; void abort() override { aborted = true; } void fireDone() { emit done(data::GridParts{}); } }; struct StubAsyncRepo : data::IAsyncDatasetRepository { StubChartLoad* lastChart = nullptr; StubGridLoad* lastGrid = nullptr; data::ChartLoad* loadChartAsync(const std::string&) override { lastChart = new StubChartLoad; return lastChart; } data::GridLoad* loadGridAsync(const std::string&) override { lastGrid = new StubGridLoad; return lastGrid; } }; } TEST(DatasetDetailController, EmitsChartReadyOnDone) { StubAsyncRepo repo; controller::DatasetDetailController c(repo); QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); c.openDataset("ds1", "dd_inversion_data"); repo.lastChart->fireDone(); EXPECT_EQ(spy.count(), 1); } TEST(DatasetDetailController, EmitsLoadFailedOnFailed) { StubAsyncRepo repo; controller::DatasetDetailController c(repo); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_inversion_data"); repo.lastChart->fireFailed(); EXPECT_EQ(spy.count(), 1); } TEST(DatasetDetailController, UnsupportedTypeFailsImmediately) { StubAsyncRepo repo; controller::DatasetDetailController c(repo); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); c.openDataset("ds1", "dd_other"); EXPECT_EQ(spy.count(), 1); EXPECT_EQ(repo.lastChart, nullptr); // 未发起加载 } TEST(DatasetDetailController, AbortsPreviousOnReopen) { StubAsyncRepo repo; controller::DatasetDetailController c(repo); c.openDataset("dsA", "dd_inversion_data"); StubChartLoad* a = repo.lastChart; c.openDataset("dsB", "dd_inversion_data"); // 替换 EXPECT_TRUE(a->aborted); // 旧句柄被 abort } TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) { StubAsyncRepo repo; controller::DatasetDetailController c(repo); QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); c.openDataset("dsA", "dd_inversion_data"); StubChartLoad* a = repo.lastChart; c.openDataset("dsB", "dd_inversion_data"); StubChartLoad* b = repo.lastChart; a->fireDone(); // 旧句柄迟到 → 身份比对丢弃 EXPECT_EQ(spy.count(), 0); b->fireDone(); // 当前句柄 → 正常 EXPECT_EQ(spy.count(), 1); } ``` - [ ] **Step 4: 跑测试确认通过** Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` Expected: 5 个 `DatasetDetailController.*` PASS(替换原 2 个);总 84/84。 - [ ] **Step 5: Commit** ```bash git add src/controller/DatasetDetailController.hpp src/controller/DatasetDetailController.cpp tests/controller/test_dataset_detail_controller.cpp git commit -m "feat(controller): DatasetDetailController 异步化(abort-and-replace+句柄身份比对+loadStarted, 移除busy_/catch) + 回灌防护测试" ``` --- ## Task 7: app — 装配接线 + qRegisterMetaType **Files:** - Modify: `src/app/main.cpp` - [ ] **Step 1: 注册元类型**(在 `main()` 早期、首次发起网络前,如 `QApplication app(...)` 之后) 在 main.cpp 顶部 include 区确保有 `#include "ApiClient.hpp"`(已有,经 ApiDatasetRepository)。在 `QApplication` 构造之后加: ```cpp qRegisterMetaType(); ``` - [ ] **Step 2: 确认装配无需改动** `geopro::data::ApiDatasetRepository datasetRepo(api);`(main.cpp:847)与 `DatasetDetailController detailCtrl(datasetRepo);`(:848)——`datasetRepo` 现是 `IAsyncDatasetRepository`,控制器形参已改为该类型,**引用绑定无需改动**。第 505–527 行 chartReady/gridReady/loadFailed/focusRequested/gridDataNeeded 接线**全部不变**(信号面未变)。 - [ ] **Step 3: 构建 + 全量测试** Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1` Expected: 84/84 PASS。构建出 `geopro_desktop.exe`。 - [ ] **Step 4: 手动验证(核心收益)** 启动 `build/release/src/app/geopro_desktop.exe`,登录 → 打开标准 ds `id=1458990939709440`: - 双击数据集 → 原数据加载期间 **UI 不冻**(可拖窗口/切其他)。 - 切到「网格数据」页签 → rows 1–4s 加载期间 **UI 不冻**。 - rows 加载中**快速切换到另一数据集 / 关闭页签** → 旧请求被 abort(无旧数据回灌、无崩溃)。 - 并发:原数据 2 请求、网格 3 请求并发发出(网络面板可见)。 - [ ] **Step 5: Commit** ```bash git add src/app/main.cpp git commit -m "feat(app): 装配异步详情仓储 + qRegisterMetaType" ``` --- ## Task 8: UI — `LoadingOverlay` + 加载反馈(可后置/并行) > 与异步内核无依赖,可在 Task 1–7 全绿后再做。先读 `src/app/panels/DatasetDetailPage.{hpp,cpp}` 确认 `gridView_` 容器与布局,再放遮罩。 **Files:** - Create: `src/app/panels/LoadingOverlay.hpp`, `src/app/panels/LoadingOverlay.cpp` - Modify: `src/app/panels/DatasetDetailPage.hpp`, `src/app/panels/DatasetDetailPage.cpp`, `src/app/panels/DatasetDetailPanel.hpp`, `src/app/panels/DatasetDetailPanel.cpp`, `src/app/main.cpp`, `src/app/CMakeLists.txt` - [ ] **Step 1: 写 `LoadingOverlay.hpp`** ```cpp #pragma once #include class QLabel; namespace geopro::app { // 半透明「加载中…」遮罩。贴在目标视图上层,show()/hide() 切换,几何随父 resize 跟随。 class LoadingOverlay : public QWidget { Q_OBJECT public: explicit LoadingOverlay(QWidget* parent); void showOver(); // 提到父尺寸、置顶、显示 protected: bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize private: QLabel* label_; }; } // namespace geopro::app ``` - [ ] **Step 2: 写 `LoadingOverlay.cpp`** ```cpp #include "panels/LoadingOverlay.hpp" #include #include #include #include namespace geopro::app { LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) { setAttribute(Qt::WA_StyledBackground, true); setStyleSheet(QStringLiteral("background: rgba(255,255,255,160);")); label_->setText(QStringLiteral("加载中…")); label_->setAlignment(Qt::AlignCenter); auto* lay = new QVBoxLayout(this); lay->addWidget(label_); if (parent) parent->installEventFilter(this); hide(); } void LoadingOverlay::showOver() { if (parentWidget()) setGeometry(parentWidget()->rect()); raise(); show(); } bool LoadingOverlay::eventFilter(QObject* obj, QEvent* ev) { if (obj == parentWidget() && ev->type() == QEvent::Resize && isVisible()) { setGeometry(parentWidget()->rect()); } return QWidget::eventFilter(obj, ev); } } // namespace geopro::app ``` - [ ] **Step 3: DatasetDetailPage 暴露加载状态接口** `DatasetDetailPage.hpp` 加方法声明(`setGridData` 附近): ```cpp void setGridLoading(bool on); // 网格视图遮罩开关 ``` `DatasetDetailPage.cpp`:在构造里于 `gridView_` 之上建一个 `LoadingOverlay* gridOverlay_`(父为 gridView_ 的容器或 gridView_ 本身),实现: ```cpp void DatasetDetailPage::setGridLoading(bool on) { if (on) gridOverlay_->showOver(); else gridOverlay_->hide(); } ``` (成员加 `LoadingOverlay* gridOverlay_;`,头部 `#include "panels/LoadingOverlay.hpp"` 或前置声明 + cpp include。`setGridData` 末尾调 `setGridLoading(false)`。) - [ ] **Step 4: DatasetDetailPanel 转发加载状态** `DatasetDetailPanel.hpp` 加: ```cpp void setGridLoading(const QString& dsId, bool on); ``` `DatasetDetailPanel.cpp` 实现:`pageFor(dsId)` 找到页 → `page->setGridLoading(on)`。 - [ ] **Step 5: main.cpp 接 loadStarted** 在第 518 行 gridReady 接线附近加: ```cpp QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadStarted, detailPanel, [detailPanel](const QString& dsId, geopro::controller::DatasetDetailController::LoadPhase phase) { if (phase == geopro::controller::DatasetDetailController::LoadPhase::Grid) detailPanel->setGridLoading(dsId, true); }); ``` (原数据初次加载的 busy 反馈:页面于 chartReady 创建,加载期可设 `window.setCursor(Qt::BusyCursor)`,chartReady/loadFailed 复位——可选,最小化先不做。) - [ ] **Step 6: 注册 LoadingOverlay 到 app CMake** `src/app/CMakeLists.txt` 找到 panels 源列表,加 `panels/LoadingOverlay.cpp`(geopro app 目标已 AUTOMOC ON——确认;若否则比照现有 panel 文件加法)。 - [ ] **Step 7: 构建 + 手动验证遮罩** Run: `powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` 启动 app → 切网格页签:rows 加载期间网格区显示「加载中…」遮罩、到达后消失;切走/关页遮罩随页消失。 - [ ] **Step 8: Commit** ```bash git add src/app/panels/LoadingOverlay.hpp src/app/panels/LoadingOverlay.cpp src/app/panels/DatasetDetailPage.hpp src/app/panels/DatasetDetailPage.cpp src/app/panels/DatasetDetailPanel.hpp src/app/panels/DatasetDetailPanel.cpp src/app/main.cpp src/app/CMakeLists.txt git commit -m "feat(app): 网格懒加载「加载中」遮罩(LoadingOverlay) 接 loadStarted" ``` --- ## Self-Review(spec 覆盖核对) - **§2 目标 1(不冻 UI)** → Task 2/5/7(getAsync 去 QEventLoop)+ Task 7 手动验证。✓ - **§2 目标 2(abort 过期)** → Task 2 ApiCall.abort / Task 3 ApiBatch.abort / Task 6 abort-and-replace + 身份比对。✓ - **§2 目标 3(并发)** → Task 5 ApiBatch 同时发 2/3 请求。✓ - **§2 目标 4(加载反馈)** → Task 6 loadStarted + Task 8 LoadingOverlay。✓ - **§2 目标 5(可复用原语)** → Task 2/3 IApiCall/ApiCall/ApiBatch。✓ - **§2 目标 6(测试不退化、离线可测)** → Task 1/3/4/6 单测,全程 dev-test。✓ - **§5.0 安全不变量** → 各层 `aborted_`(ApiCall/ApiBatch/ApiChartLoad/ApiGridLoad)+ 控制器身份比对 + 全 deleteLater;回归用例 Task 3 AbortGate / Task 4 GridLoadAbort / Task 6 DropsLateSignal。✓ - **§5.1 buildResponse 复用** → Task 1。✓ - **§7 失败判定 code==200** → Task 5 isFailure。✓ - **§5.2 旧方法去向 / 不碰 LocalSampleRepository** → Task 5 仅改 ApiDatasetRepository,IDatasetRepository 不动。✓ - **§5.2 fail-fast** → Task 3 FailFastAbortsOthers。✓ - **AUTOMOC** → Task 2(net ON)/ Task 4(data ON)。✓ - **qRegisterMetaType** → Task 7。✓ 类型/签名一致性核对:`IApiCall::finished(ApiResponse)`、`ApiBatch::succeeded(QList)`/`failed(int,ApiResponse)`、`ChartLoad::done(ChartParts)`/`failed(QString)`、`IAsyncDatasetRepository::loadChartAsync→ChartLoad*`、控制器 `loadStarted(QString,LoadPhase)` 全程一致。 --- ## Execution Handoff 计划已保存 `docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md`。两种执行方式: 1. **Subagent-Driven(推荐)** — 每个 Task 派新 subagent 实现,任务间审查,迭代快。 2. **Inline Execution** — 本会话内按 executing-plans 批量执行 + 检查点。 选哪个?