Compare commits
10 Commits
8f94443323
...
8b3bc50f58
| Author | SHA1 | Date |
|---|---|---|
|
|
8b3bc50f58 | |
|
|
dc9506c260 | |
|
|
eaa3770f93 | |
|
|
350f46060d | |
|
|
e57985c057 | |
|
|
8cdd6679a9 | |
|
|
bb602e2011 | |
|
|
e980ddd346 | |
|
|
72b300d722 | |
|
|
f74d47e62e |
|
|
@ -35,6 +35,7 @@ add_executable(geopro_desktop WIN32
|
||||||
panels/chart/ContourPlotItem.cpp
|
panels/chart/ContourPlotItem.cpp
|
||||||
panels/chart/LivePanner.cpp
|
panels/chart/LivePanner.cpp
|
||||||
panels/AnomalyTablePanel.cpp
|
panels/AnomalyTablePanel.cpp
|
||||||
|
panels/LoadingOverlay.cpp
|
||||||
panels/DatasetDetailPage.cpp
|
panels/DatasetDetailPage.cpp
|
||||||
panels/DatasetDetailPanel.cpp
|
panels/DatasetDetailPanel.cpp
|
||||||
CentralScene.cpp
|
CentralScene.cpp
|
||||||
|
|
|
||||||
|
|
@ -520,8 +520,19 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
||||||
[detailPanel](const geopro::controller::DatasetDetailController::GridData& d) {
|
[detailPanel](const geopro::controller::DatasetDetailController::GridData& d) {
|
||||||
detailPanel->setGridData(d);
|
detailPanel->setGridData(d);
|
||||||
});
|
});
|
||||||
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, &window,
|
QObject::connect(
|
||||||
[&window](const QString& dsId, const QString& msg) {
|
&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);
|
||||||
|
});
|
||||||
|
// context 用 detailPanel(与 loadStarted 一致):detailPanel 析构即自动断连,避免野指针。
|
||||||
|
// window 比 detailPanel 活得久,lambda 捕 &window 取状态栏安全。loadFailed 无 phase:
|
||||||
|
// Chart 失败时 gridOverlay 本已隐藏,setGridLoading(false) 幂等无害;Grid 失败时正确清遮罩。
|
||||||
|
QObject::connect(&detailCtrl, &geopro::controller::DatasetDetailController::loadFailed, detailPanel,
|
||||||
|
[&window, detailPanel](const QString& dsId, const QString& msg) {
|
||||||
|
detailPanel->setGridLoading(dsId, false);
|
||||||
window.statusBar()->showMessage(
|
window.statusBar()->showMessage(
|
||||||
QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000);
|
QStringLiteral("数据集 %1 加载失败:%2").arg(dsId, msg), 5000);
|
||||||
});
|
});
|
||||||
|
|
@ -788,6 +799,10 @@ int main(int argc, char* argv[])
|
||||||
QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat());
|
QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat());
|
||||||
QApplication app(argc, argv);
|
QApplication app(argc, argv);
|
||||||
|
|
||||||
|
// 异步 ApiCall::finished 等信号携带 ApiResponse;注册元类型以支持跨 QueuedConnection 传递
|
||||||
|
// (当前详情链路为同线程 DirectConnection,非严格必需,但作防御性注册,见 spec §5.1)。
|
||||||
|
qRegisterMetaType<geopro::net::ApiResponse>();
|
||||||
|
|
||||||
// 组织/应用名:QSettings 持久化(dock 布局、登录记忆等)按此定位存储位置。
|
// 组织/应用名:QSettings 持久化(dock 布局、登录记忆等)按此定位存储位置。
|
||||||
QCoreApplication::setOrganizationName(QStringLiteral("Geomative"));
|
QCoreApplication::setOrganizationName(QStringLiteral("Geomative"));
|
||||||
QCoreApplication::setApplicationName(QStringLiteral("Geopro3"));
|
QCoreApplication::setApplicationName(QStringLiteral("Geopro3"));
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
#include "Glyphs.hpp"
|
#include "Glyphs.hpp"
|
||||||
#include "PanelHeader.hpp"
|
#include "PanelHeader.hpp"
|
||||||
|
#include "panels/LoadingOverlay.hpp"
|
||||||
#include "panels/chart/GridDataChartView.hpp"
|
#include "panels/chart/GridDataChartView.hpp"
|
||||||
#include "panels/chart/RawDataChartView.hpp"
|
#include "panels/chart/RawDataChartView.hpp"
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) {
|
||||||
|
|
||||||
rawView_ = new RawDataChartView(this);
|
rawView_ = new RawDataChartView(this);
|
||||||
gridView_ = new GridDataChartView(this);
|
gridView_ = new GridDataChartView(this);
|
||||||
|
gridOverlay_ = new LoadingOverlay(gridView_); // 父为 gridView_,随其尺寸覆盖网格图区
|
||||||
|
|
||||||
const QVector<PanelTab> tabs = {
|
const QVector<PanelTab> tabs = {
|
||||||
{Glyph::Detail, QStringLiteral("原数据"), rawView_, false},
|
{Glyph::Detail, QStringLiteral("原数据"), rawView_, false},
|
||||||
|
|
@ -49,12 +51,18 @@ void DatasetDetailPage::setData(const geopro::controller::DatasetDetailControlle
|
||||||
gridRequested_ = false; // 新数据集 → 网格数据需重新按需加载
|
gridRequested_ = false; // 新数据集 → 网格数据需重新按需加载
|
||||||
rawView_->setData(d);
|
rawView_->setData(d);
|
||||||
gridView_->setData(d);
|
gridView_->setData(d);
|
||||||
|
setGridLoading(false); // 重开/换 ds:重置遮罩状态,避免上次的「加载中」残留
|
||||||
}
|
}
|
||||||
|
|
||||||
void DatasetDetailPage::setGridData(
|
void DatasetDetailPage::setGridData(
|
||||||
const geopro::controller::DatasetDetailController::GridData& d) {
|
const geopro::controller::DatasetDetailController::GridData& d) {
|
||||||
gridRequested_ = true; // 已加载,切回网格页不再重复请求
|
gridRequested_ = true; // 已加载,切回网格页不再重复请求
|
||||||
gridView_->setGridData(d.grid, d.gridScale, d.anomalies);
|
gridView_->setGridData(d.grid, d.gridScale, d.anomalies);
|
||||||
|
setGridLoading(false); // 数据到达,隐藏遮罩
|
||||||
|
}
|
||||||
|
|
||||||
|
void DatasetDetailPage::setGridLoading(bool on) {
|
||||||
|
if (on) gridOverlay_->showOver(); else gridOverlay_->hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace geopro::app
|
} // namespace geopro::app
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ namespace geopro::app {
|
||||||
|
|
||||||
class RawDataChartView;
|
class RawDataChartView;
|
||||||
class GridDataChartView;
|
class GridDataChartView;
|
||||||
|
class LoadingOverlay;
|
||||||
|
|
||||||
// 单个数据集详情页:下划线页签「原数据 / 网格数据」+ 右侧「导出」操作。
|
// 单个数据集详情页:下划线页签「原数据 / 网格数据」+ 右侧「导出」操作。
|
||||||
// 内部分别由 RawDataChartView / GridDataChartView 实现各自三层布局。
|
// 内部分别由 RawDataChartView / GridDataChartView 实现各自三层布局。
|
||||||
|
|
@ -16,6 +17,8 @@ public:
|
||||||
void setData(const geopro::controller::DatasetDetailController::ChartData& d);
|
void setData(const geopro::controller::DatasetDetailController::ChartData& d);
|
||||||
// 网格数据到达(懒加载结果)→ 下发给 GridDataChartView 并标记已加载。
|
// 网格数据到达(懒加载结果)→ 下发给 GridDataChartView 并标记已加载。
|
||||||
void setGridData(const geopro::controller::DatasetDetailController::GridData& d);
|
void setGridData(const geopro::controller::DatasetDetailController::GridData& d);
|
||||||
|
// 网格懒加载进行中(true)/结束(false)时切换遮罩显隐。
|
||||||
|
void setGridLoading(bool on);
|
||||||
QString dsId() const { return dsId_; }
|
QString dsId() const { return dsId_; }
|
||||||
signals:
|
signals:
|
||||||
// 「网格数据」页签首次激活且本页网格数据未加载 → 请求懒加载。
|
// 「网格数据」页签首次激活且本页网格数据未加载 → 请求懒加载。
|
||||||
|
|
@ -26,6 +29,7 @@ private:
|
||||||
bool gridRequested_ = false; // 已请求过(避免重复发信号)
|
bool gridRequested_ = false; // 已请求过(避免重复发信号)
|
||||||
RawDataChartView* rawView_;
|
RawDataChartView* rawView_;
|
||||||
GridDataChartView* gridView_;
|
GridDataChartView* gridView_;
|
||||||
|
LoadingOverlay* gridOverlay_; // 网格懒加载期间覆盖 gridView_ 的遮罩
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace geopro::app
|
} // namespace geopro::app
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailCon
|
||||||
void DatasetDetailPanel::setGridData(const geopro::controller::DatasetDetailController::GridData& d) {
|
void DatasetDetailPanel::setGridData(const geopro::controller::DatasetDetailController::GridData& d) {
|
||||||
if (auto* p = pageFor(d.dsId)) p->setGridData(d);
|
if (auto* p = pageFor(d.dsId)) p->setGridData(d);
|
||||||
}
|
}
|
||||||
|
void DatasetDetailPanel::setGridLoading(const QString& dsId, bool on) {
|
||||||
|
if (auto* p = pageFor(dsId)) p->setGridLoading(on);
|
||||||
|
}
|
||||||
void DatasetDetailPanel::focusDataset(const QString& dsId) {
|
void DatasetDetailPanel::focusDataset(const QString& dsId) {
|
||||||
if (auto* p = pageFor(dsId)) setCurrentWidget(p);
|
if (auto* p = pageFor(dsId)) setCurrentWidget(p);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ public:
|
||||||
explicit DatasetDetailPanel(QWidget* parent = nullptr);
|
explicit DatasetDetailPanel(QWidget* parent = nullptr);
|
||||||
void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达
|
void openOrUpdate(const geopro::controller::DatasetDetailController::ChartData& d); // 双击/数据到达
|
||||||
void setGridData(const geopro::controller::DatasetDetailController::GridData& d); // 网格数据懒加载到达
|
void setGridData(const geopro::controller::DatasetDetailController::GridData& d); // 网格数据懒加载到达
|
||||||
|
void setGridLoading(const QString& dsId, bool on); // 网格懒加载进行中/结束 → 转发给对应页
|
||||||
void focusDataset(const QString& dsId); // 单击聚焦已开页
|
void focusDataset(const QString& dsId); // 单击聚焦已开页
|
||||||
signals:
|
signals:
|
||||||
void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表
|
void activeDatasetChanged(const QString& dsId); // 反向联动数据集列表
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
#include "panels/LoadingOverlay.hpp"
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) {
|
||||||
|
Q_ASSERT(parent); // 契约:必须有父(遮罩几何跟随父,无父无法工作)
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
#pragma once
|
||||||
|
#include <QWidget>
|
||||||
|
class QLabel;
|
||||||
|
namespace geopro::app {
|
||||||
|
|
||||||
|
// 半透明「加载中…」遮罩。贴在目标视图上层,showOver()/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
|
||||||
|
|
@ -1,60 +1,67 @@
|
||||||
#include "DatasetDetailController.hpp"
|
#include "DatasetDetailController.hpp"
|
||||||
#include <stdexcept>
|
#include "repo/IAsyncDatasetRepository.hpp"
|
||||||
#include "repo/IDatasetRepository.hpp"
|
#include "api/DatasetLoadHandles.hpp"
|
||||||
namespace geopro::controller {
|
namespace geopro::controller {
|
||||||
|
|
||||||
DatasetDetailController::DatasetDetailController(data::IDatasetRepository& repo, QObject* parent)
|
DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent)
|
||||||
: QObject(parent), repo_(repo) {}
|
: QObject(parent), repo_(repo) {}
|
||||||
|
|
||||||
|
DatasetDetailController::~DatasetDetailController() {
|
||||||
|
if (chartLoad_) chartLoad_->abort(); // 退出契约:abort 在飞句柄,不依赖外部析构顺序兜底
|
||||||
|
if (gridLoad_) gridLoad_->abort();
|
||||||
|
}
|
||||||
|
|
||||||
void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) {
|
void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) {
|
||||||
if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入)
|
|
||||||
busy_ = true;
|
|
||||||
if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演
|
if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演
|
||||||
busy_ = false;
|
|
||||||
emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
|
emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const std::string id = dsId.toStdString();
|
if (chartLoad_) chartLoad_->abort(); // abort-and-replace
|
||||||
try {
|
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;
|
ChartData d;
|
||||||
d.dsId = dsId;
|
d.dsId = dsId;
|
||||||
d.ddCode = ddCode;
|
d.ddCode = ddCode;
|
||||||
// 严格对齐原版「原数据」页:只拉 scatter + 散点色阶(type1) 两个。
|
d.scatter = parts.scatter;
|
||||||
// 网格数据(inversion/rows)与异常(queryException)随「网格数据」页按需懒加载。
|
d.scatterScale = parts.scatterScale;
|
||||||
d.scatter = repo_.loadScatter(id);
|
|
||||||
d.scatterScale = repo_.loadScatterColorScale(id);
|
|
||||||
busy_ = false;
|
|
||||||
emit chartReady(d);
|
emit chartReady(d);
|
||||||
} catch (const std::exception& e) {
|
});
|
||||||
busy_ = false;
|
QObject::connect(load, &data::ChartLoad::failed, this,
|
||||||
emit loadFailed(dsId, QString::fromStdString(e.what()));
|
[this, load, dsId](const QString& msg) {
|
||||||
} catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_,否则永久拒绝后续加载
|
if (load != chartLoad_) return;
|
||||||
busy_ = false;
|
chartLoad_.clear();
|
||||||
emit loadFailed(dsId, QStringLiteral("未知错误"));
|
emit loadFailed(dsId, msg);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) {
|
void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) {
|
||||||
if (busy_) return; // 防重入(同步网络期间 QEventLoop 可重入)
|
|
||||||
if (ddCode != QLatin1String("dd_inversion_data")) return; // 仅 ERT 反演有网格数据
|
if (ddCode != QLatin1String("dd_inversion_data")) return; // 仅 ERT 反演有网格数据
|
||||||
busy_ = true;
|
if (gridLoad_) gridLoad_->abort(); // abort-and-replace
|
||||||
const std::string id = dsId.toStdString();
|
data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString());
|
||||||
try {
|
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; // §5.0 句柄身份比对:丢弃迟到信号
|
||||||
|
gridLoad_.clear();
|
||||||
GridData d;
|
GridData d;
|
||||||
d.dsId = dsId;
|
d.dsId = dsId;
|
||||||
// 网格数据:rows(服务端网格化,慢) + 色阶 type2 + 异常 queryException。
|
d.grid = parts.grid;
|
||||||
d.grid = repo_.loadGrid(id);
|
d.gridScale = parts.gridScale;
|
||||||
d.gridScale = repo_.loadColorScale(id);
|
d.anomalies = parts.anomalies;
|
||||||
d.anomalies = repo_.loadAnomalies(id);
|
|
||||||
busy_ = false;
|
|
||||||
emit gridReady(d);
|
emit gridReady(d);
|
||||||
} catch (const std::exception& e) {
|
});
|
||||||
busy_ = false;
|
QObject::connect(load, &data::GridLoad::failed, this,
|
||||||
emit loadFailed(dsId, QString::fromStdString(e.what()));
|
[this, load, dsId](const QString& msg) {
|
||||||
} catch (...) { // 非 std 异常(VTK/Qt)也必须复位 busy_,否则永久拒绝后续加载
|
if (load != gridLoad_) return;
|
||||||
busy_ = false;
|
gridLoad_.clear();
|
||||||
emit loadFailed(dsId, QStringLiteral("未知错误"));
|
emit loadFailed(dsId, msg);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); }
|
void DatasetDetailController::focusDataset(const QString& dsId) { emit focusRequested(dsId); }
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include "model/Field.hpp"
|
#include "model/Field.hpp"
|
||||||
#include "model/ColorScale.hpp"
|
#include "model/ColorScale.hpp"
|
||||||
#include "model/Anomaly.hpp"
|
#include "model/Anomaly.hpp"
|
||||||
namespace geopro::data { class IDatasetRepository; }
|
namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; }
|
||||||
namespace geopro::controller {
|
namespace geopro::controller {
|
||||||
|
|
||||||
// 数据详情编排:单击/双击数据集 → 拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。
|
// 数据详情编排:双击/网格页签 → 异步拉 散点/网格/色阶/异常 → 发信号给详情面板。被动视图。
|
||||||
// 仅服务图表,不与 WorkbenchNavController(项目/结构导航)耦合。
|
|
||||||
class DatasetDetailController : public QObject {
|
class DatasetDetailController : public QObject {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
|
enum class LoadPhase { Chart, Grid };
|
||||||
|
Q_ENUM(LoadPhase)
|
||||||
|
|
||||||
struct ChartData {
|
struct ChartData {
|
||||||
QString dsId, ddCode;
|
QString dsId, ddCode;
|
||||||
geopro::core::ScatterField scatter;
|
geopro::core::ScatterField scatter;
|
||||||
|
|
@ -29,19 +32,21 @@ public:
|
||||||
std::vector<geopro::core::Anomaly> anomalies;
|
std::vector<geopro::core::Anomaly> anomalies;
|
||||||
};
|
};
|
||||||
|
|
||||||
explicit DatasetDetailController(data::IDatasetRepository& repo, QObject* parent = nullptr);
|
explicit DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent = nullptr);
|
||||||
|
~DatasetDetailController() override; // 退出契约(spec §7):abort 在飞句柄,避免迟到信号打到已析构 this
|
||||||
public slots:
|
public slots:
|
||||||
void openDataset(const QString& dsId, const QString& ddCode); // 双击=新建/聚焦页
|
void openDataset(const QString& dsId, const QString& ddCode);
|
||||||
void focusDataset(const QString& dsId); // 单击=聚焦已开页
|
void focusDataset(const QString& dsId);
|
||||||
// 网格数据懒加载:网格页首次激活时调用(rows 服务端网格化 1-4s,故不随 openDataset 拉)。
|
|
||||||
void loadGridData(const QString& dsId, const QString& ddCode);
|
void loadGridData(const QString& dsId, const QString& ddCode);
|
||||||
signals:
|
signals:
|
||||||
|
void loadStarted(const QString& dsId, LoadPhase phase);
|
||||||
void chartReady(const ChartData& data);
|
void chartReady(const ChartData& data);
|
||||||
void gridReady(const GridData& data);
|
void gridReady(const GridData& data);
|
||||||
void focusRequested(const QString& dsId);
|
void focusRequested(const QString& dsId);
|
||||||
void loadFailed(const QString& dsId, const QString& message);
|
void loadFailed(const QString& dsId, const QString& message);
|
||||||
private:
|
private:
|
||||||
data::IDatasetRepository& repo_;
|
data::IAsyncDatasetRepository& repo_;
|
||||||
bool busy_ = false;
|
QPointer<data::ChartLoad> chartLoad_;
|
||||||
|
QPointer<data::GridLoad> gridLoad_;
|
||||||
};
|
};
|
||||||
} // namespace geopro::controller
|
} // namespace geopro::controller
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ add_library(geopro_data STATIC
|
||||||
dto/NavDto.cpp
|
dto/NavDto.cpp
|
||||||
dto/DatasetChartDto.cpp
|
dto/DatasetChartDto.cpp
|
||||||
api/ApiProjectRepository.cpp
|
api/ApiProjectRepository.cpp
|
||||||
api/ApiDatasetRepository.cpp)
|
api/ApiDatasetRepository.cpp
|
||||||
|
api/DatasetLoadHandles.cpp)
|
||||||
target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core PRIVATE nlohmann_json::nlohmann_json)
|
target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core PRIVATE nlohmann_json::nlohmann_json)
|
||||||
target_compile_features(geopro_data PUBLIC cxx_std_17)
|
target_compile_features(geopro_data PUBLIC cxx_std_17)
|
||||||
set_target_properties(geopro_data PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF)
|
set_target_properties(geopro_data PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF)
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,62 @@
|
||||||
#include "api/ApiDatasetRepository.hpp"
|
#include "api/ApiDatasetRepository.hpp"
|
||||||
#include <stdexcept>
|
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include "ApiClient.hpp"
|
#include "ApiClient.hpp"
|
||||||
|
#include "ApiBatch.hpp"
|
||||||
|
#include "api/DatasetLoadHandles.hpp"
|
||||||
#include "dto/DatasetChartDto.hpp"
|
#include "dto/DatasetChartDto.hpp"
|
||||||
|
|
||||||
namespace geopro::data {
|
namespace geopro::data {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
QString enc(const std::string& s) {
|
QString enc(const std::string& s) {
|
||||||
return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s)));
|
return QString::fromUtf8(QUrl::toPercentEncoding(QString::fromStdString(s)));
|
||||||
}
|
}
|
||||||
void must(const net::ApiResponse& r, const char* what) {
|
|
||||||
if (r.code != 200) throw std::runtime_error(std::string(what) + " failed: " +
|
// 失败判定(原 must() 口径):业务码 != 200 或传输错误。
|
||||||
(r.msg.isEmpty() ? r.rawError.toStdString() : r.msg.toStdString()));
|
bool isFailure(const geopro::net::ApiResponse& r) {
|
||||||
|
return r.code != 200 || !r.rawError.isEmpty();
|
||||||
}
|
}
|
||||||
geopro::core::ColorScale colorScale(net::ApiClient& api, const std::string& dsId, int type) {
|
|
||||||
QJsonObject body{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}};
|
QJsonObject colorBody(const std::string& dsId, int type) {
|
||||||
const net::ApiResponse r = api.postJson(QStringLiteral("/business/lvl/colorGradation/getDetail"), body);
|
return QJsonObject{{"dsObjectId", QString::fromStdString(dsId)}, {"businessCode", ""}, {"type", type}};
|
||||||
must(r, "colorGradation");
|
|
||||||
return dto::parseColorBar(r.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {}
|
ApiDatasetRepository::ApiDatasetRepository(net::ApiClient& api) : api_(api) {}
|
||||||
|
|
||||||
geopro::core::Grid ApiDatasetRepository::loadGrid(const std::string& dsId) {
|
ChartLoad* ApiDatasetRepository::loadChartAsync(const std::string& dsId) {
|
||||||
const net::ApiResponse r = api_.get(
|
// index 0 = scatter(GET),index 1 = 散点色阶 type1(POST)
|
||||||
QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId)));
|
QList<net::IApiCall*> calls{
|
||||||
must(r, "inversion/rows");
|
api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId))),
|
||||||
return dto::parseInversionGrid(r.data);
|
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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
geopro::core::ScatterField ApiDatasetRepository::loadScatter(const std::string& dsId) {
|
|
||||||
const net::ApiResponse r = api_.get(
|
GridLoad* ApiDatasetRepository::loadGridAsync(const std::string& dsId) {
|
||||||
QStringLiteral("/business/dd/ert/inversion/getErtRawDataScatterGraph/%1").arg(enc(dsId)));
|
// index 0 = rows(GET,慢),1 = 色阶 type2(POST),2 = 异常(GET)
|
||||||
must(r, "scatterGraph");
|
QList<net::IApiCall*> calls{
|
||||||
return dto::parseScatterGraph(r.data);
|
api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsId))),
|
||||||
}
|
api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), colorBody(dsId, 2)),
|
||||||
geopro::core::ColorScale ApiDatasetRepository::loadColorScale(const std::string& dsId) {
|
api_.getAsync(QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId))),
|
||||||
return colorScale(api_, dsId, 2);
|
};
|
||||||
}
|
auto* batch = new net::ApiBatch(calls, &isFailure);
|
||||||
geopro::core::ColorScale ApiDatasetRepository::loadScatterColorScale(const std::string& dsId) {
|
return new ApiGridLoad(batch, [](const QList<net::ApiResponse>& r) {
|
||||||
return colorScale(api_, dsId, 1);
|
GridParts p;
|
||||||
}
|
p.grid = dto::parseInversionGrid(r[0].data);
|
||||||
std::vector<geopro::core::Anomaly> ApiDatasetRepository::loadAnomalies(const std::string& dsId) {
|
p.gridScale = dto::parseColorBar(r[1].data);
|
||||||
const net::ApiResponse r = api_.get(
|
p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray());
|
||||||
QStringLiteral("/business/exception/queryException/%1").arg(enc(dsId)));
|
return p;
|
||||||
must(r, "queryException");
|
});
|
||||||
return dto::parseDatasetAnomalies(r.data.value(QStringLiteral("value")).toArray());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace geopro::data
|
} // namespace geopro::data
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "repo/IDatasetRepository.hpp"
|
#include "repo/IAsyncDatasetRepository.hpp"
|
||||||
namespace geopro::net { class ApiClient; }
|
namespace geopro::net { class ApiClient; }
|
||||||
namespace geopro::data {
|
namespace geopro::data {
|
||||||
|
|
||||||
// 真实 API 实现 IDatasetRepository(ERT 反演相关)。失败抛 std::runtime_error。
|
// 真实 API 实现 IAsyncDatasetRepository(ERT 反演)。每次加载返回自管理句柄。
|
||||||
class ApiDatasetRepository : public IDatasetRepository {
|
class ApiDatasetRepository : public IAsyncDatasetRepository {
|
||||||
public:
|
public:
|
||||||
explicit ApiDatasetRepository(net::ApiClient& api);
|
explicit ApiDatasetRepository(net::ApiClient& api);
|
||||||
std::vector<GsNode> loadStructure() override { return {}; } // 不经此仓储取结构
|
ChartLoad* loadChartAsync(const std::string& dsId) override;
|
||||||
geopro::core::Grid loadGrid(const std::string& dsId) override;
|
GridLoad* loadGridAsync(const std::string& dsId) override;
|
||||||
geopro::core::ScatterField loadScatter(const std::string& dsId) override;
|
|
||||||
geopro::core::ColorScale loadColorScale(const std::string& dsId) override; // type2 网格
|
|
||||||
geopro::core::ColorScale loadScatterColorScale(const std::string& dsId) override; // type1 原数据
|
|
||||||
std::vector<geopro::core::Anomaly> loadAnomalies(const std::string& dsId) override;
|
|
||||||
private:
|
private:
|
||||||
net::ApiClient& api_;
|
net::ApiClient& api_;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
#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
|
||||||
|
ChartParts parts;
|
||||||
|
try {
|
||||||
|
parts = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
emit failed(QString::fromUtf8(e.what()));
|
||||||
|
deleteLater();
|
||||||
|
return;
|
||||||
|
} catch (...) { // 非 std 异常跨信号槽会 terminate,兜底转 failed
|
||||||
|
emit failed(QStringLiteral("解析失败:未知异常"));
|
||||||
|
deleteLater();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit done(parts);
|
||||||
|
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;
|
||||||
|
GridParts parts;
|
||||||
|
try {
|
||||||
|
parts = parse_(resps); // 仅解析在 try 内:下游 done 处理器抛出不应误报为解析失败
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
emit failed(QString::fromUtf8(e.what()));
|
||||||
|
deleteLater();
|
||||||
|
return;
|
||||||
|
} catch (...) {
|
||||||
|
emit failed(QStringLiteral("解析失败:未知异常"));
|
||||||
|
deleteLater();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit done(parts);
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
#include "ApiBatch.hpp"
|
||||||
|
|
||||||
|
namespace geopro::net {
|
||||||
|
|
||||||
|
ApiBatch::ApiBatch(QList<IApiCall*> calls, Predicate isFailure, QObject* parent)
|
||||||
|
: QObject(parent), isFailure_(std::move(isFailure)) {
|
||||||
|
Q_ASSERT(!calls.isEmpty()); // 契约:至少一个 call(空 batch 永不发 succeeded,调用方会挂起)
|
||||||
|
Q_ASSERT(isFailure_); // 契约:必须提供失败谓词(否则首个 finished 即 bad_function_call)
|
||||||
|
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; // 此后 remaining_ 不再维护:迟到 finished 全被入口守卫挡掉
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
#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
|
||||||
|
|
@ -6,13 +6,16 @@
|
||||||
namespace geopro::net {
|
namespace geopro::net {
|
||||||
|
|
||||||
ApiCall::ApiCall(QNetworkReply* reply, QObject* parent) : IApiCall(parent), reply_(reply) {
|
ApiCall::ApiCall(QNetworkReply* reply, QObject* parent) : IApiCall(parent), reply_(reply) {
|
||||||
|
Q_ASSERT(reply); // 契约:不接受 null reply(NAM get/post 正常不返回 null)
|
||||||
QObject::connect(reply_, &QNetworkReply::finished, this, &ApiCall::onFinished);
|
QObject::connect(reply_, &QNetworkReply::finished, this, &ApiCall::onFinished);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ApiCall::onFinished() {
|
void ApiCall::onFinished() {
|
||||||
if (aborted_) return; // §5.0 入口守卫:迟到信号闸门
|
if (aborted_) return; // §5.0 入口守卫:迟到信号闸门
|
||||||
ApiResponse resp = buildResponse(reply_);
|
QNetworkReply* reply = reply_.data(); // 快照:意图明确 + 防御 reply_ 中途被置空
|
||||||
if (reply_) reply_->deleteLater();
|
if (!reply) return;
|
||||||
|
ApiResponse resp = buildResponse(reply);
|
||||||
|
reply->deleteLater();
|
||||||
emit finished(resp);
|
emit finished(resp);
|
||||||
deleteLater();
|
deleteLater();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ add_library(geopro_net STATIC
|
||||||
ApiResponseParse.cpp
|
ApiResponseParse.cpp
|
||||||
IApiCall.cpp
|
IApiCall.cpp
|
||||||
ApiCall.cpp
|
ApiCall.cpp
|
||||||
|
ApiBatch.cpp
|
||||||
AuthService.cpp)
|
AuthService.cpp)
|
||||||
target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network)
|
target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network)
|
||||||
|
|
|
||||||
|
|
@ -38,16 +38,19 @@ target_sources(geopro_tests PRIVATE data/test_parsers.cpp)
|
||||||
target_sources(geopro_tests PRIVATE data/test_local_repo.cpp)
|
target_sources(geopro_tests PRIVATE data/test_local_repo.cpp)
|
||||||
target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp)
|
target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp)
|
||||||
target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp)
|
target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp)
|
||||||
|
target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp)
|
||||||
target_link_libraries(geopro_tests PRIVATE geopro_data)
|
target_link_libraries(geopro_tests PRIVATE geopro_data)
|
||||||
|
|
||||||
# net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package
|
# net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package
|
||||||
# 并链接 OpenSSL(geopro_net 的 PUBLIC 链接通常已传递,这里显式以防头文件找不到)。
|
# 并链接 OpenSSL(geopro_net 的 PUBLIC 链接通常已传递,这里显式以防头文件找不到)。
|
||||||
find_package(OpenSSL REQUIRED)
|
find_package(OpenSSL REQUIRED)
|
||||||
find_package(Qt6 COMPONENTS Core Network REQUIRED)
|
find_package(Qt6 COMPONENTS Core Network Test REQUIRED)
|
||||||
target_sources(geopro_tests PRIVATE net/test_rsa.cpp)
|
target_sources(geopro_tests PRIVATE net/test_rsa.cpp)
|
||||||
# 端到端登录连通测试(ApiClient + AuthService),需 Qt6::Core/Network 与事件循环。
|
# 端到端登录连通测试(ApiClient + AuthService),需 Qt6::Core/Network 与事件循环。
|
||||||
target_sources(geopro_tests PRIVATE net/test_auth.cpp)
|
target_sources(geopro_tests PRIVATE net/test_auth.cpp)
|
||||||
target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network)
|
# ApiBatch 离线单测(QSignalSpy 需 Qt6::Test)。
|
||||||
|
target_sources(geopro_tests PRIVATE net/test_api_batch.cpp)
|
||||||
|
target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test)
|
||||||
|
|
||||||
# geopro_data 链 Qt6::Core,测试 exe 运行(含 gtest 发现)需要 Qt6Core.dll 等运行时
|
# geopro_data 链 Qt6::Core,测试 exe 运行(含 gtest 发现)需要 Qt6Core.dll 等运行时
|
||||||
# DLL 在旁。复用 app 同样的 TARGET_RUNTIME_DLLS POST_BUILD 拷贝。
|
# DLL 在旁。复用 app 同样的 TARGET_RUNTIME_DLLS POST_BUILD 拷贝。
|
||||||
|
|
@ -87,6 +90,8 @@ if(TARGET qwt)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app)
|
target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/src/app)
|
||||||
|
# tests/ 在 include 路径:net/test_api_batch.cpp 用 #include "net/FakeApiCall.hpp"。
|
||||||
|
target_include_directories(geopro_tests PRIVATE ${CMAKE_SOURCE_DIR}/tests)
|
||||||
target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp)
|
target_sources(geopro_tests PRIVATE app/test_chart_strategy_registry.cpp)
|
||||||
# ColorMapService 测试(geopro_desktop 是可执行文件,直接把源加入测试目标)
|
# ColorMapService 测试(geopro_desktop 是可执行文件,直接把源加入测试目标)
|
||||||
target_sources(geopro_tests PRIVATE
|
target_sources(geopro_tests PRIVATE
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,115 @@
|
||||||
#include <gtest/gtest.h>
|
#include <gtest/gtest.h>
|
||||||
#include <QSignalSpy>
|
#include <QSignalSpy>
|
||||||
#include "DatasetDetailController.hpp"
|
#include "DatasetDetailController.hpp"
|
||||||
#include "repo/IDatasetRepository.hpp"
|
#include "repo/IAsyncDatasetRepository.hpp"
|
||||||
|
#include "api/DatasetLoadHandles.hpp"
|
||||||
using namespace geopro;
|
using namespace geopro;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
struct StubRepo : data::IDatasetRepository {
|
// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。
|
||||||
bool fail = false;
|
struct StubChartLoad : data::ChartLoad {
|
||||||
std::vector<data::GsNode> loadStructure() override { return {}; }
|
bool aborted = false;
|
||||||
core::Grid loadGrid(const std::string&) override { core::Grid g(2,2); g.x={0,1}; g.y={0,1}; return g; }
|
void abort() override { aborted = true; }
|
||||||
// openDataset 现只拉 scatter/scatterScale/anomalies(网格懒加载),失败路径由 loadScatter 抛出触发。
|
void fireDone() { emit done(data::ChartParts{}); }
|
||||||
core::ScatterField loadScatter(const std::string&) override { if (fail) throw std::runtime_error("x"); return {}; }
|
void fireFailed() { emit failed(QStringLiteral("x")); }
|
||||||
core::ColorScale loadColorScale(const std::string&) override { return {}; }
|
};
|
||||||
core::ColorScale loadScatterColorScale(const std::string&) override { return {}; }
|
struct StubGridLoad : data::GridLoad {
|
||||||
std::vector<core::Anomaly> loadAnomalies(const std::string&) override { return {}; }
|
bool aborted = false;
|
||||||
|
void abort() override { aborted = true; }
|
||||||
|
void fireDone() { emit done(data::GridParts{}); }
|
||||||
|
void fireFailed() { emit failed(QStringLiteral("x")); }
|
||||||
|
};
|
||||||
|
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, EmitsChartReadyOnSuccess) {
|
|
||||||
StubRepo repo;
|
TEST(DatasetDetailController, EmitsChartReadyOnDone) {
|
||||||
|
StubAsyncRepo repo;
|
||||||
controller::DatasetDetailController c(repo);
|
controller::DatasetDetailController c(repo);
|
||||||
QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady);
|
QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady);
|
||||||
c.openDataset("ds1", "dd_inversion_data");
|
c.openDataset("ds1", "dd_inversion_data");
|
||||||
|
repo.lastChart->fireDone();
|
||||||
EXPECT_EQ(spy.count(), 1);
|
EXPECT_EQ(spy.count(), 1);
|
||||||
}
|
}
|
||||||
TEST(DatasetDetailController, EmitsLoadFailedOnThrow) {
|
|
||||||
StubRepo repo; repo.fail = true;
|
TEST(DatasetDetailController, EmitsLoadFailedOnFailed) {
|
||||||
|
StubAsyncRepo repo;
|
||||||
controller::DatasetDetailController c(repo);
|
controller::DatasetDetailController c(repo);
|
||||||
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
|
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
|
||||||
c.openDataset("ds1", "dd_inversion_data");
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DatasetDetailController, EmitsGridReadyOnDone) {
|
||||||
|
StubAsyncRepo repo;
|
||||||
|
controller::DatasetDetailController c(repo);
|
||||||
|
QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady);
|
||||||
|
c.loadGridData("ds1", "dd_inversion_data");
|
||||||
|
repo.lastGrid->fireDone();
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DatasetDetailController, EmitsLoadFailedOnGridFailed) {
|
||||||
|
StubAsyncRepo repo;
|
||||||
|
controller::DatasetDetailController c(repo);
|
||||||
|
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
|
||||||
|
c.loadGridData("ds1", "dd_inversion_data");
|
||||||
|
repo.lastGrid->fireFailed();
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DatasetDetailController, DropsLateGridSignalFromAbortedLoad) {
|
||||||
|
StubAsyncRepo repo;
|
||||||
|
controller::DatasetDetailController c(repo);
|
||||||
|
QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady);
|
||||||
|
c.loadGridData("dsA", "dd_inversion_data");
|
||||||
|
StubGridLoad* a = repo.lastGrid;
|
||||||
|
c.loadGridData("dsB", "dd_inversion_data"); // 替换 → 旧句柄被 abort
|
||||||
|
StubGridLoad* b = repo.lastGrid;
|
||||||
|
EXPECT_TRUE(a->aborted);
|
||||||
|
a->fireDone(); // 旧句柄迟到 → 身份比对丢弃
|
||||||
|
EXPECT_EQ(spy.count(), 0);
|
||||||
|
b->fireDone(); // 当前句柄 → 正常
|
||||||
EXPECT_EQ(spy.count(), 1);
|
EXPECT_EQ(spy.count(), 1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
#include <stdexcept>
|
||||||
|
#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, ChartLoadEmitsFailedWhenParseThrows) {
|
||||||
|
auto* a = new FakeApiCall;
|
||||||
|
auto* batch = new ApiBatch({a}, isFailure);
|
||||||
|
auto* load = new ApiChartLoad(batch, [](const QList<ApiResponse>&) -> ChartParts {
|
||||||
|
throw std::runtime_error("parse boom");
|
||||||
|
});
|
||||||
|
QSignalSpy doneSpy(load, &ChartLoad::done);
|
||||||
|
QSignalSpy failSpy(load, &ChartLoad::failed);
|
||||||
|
a->fire(ok()); // batch 成功 → parse 抛异常 → failed(emit done 已移出 try)
|
||||||
|
EXPECT_EQ(doneSpy.count(), 0);
|
||||||
|
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_ 双闸门
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
#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
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
#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, SucceedsWithSingleCall) {
|
||||||
|
auto* a = new FakeApiCall;
|
||||||
|
auto* batch = new ApiBatch({a}, isFailure);
|
||||||
|
QSignalSpy okSpy(batch, &ApiBatch::succeeded);
|
||||||
|
a->fire(ok()); // N=1:一次 fire 即触发 --remaining_==0
|
||||||
|
EXPECT_EQ(okSpy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue