feat/dataset-detail-chart #5

Merged
gaozheng merged 74 commits from feat/dataset-detail-chart into main 2026-06-13 17:30:37 +08:00
5 changed files with 40 additions and 7 deletions
Showing only changes of commit 62352395ba - Show all commits

View File

@ -275,6 +275,7 @@ void WorkbenchNavController::loadMoreData() {
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
if (req != moreDataReq_) return; if (req != moreDataReq_) return;
moreDataReq_.clear(); moreDataReq_.clear();
--dataPageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreData 跳页
emit loadFailed(QStringLiteral("datasets"), msg); emit loadFailed(QStringLiteral("datasets"), msg);
emitBusyIfChanged(); emitBusyIfChanged();
}); });
@ -299,6 +300,7 @@ void WorkbenchNavController::loadMoreFiles() {
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) { QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
if (req != moreFilesReq_) return; if (req != moreFilesReq_) return;
moreFilesReq_.clear(); moreFilesReq_.clear();
--filePageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreFiles 跳页
emit loadFailed(QStringLiteral("files"), msg); emit loadFailed(QStringLiteral("files"), msg);
emitBusyIfChanged(); emitBusyIfChanged();
}); });
@ -331,13 +333,22 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
if (h) h->abort(); if (h) h->abort();
checkedInflight_.clear(); checkedInflight_.clear();
QStringList missing; // 入口去重:保留首次出现顺序,剔除重复 id避免重复请求 + 异常树重复 group
QStringList deduped;
QSet<QString> seen;
for (const QString& tmQ : tmObjectIds) { for (const QString& tmQ : tmObjectIds) {
if (seen.contains(tmQ)) continue;
seen.insert(tmQ);
deduped.push_back(tmQ);
}
QStringList missing;
for (const QString& tmQ : deduped) {
const std::string tm = tmQ.toStdString(); const std::string tm = tmQ.toStdString();
if (tmExceptionCache_.find(tm) == tmExceptionCache_.end()) missing.push_back(tmQ); if (tmExceptionCache_.find(tm) == tmExceptionCache_.end()) missing.push_back(tmQ);
} }
if (missing.isEmpty()) { // 全命中缓存同步组装busyChanged 不抖动 if (missing.isEmpty()) { // 全命中缓存同步组装busyChanged 不抖动
assembleAndEmitExceptionTree(tmObjectIds); assembleAndEmitExceptionTree(deduped);
emitBusyIfChanged(); emitBusyIfChanged();
return; return;
} }
@ -350,7 +361,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
NavRequest* req = repo_.loadExceptionsByTmAsync(tm); NavRequest* req = repo_.loadExceptionsByTmAsync(tm);
checkedInflight_.push_back(req); checkedInflight_.push_back(req);
QObject::connect(req, &NavRequest::done, this, QObject::connect(req, &NavRequest::done, this,
[this, req, tm, remaining, failedFlag, tmObjectIds](const QVariant& v) { [this, req, tm, remaining, failedFlag, deduped](const QVariant& v) {
// 身份比对req 仍在当前批中才接受(旧批已 abort + 从 vector 移除)。 // 身份比对req 仍在当前批中才接受(旧批已 abort + 从 vector 移除)。
bool inCurrent = false; bool inCurrent = false;
for (const auto& h : checkedInflight_) for (const auto& h : checkedInflight_)
@ -359,7 +370,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
tmExceptionCache_[tm] = qvariant_cast<std::vector<ExceptionRow>>(v); tmExceptionCache_[tm] = qvariant_cast<std::vector<ExceptionRow>>(v);
if (--(*remaining) == 0 && !*failedFlag) { if (--(*remaining) == 0 && !*failedFlag) {
checkedInflight_.clear(); checkedInflight_.clear();
assembleAndEmitExceptionTree(tmObjectIds); assembleAndEmitExceptionTree(deduped);
emitBusyIfChanged(); emitBusyIfChanged();
} }
}); });

View File

@ -27,7 +27,7 @@ public:
using Parser = std::function<QVariant(const geopro::net::ApiResponse&)>; using Parser = std::function<QVariant(const geopro::net::ApiResponse&)>;
using Predicate = std::function<bool(const geopro::net::ApiResponse&)>; using Predicate = std::function<bool(const geopro::net::ApiResponse&)>;
ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure, ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure,
QObject* parent = nullptr); // 接管 call QObject* parent = nullptr); // 持有非拥有引用QPointercall 完成(finished)或 abort 后自行 deleteLater 自管理生命周期,本类不得 delete 它
void abort() override; void abort() override;
private: private:
QPointer<geopro::net::IApiCall> call_; QPointer<geopro::net::IApiCall> call_;

