50 KiB
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<ApiResponse>();接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
#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 后半段搬来)
#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 改为:
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:
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
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
#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_、中断、deleteLater;abort 后绝不 emit finished
signals:
void finished(const geopro::net::ApiResponse& resp); // 成功/错误均经此(错误写 rawError)
};
} // namespace geopro::net
- Step 2: 写
ApiCall.hpp
#pragma once
#include <QPointer>
#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<QNetworkReply> reply_;
bool aborted_ = false;
};
} // namespace geopro::net
- Step 3: 写
ApiCall.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 之上)插入:
} // namespace geopro::net ←★ 不要重复;见下方完整片段
具体改法:①在 class ApiClient 内 postJson 声明后加两行;②文件末尾 } // namespace geopro::net 之后加 Q_DECLARE_METATYPE。
class ApiClient 内新增:
// 异步 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 之后)加:
#include <QMetaType>
Q_DECLARE_METATYPE(geopro::net::ApiResponse)
- Step 5: 改
ApiClient.cpp— 实现异步方法
顶部加 #include "ApiCall.hpp"。在 postJson 实现之后加:
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 + 加源
add_library(geopro_net STATIC
crypto/RsaEncryptor.cpp
ApiClient.cpp
ApiResponseParse.cpp
ApiCall.cpp
AuthService.cpp)
并把末行改为:
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
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)
#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
#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) 之后)加:
target_sources(geopro_tests PRIVATE net/test_api_batch.cpp)
并确保 net 段链接含 Qt6::Test(用于 QSignalSpy)。把第 50 行链接行改为:
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) 提供——若不存在则加:
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
#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
#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-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:
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
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(纯结构体)
#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 实现)
#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→解析→done;failed→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
#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
#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 接线)
#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:
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) 之后)加:
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
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
#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)
#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
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
#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
#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 + 回灌防护)
#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
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 构造之后加:
qRegisterMetaType<geopro::net::ApiResponse>();
- 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
git add src/app/main.cpp
git commit -m "feat(app): 装配异步详情仓储 + qRegisterMetaType<ApiResponse>"
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
#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
#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 附近):
void setGridLoading(bool on); // 网格视图遮罩开关
DatasetDetailPage.cpp:在构造里于 gridView_ 之上建一个 LoadingOverlay* gridOverlay_(父为 gridView_ 的容器或 gridView_ 本身),实现:
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 加:
void setGridLoading(const QString& dsId, bool on);
DatasetDetailPanel.cpp 实现:pageFor(dsId) 找到页 → page->setGridLoading(on)。
- Step 5: main.cpp 接 loadStarted
在第 518 行 gridReady 接线附近加:
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
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<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。两种执行方式:
- Subagent-Driven(推荐) — 每个 Task 派新 subagent 实现,任务间审查,迭代快。
- Inline Execution — 本会话内按 executing-plans 批量执行 + 检查点。
选哪个?