From 66541b5ef87fa3138ff24bb2be38a411de72a04e Mon Sep 17 00:00:00 2001 From: gaozheng Date: Thu, 11 Jun 2026 19:37:29 +0800 Subject: [PATCH] =?UTF-8?q?docs(plan):=20ApiClient=20=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E5=8C=96(DatasetDetail=20=E8=B7=AF=E5=BE=84)=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=AE=A1=E5=88=92=20=E2=80=94=208=20=E4=BB=BB=E5=8A=A1=20TDD/b?= =?UTF-8?q?ite-sized=EF=BC=8C=E5=90=AB=20net=E5=8E=9F=E8=AF=AD/data?= =?UTF-8?q?=E5=8F=A5=E6=9F=84/=E6=8E=A7=E5=88=B6=E5=99=A8/UI=E9=81=AE?= =?UTF-8?q?=E7=BD=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-06-11-apiclient-async-datasetdetail.md | 1363 +++++++++++++++++ 1 file changed, 1363 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md diff --git a/docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md b/docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md new file mode 100644 index 0000000..88db486 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md @@ -0,0 +1,1363 @@ +# 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 批量执行 + 检查点。 + +选哪个? +