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

50 KiB
Raw Permalink Blame History

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(实现可测的 IApiCallApiBatch 并发汇聚多请求并 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 — 包一个 QNetworkReplyIApiCall 实现。
  • src/net/ApiResponseParse.hpp / src/net/ApiResponseParse.cppbuildResponse(QNetworkReply*)(从现 Impl::await 抽出解析部分sync/async 共用DRY
  • src/net/ApiBatch.hpp / src/net/ApiBatch.cpp — 并发汇聚 + fail-fast 原语。
  • src/data/api/DatasetLoads.hppChartParts / GridParts 结果载体(纯结构体)。
  • src/data/api/DatasetLoadHandles.hpp / src/data/api/DatasetLoadHandles.cpp — 抽象 ChartLoad/GridLoadQObject 基类)+ 具体 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/postJsonAsyncawait 改用 buildResponseApiResponseQ_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.cppqRegisterMetaType<ApiResponse>();接 loadStartedTask 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

#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.cppkHttpStatusUnset、相关未用 includeQJsonDocument/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.txtadd_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 PASSAuthLiveTest.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): 抽出 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

#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
#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
#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 ApiClientpostJson 声明后加两行;②文件末尾 } // 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.hppApiCall.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
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 — ApiBatchfail-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-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

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→解析→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
#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;

// 数据集详情异步仓储抽象。返回自管理句柄(完成/失败后 deleteLaterclass 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 实现 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(保留 encmust 改为复用 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.cppGridParts.gridparseInversionGrid 返回值覆盖赋值即可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:847DatasetDetailController 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

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

#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.cppgeopro 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-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 批量执行 + 检查点。

选哪个?