geopro/docs/superpowers/plans/2026-06-11-apiclient-async-...

1364 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-fastdata 层 `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<ApiResponse>()`;接 `loadStarted`Task 8
- `src/app/CMakeLists.txt` — 加 LoadingOverlayTask 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 <QByteArray>
#include <QJsonDocument>
#include <QJsonParseError>
#include <QJsonValue>
#include <QNetworkReply>
#include <QNetworkRequest>
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): 抽出 buildResponsesync/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 <QObject>
#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_、中断、deleteLaterabort 后绝不 emit finished
signals:
void finished(const geopro::net::ApiResponse& resp); // 成功/错误均经此(错误写 rawError
};
} // namespace geopro::net
```
- [ ] **Step 2: 写 `ApiCall.hpp`**
```cpp
#pragma once
#include <QPointer>
#include "IApiCall.hpp"
class QNetworkReply;
namespace geopro::net {
// 包一个 QNetworkReply 的 IApiCall 实现。reply 完成 → buildResponse → emit finished → 自删。
// 安全不变量spec §5.0abort 后绝不 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<QNetworkReply> reply_;
bool aborted_ = false;
};
} // namespace geopro::net
```
- [ ] **Step 3: 写 `ApiCall.cpp`**
```cpp
#include "ApiCall.hpp"
#include <QNetworkReply>
#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 <QMetaType>
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 3ApiBatch + 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 <gtest/gtest.h>
#include <QSignalSpy>
#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 <functional>
#include <QList>
#include <QPointer>
#include <QObject>
#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<bool(const ApiResponse&)>;
ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls
void abort();
signals:
void succeeded(const QList<geopro::net::ApiResponse>& responses);
void failed(int index, const geopro::net::ApiResponse& resp);
private:
QList<QPointer<IApiCall>> calls_;
QList<ApiResponse> 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<IApiCall*> calls, Predicate isFailure, QObject* parent)
: QObject(parent), isFailure_(std::move(isFailure)) {
responses_.resize(calls.size());
remaining_ = static_cast<int>(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-fastabort 其余在飞
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 <vector>
#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<geopro::core::Anomaly> anomalies;
};
} // namespace geopro::data
```
- [ ] **Step 2: 写 `src/data/api/DatasetLoadHandles.hpp`**(抽象基 + Api 实现)
```cpp
#pragma once
#include <functional>
#include <QList>
#include <QObject>
#include <QPointer>
#include <QString>
#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→解析→donefailed→failed ──
class ApiChartLoad : public ChartLoad {
Q_OBJECT
public:
using Parser = std::function<ChartParts(const QList<geopro::net::ApiResponse>&)>;
ApiChartLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr);
void abort() override;
private:
QPointer<geopro::net::ApiBatch> batch_;
Parser parse_;
bool aborted_ = false;
};
class ApiGridLoad : public GridLoad {
Q_OBJECT
public:
using Parser = std::function<GridParts(const QList<geopro::net::ApiResponse>&)>;
ApiGridLoad(geopro::net::ApiBatch* batch, Parser parse, QObject* parent = nullptr);
void abort() override;
private:
QPointer<geopro::net::ApiBatch> batch_;
Parser parse_;
bool aborted_ = false;
};
} // namespace geopro::data
```
- [ ] **Step 3: 写 `src/data/api/DatasetLoadHandles.cpp`**
```cpp
#include "api/DatasetLoadHandles.hpp"
#include <stdexcept>
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<geopro::net::ApiResponse>& 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<geopro::net::ApiResponse>& 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 <string>
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 <gtest/gtest.h>
#include <QSignalSpy>
#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<ApiResponse>& 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<ApiResponse>&) { 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<ApiResponse>&) { 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 实现 IAsyncDatasetRepositoryERT 反演)。每次加载返回自管理句柄。
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 <stdexcept>
#include <QJsonObject>
#include <QString>
#include <QUrl>
#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<net::IApiCall*> 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<net::ApiResponse>& 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<net::IApiCall*> 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<net::ApiResponse>& 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 <string>
#include <QObject>
#include <QPointer>
#include <QString>
#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<geopro::core::Anomaly> anomalies;
};
struct GridData {
QString dsId;
geopro::core::Grid grid{1, 1};
geopro::core::ColorScale gridScale;
std::vector<geopro::core::Anomaly> 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<data::ChartLoad> chartLoad_;
QPointer<data::GridLoad> 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 <gtest/gtest.h>
#include <QSignalSpy>
#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<geopro::net::ApiResponse>();
```
- [ ] **Step 2: 确认装配无需改动**
`geopro::data::ApiDatasetRepository datasetRepo(api);`main.cpp:847`DatasetDetailController detailCtrl(datasetRepo);`:848——`datasetRepo` 现是 `IAsyncDatasetRepository`,控制器形参已改为该类型,**引用绑定无需改动**。第 505527 行 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 14s 加载期间 **UI 不冻**
- rows 加载中**快速切换到另一数据集 / 关闭页签** → 旧请求被 abort无旧数据回灌、无崩溃
- 并发:原数据 2 请求、网格 3 请求并发发出(网络面板可见)。
- [ ] **Step 5: Commit**
```bash
git add src/app/main.cpp
git commit -m "feat(app): 装配异步详情仓储 + qRegisterMetaType<ApiResponse>"
```
---
## Task 8: UI — `LoadingOverlay` + 加载反馈(可后置/并行)
> 与异步内核无依赖,可在 Task 17 全绿后再做。先读 `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 <QWidget>
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 <QEvent>
#include <QLabel>
#include <QResizeEvent>
#include <QVBoxLayout>
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-Reviewspec 覆盖核对)
- **§2 目标 1不冻 UI** → Task 2/5/7getAsync 去 QEventLoop+ Task 7 手动验证。✓
- **§2 目标 2abort 过期)** → 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 仅改 ApiDatasetRepositoryIDatasetRepository 不动。✓
- **§5.2 fail-fast** → Task 3 FailFastAbortsOthers。✓
- **AUTOMOC** → Task 2net ON/ Task 4data ON。✓
- **qRegisterMetaType** → Task 7。✓
类型/签名一致性核对:`IApiCall::finished(ApiResponse)`、`ApiBatch::succeeded(QList<ApiResponse>)`/`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 批量执行 + 检查点。
选哪个?
</content>