View File

@ -13,7 +13,7 @@ class ApiBatch : public QObject {
Q_OBJECT Q_OBJECT
public: public:
using Predicate = std::function<bool(const ApiResponse&)>; using Predicate = std::function<bool(const ApiResponse&)>;
ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent = nullptr); // 接管 calls ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent = nullptr); // 持有非拥有引用QPointer 列表);各 call 完成或 abort 后自行 deleteLater本类不得 delete 它们
void abort(); void abort();
signals: signals:
void succeeded(const QList<geopro::net::ApiResponse>& responses); void succeeded(const QList<geopro::net::ApiResponse>& responses);

View File

@ -11,10 +11,12 @@ namespace geopro::net {
// 任一步失败 → fail-fastfailed(index,resp) + abort 当前在飞 + deleteLater。 // 任一步失败 → fail-fastfailed(index,resp) + abort 当前在飞 + deleteLater。
// 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0aborted_ 闸门 + 一律 deleteLater // 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0aborted_ 闸门 + 一律 deleteLater
// 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。 // 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。
// 注意Part A导航暂未接入生产调用首个生产使用见 Part B 登录AuthService 串行链);
// 已由 tests/net/test_api_chain.cpp 覆盖,请勿当死代码删除。
class ApiChain : public QObject { class ApiChain : public QObject {
Q_OBJECT Q_OBJECT
public: public:
// 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall接管所有权)。可抛 std::exception。 // 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall持有非拥有引用 QPointerIApiCall 完成或 abort 后自行 deleteLater本类不得 delete 它)。可抛 std::exception。
using StepFactory = std::function<IApiCall*(const QList<ApiResponse>& prior)>; using StepFactory = std::function<IApiCall*(const QList<ApiResponse>& prior)>;
using Predicate = std::function<bool(const ApiResponse&)>; using Predicate = std::function<bool(const ApiResponse&)>;
ApiChain(QList<StepFactory> steps, Predicate isFailure, QObject* parent = nullptr); ApiChain(QList<StepFactory> steps, Predicate isFailure, QObject* parent = nullptr);

View File

@ -188,6 +188,26 @@ TEST(WorkbenchNavController, SelectDatasetEmitsDetail) {
EXPECT_EQ(spy.count(), 1); EXPECT_EQ(spy.count(), 1);
} }
// selectObject 三并发部分失败data 失败、file/detail 成功 → loadFailed×1filesLoaded×1objectDetailLoaded×1datasetsLoaded×0。
TEST(WorkbenchNavController, SelectObjectOneFailureEmitsPartialResults) {
StubAsyncRepo repo;
controller::WorkbenchNavController c(repo);
QSignalSpy dsSpy(&c, &controller::WorkbenchNavController::datasetsLoaded);
QSignalSpy flSpy(&c, &controller::WorkbenchNavController::filesLoaded);
QSignalSpy dtSpy(&c, &controller::WorkbenchNavController::objectDetailLoaded);
QSignalSpy failSpy(&c, &controller::WorkbenchNavController::loadFailed);
c.selectObject("obj2", 1);
// data 路失败file/detail 路成功(三路独立,互不影响)
repo.lastData->fireFailed();
repo.lastFile->fireDone(dsPageVar());
repo.lastDetail->fireDone(formVar());
EXPECT_EQ(dsSpy.count(), 0); // data 失败,无 datasetsLoaded
EXPECT_EQ(flSpy.count(), 1); // file 成功,有 filesLoaded
EXPECT_EQ(dtSpy.count(), 1); // detail 成功,有 objectDetailLoaded
EXPECT_EQ(failSpy.count(), 1); // 只有 data 路触发 loadFailed
EXPECT_EQ(failSpy.first().at(0).toString(), QStringLiteral("datasets"));
}
// 失败路径start 首级失败 → loadFailed + busy 复位。 // 失败路径start 首级失败 → loadFailed + busy 复位。
TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) { TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) {
StubAsyncRepo repo; StubAsyncRepo repo;