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

282 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 设计ApiClient 异步化DatasetDetail 路径试点)
> 日期 2026-06-11。范围把数据集详情路径从同步阻塞改为异步非阻塞作为全 App 异步化的模式样板。
> 后续会按此模式铺开到导航/登录路径(本期不做)。
## 1. 背景
geopro 现网络栈三层全同步阻塞:
- **`ApiClient`**`src/net/`):单个 `QNetworkAccessManager``get/postJson` 内部用 `QNetworkReply + QEventLoop` 死等响应。共享 cookie / JSESSIONID。
- **Repository**`ApiDatasetRepository`、`ApiProjectRepository`):同步调 `api_.get/postJson`,解析后返回 typed 值或抛 `std::runtime_error`
- **Controller**`DatasetDetailController`、`WorkbenchNavController`):在 slot 内同步调 repo`emit` 结果信号。
**核心问题**:每次请求都用 `QEventLoop` 阻塞 UI 线程 → 全 App 冻结。其中数据集详情「网格数据」页签的 `dd/ert/inversion/rows/{id}` 请求因**服务端网格化波动 14s**,期间界面完全卡死。
`QEventLoop` 阻塞的副作用是**可重入**:阻塞期间 Qt 仍泵事件,用户的双击/切换会重入 slot因此现有控制器到处用 `busy_` 守卫挡重入——这些守卫本身就是同步阻塞架构的产物。
`QNetworkAccessManager` 本就是异步设计;当前架构用 `QEventLoop` 把它人为变同步,是反模式。本设计回归 Qt 原生异步。
## 2. 目标与非目标
### 目标
1. 数据集详情路径(原数据 + 网格数据)全程**不阻塞 UI 线程**。
2. 慢请求在飞时切换/关闭数据集页签可**主动 abort 过期请求**,结果不回灌。
3. 同一逻辑加载内的多个独立请求**并发**发出(原数据 2 个、网格 3 个),缩短总等待。
4. 加载期间给用户**轻量"加载中"反馈**(不再因冻结而"看起来在响应")。
5. 产出**可复用的异步原语**`ApiCall` / `ApiBatch` / 句柄模式),供后续导航/登录路径照搬。
6. 现有测试不退化;新增异步路径的单测可离线运行。
### 非目标(本期不做)
- **不**改 `WorkbenchNavController` / `ApiProjectRepository` / `AuthService` / 登录流程——它们继续走同步 `ApiClient.get/postJson`
- **不**做后台工作线程:网络是 I/O 密集,`QNetworkAccessManager` 原生异步即可,不引入 `QThread`/`QtConcurrent`。
- **不**追求与原版 loading 动画像素级一致(轻量提示即可,细抠后续单独做)。
- **不**改 `LocalSampleRepository`(它实现旧同步 `IDatasetRepository`,但不经详情控制器使用)。
## 3. 已定决策(及理由)
| 决策 | 选择 | 理由 |
|---|---|---|
| 范围 | 仅 DatasetDetail 路径 | 风险小、最痛点14s rows先解决、产出可复用模式 |
| 取消语义 | 主动 abort 过期请求 | 体验最好;句柄对象使 abort 成为一等公民 |
| 加载反馈 | 轻量"加载中"遮罩 + 禁用交互 | 不阻塞后必须给反馈;先跑通机制 |
| 异步机制 | 方案 A每请求一个 QObject 句柄(信号 + abort | 纯 Qt 信号、贴合现有信号驱动代码库abort 用具体句柄最干净QFuture 的 cancel 不中断底层 reply反而要再加句柄测试用 QSignalSpy 顺手 |
| 多请求汇聚 | **fail-fast with abort**(任一项失败立即回报并 abort 其余在飞 call | 慢的 rows 14s若 colorScale 200ms 先失败gather-all 会让用户干等 14s 才看到失败(失败比成功还慢,违背"及时反馈"。fail-fast 依赖下方「abort 闸门」机制杜绝迟到回灌,原"避免部分-abort 竞态"的顾虑已被该机制消解。成功路径仍需全部到齐 |
| **abort 闸门(核心安全机制)** | 链路级 `aborted_` 标志 + 句柄身份比对 | 见 §5.1/§5.3:仅靠 disconnect 挡不住「已 emit、正在事件队列等待派发」的迟到信号必须每层入口判 `aborted_` + 控制器比对句柄身份,才能兑现"abort 后绝不回灌"的核心承诺 |
## 4. 架构
```
DatasetDetailController (QObject状态/编排)
│ 调 repo.loadChartAsync(id) / loadGridAsync(id),持有返回的 *Load 句柄
│ 切换/关页 → 句柄.abort()
IAsyncDatasetRepository纯抽象 ←── ApiDatasetRepository 实现
│ 每次 loadXxxAsync 创建一个 *Load 句柄,内部用 ApiBatch 并发 N 个 ApiCall
│ batch 全部完成 → 逐项校验+解析 → emit done(Parts) / failed(msg)
ApiClient.getAsync(path) → ApiCall包一个 QNetworkReply实现 IApiCall
ApiClient 保留同步 get/postJson 供 Nav/登录继续使用,共享同一 QNetworkAccessManager
```
**信号面保持不变**`DatasetDetailController` 对外仍发 `chartReady(ChartData)` / `gridReady(GridData)` / `loadFailed(dsId,msg)` / `focusRequested(dsId)`,新增 `loadStarted(dsId, Phase)`。因此 `main.cpp` 接线与 `DatasetDetailPanel` 几乎不动,仅接 `loadStarted` 显示遮罩。
## 5. 组件设计
### 5.0 核心安全不变量abort 后绝不回灌(贯穿全链路)
> 这是本设计成败的关键。`disconnect` 只能阻止「将来才发」的信号,**挡不住「已经 emit、已转成 `QMetaCallEvent` 在事件队列中等待派发」的迟到信号**(尤其在共享 NAM 同步嵌套 `QEventLoop` 期间,见 5.1)。因此 disconnect 仅作"尽力而为",真正的权威闸门是下面两条:
1. **每层 `aborted_` 标志 + 入口守卫**`ApiCall` / `ApiBatch` / `ChartLoad` / `GridLoad` 各持一个 `bool aborted_`。所有 `finished`/`done`/`failed` 槽**入口第一行** `if (aborted_) return;`。`abort()` 先置 `aborted_=true` 再断连/中断。聚合 emit 前同样判 `aborted_`
2. **控制器句柄身份比对**`DatasetDetailController` 在 `done`/`failed` 槽内校验「发信号的句柄 == 当前持有的 `chartLoad_`/`gridLoad_`」(用 `sender()` 或 lambda 捕获句柄指针比对),过期句柄的迟到信号直接丢弃。与 abort-and-replace 形成纵深防御。
3. **销毁一律 `deleteLater`**`ApiCall`/`ApiBatch`/`*Load`/`QNetworkReply` 在 abort 或完成后**一律 `deleteLater`,禁止任何同步 `delete`**——避免「abort 一个正在自己回调栈中的对象」导致 use-after-free。
### 5.1 net 层
#### `IApiCall`(新增,抽象,可测试缝)
```cpp
// src/net/IApiCall.hpp
class IApiCall : public QObject {
Q_OBJECT
public:
using QObject::QObject;
virtual void abort() = 0; // 中断;不再 emit finisheddeleteLater
signals:
void finished(const geopro::net::ApiResponse& resp); // 成功/错误均经此(错误写 rawError
};
```
存在意义:`ApiBatch` 只依赖 `IApiCall`,单测可注入假 call不碰真实网络离线测 gather/abort。
#### `ApiCall`(新增,实现 `IApiCall`
-`bool aborted_=false`(见 §5.0)。
- 构造接管一个 `QNetworkReply*`;连接 `reply->finished` → 槽入口判 `if (aborted_) return;` → 解析为 `ApiResponse`(复用现 `parseBody`)→ emit `finished``deleteLater()` 自删。
- `abort()``aborted_=true` → `disconnect` 自身与 reply 的连接 → `reply->abort()``reply->deleteLater()``this->deleteLater()`**禁止同步 delete**。`reply->abort()` 本身会再触发一次 `finished`OperationCanceledError已被 disconnect + `aborted_` 双重挡掉。
-`QueuedConnection``ApiResponse``qRegisterMetaType<geopro::net::ApiResponse>()`(应用启动时注册一次)。
#### `ApiClient`(扩展,不破坏现有)
```cpp
net::IApiCall* getAsync(const QString& path); // 立即返回,不阻塞
net::IApiCall* postJsonAsync(const QString& path, const QJsonObject& body);
// 保留ApiResponse get(path); ApiResponse postJson(path, body); // Nav/登录继续用
```
- `getAsync/postJsonAsync` 用同一 `nam_` 发起,返回 `new ApiCall(reply)`。token 注入、`buildRequest` 复用。
- **已知限制(非"无害",受 §5.0 三条约束兜底)**Nav 的同步嵌套 `QEventLoop``ApiClient::get` 的 `loop.exec()`)期间,详情的异步 reply 若完成会被一并泵出其整条槽链ApiCall→ApiBatch→*Load→Controller→`chartReady`→`detailPanel->openOrUpdate`)会**在 Nav 同步调用栈、嵌套循环里重入执行**,即详情 UI 重建可能发生在 Nav 网络调用返回前。这不是"互不写状态"能涵盖的,但有 §5.0 兜底:(a) 一律 `deleteLater` 杜绝栈内同步释放;(b) 各层 `aborted_` 入口守卫;(c) 控制器句柄身份比对。本期 Nav 仍同步、窗口期有限;**全异步后该同步路径与本限制一并消失**。可选缓解YAGNI暂不做详情完成回调改 `QueuedConnection` 投递,避免在嵌套栈内驱动 UI。
#### `ApiBatch`新增汇聚原语fail-fast
```cpp
// src/net/ApiBatch.hpp
class ApiBatch : public QObject {
Q_OBJECT
public:
// calls 接管所有权predicate 由 repo 注入,判定单个 ApiResponse 是否业务失败
ApiBatch(QList<net::IApiCall*> calls,
std::function<bool(const net::ApiResponse&)> isFailure,
QObject* parent=nullptr);
void abort(); // aborted_=trueabort 全部未完成子 callself deleteLater不 emit
signals:
void succeeded(const QList<geopro::net::ApiResponse>& responses); // 全部成功,按下标对齐
void failed(int index, const geopro::net::ApiResponse& resp); // 首个失败项
};
```
- **所有权契约**:内部用 `QList<QPointer<net::IApiCall>>` 持有子 call子 call 正常 `finished` 后自删(`QPointer` 自动置空)。`abort()` 遍历时跳过空指针。子 call parent 不设为 batch各自 `deleteLater` 自管),靠 `QPointer` + `aborted_` 防悬垂。
-`bool aborted_`§5.0)。每个子 call `finished` 槽入口判 `aborted_`
- **fail-fast**:每个子 call `finished` 到达时立即用 `isFailure(resp)` 判定——失败则 `emit failed(i, resp)` + `aborted_=true` + abort 其余在飞子 call + `deleteLater`;成功则记入 `responses_[i]`,全部到齐 → `emit succeeded(responses)` + `deleteLater`
- 业务/传输/HTTP 三类失败统一由 `isFailure` 判定(见 §7
### 5.2 data 层
#### data 层结果载体(中性,避免 data→controller 反向依赖)
```cpp
// src/data/api/DatasetLoads.hpp
struct ChartParts { core::ScatterField scatter; core::ColorScale scatterScale; };
struct GridParts { core::Grid grid{1,1}; core::ColorScale gridScale; std::vector<core::Anomaly> anomalies; };
```
#### `*Load` 句柄(每次加载一个,承载 typed 结果 + abort
```cpp
class ChartLoad : public QObject {
Q_OBJECT
public:
void abort(); // aborted_=true转发给内部 ApiBatch
signals:
void done(const geopro::data::ChartParts& parts);
void failed(const QString& message);
};
class GridLoad : public QObject { /* done(GridParts) / failed(QString) / abort() */ };
```
-`bool aborted_`§5.0);连 `ApiBatch::succeeded` 槽入口判 `aborted_` 后解析→ emit `done``deleteLater`;连 `ApiBatch::failed` → 取 `msg`(见 §7→ emit `failed``deleteLater`。`abort()` 置 `aborted_` 并转发给内部 `QPointer<ApiBatch>`
- 用句柄对象做"每次逻辑加载"的天然关联点 + abort 目标(避免请求 id 簿记)。
#### 旧同步方法去向(接口改形审计)
`ApiDatasetRepository` 不再继承 `IDatasetRepository`。旧 6 个同步方法去向:
| 旧方法 | 去向 |
|---|---|
| `loadStructure()` | **丢弃**(详情路径不用;现实现即返回 `{}` 占位;结构树走 `LocalSampleRepository`/Nav |
| `loadScatter` + `loadScatterColorScale` | 合并进 `loadChartAsync` |
| `loadGrid` + `loadColorScale` + `loadAnomalies` | 合并进 `loadGridAsync` |
`LocalSampleRepository` **继续实现同步 `IDatasetRepository`**,二接口并存;`buildWorkbench` 的 `repo` 形参类型不变。已核对:无任何处把 `ApiDatasetRepository``IDatasetRepository*` 用于 `loadStructure`,丢弃安全。
#### `IAsyncDatasetRepository`(新增抽象,详情控制器依赖它)
```cpp
// src/data/repo/IAsyncDatasetRepository.hpp
class IAsyncDatasetRepository {
public:
virtual ~IAsyncDatasetRepository() = default;
virtual data::ChartLoad* loadChartAsync(const std::string& dsId) = 0; // scatter + scatterScale(type1)
virtual data::GridLoad* loadGridAsync(const std::string& dsId) = 0; // grid(rows) + scale(type2) + anomalies
};
```
- 复合方法(而非旧 6 个原子 `loadXxx`):原子方法把"该发哪几个请求"泄漏给了控制器;汇聚+abort 是核心复杂度,集中放 repo它本就知道请求集是正确归属控制器保持轻薄。
#### `ApiDatasetRepository`(改造)
- 不再继承同步 `IDatasetRepository`;改实现 `IAsyncDatasetRepository`
- `loadChartAsync`:建 `ApiBatch{ getScatter, getScatterScale }`,注入 `isFailure`(见 §7`succeeded` 时复用现有 DTO 解析(`DatasetChartDto` 等)填 `ChartParts``done``failed` → 取错误原因并 `failed`
- `loadGridAsync``ApiBatch{ getRows, getColorScale, getException }` → 同构填 `GridParts`
- 现有同步解析/校验逻辑DTO、v 行数校验、markType 钳制)与 `must()` 的判定(`code==200`**原样搬入**,不重写——`must()` 的判定提炼为 batch 的 `isFailure` 谓词。
- 空数据非失败:异常列表可能为空——业务 `code==200` 且 data 为空走成功(空 `GridParts.anomalies`),与现状一致。
### 5.3 controller 层
#### `DatasetDetailController`(改造)
```cpp
enum class Phase { Chart, Grid };
signals:
void loadStarted(const QString& dsId, Phase phase); // 新增:驱动加载遮罩
// 不变chartReady(ChartData)/gridReady(GridData)/loadFailed(dsId,msg)/focusRequested(dsId)
private:
IAsyncDatasetRepository& repo_;
QPointer<data::ChartLoad> chartLoad_; // 当前在飞QPointer 防悬垂)
QPointer<data::GridLoad> gridLoad_;
```
- `openDataset(dsId, ddCode)`ddCode 非 `dd_inversion_data``loadFailed`;否则 **若 `chartLoad_` 在飞先 `abort()`**`repo_.loadChartAsync` 取新句柄存入 `chartLoad_`emit `loadStarted(dsId, Chart)`,连:
- `done`**句柄身份比对**(捕获的句柄指针 == 当前 `chartLoad_`?否则丢弃迟到信号,见 §5.0)→ 组 `ChartData``chartReady` → 清空 `chartLoad_`
- `failed` → 同样身份比对 → `loadFailed` → 清空 `chartLoad_`
- `loadGridData(dsId, ddCode)`:同构,针对 `gridLoad_``gridReady`
- **移除 `busy_` 守卫**:由 abort-and-replace + 句柄身份比对取代新请求来→abort 旧→发新;旧句柄的迟到信号被身份比对丢弃)。
- 句柄完成/失败后清空对应 `QPointer`。**控制器析构时 abort 所有在飞句柄**(见 §7 退出契约)。
- 现有 `catch(...)` 兜底不再需要(无同步抛出路径);错误统一经 `failed` 信号传递。
### 5.4 UI 层(加载反馈)
- 新增 `LoadingOverlay``QWidget`,半透明 + "加载中…" + 可选 busy 指示),可贴在任一视图区上层。
- `DatasetDetailPanel`
-`loadStarted(dsId, Grid)` → 在对应 ds 页的网格视图上显示遮罩 + 禁用交互;`gridReady`/`loadFailed` → 隐藏。
- 原数据初次加载(~0.8s):页面仍于 `chartReady` 创建;加载期用 busy 光标 + 状态栏"加载中…"(最小改动)。网格遮罩是高价值项(页已存在、等待 14s
- 像素级对齐原版 loading 留后续。
## 6. 数据流(时序)
**原数据(双击数据集)**
1. `detailCtrl.openDataset(id, "dd_inversion_data")` → emit `loadStarted(id,Chart)`UI 置 busy
2. abort 旧 `chartLoad_`(若有)→ `repo.loadChartAsync(id)` 返回 `ChartLoad*`
3. repo 内 `ApiBatch{getScatter, getScatterScale}` 并发UI 线程**保持响应**。
4. 两 reply 全成功到齐 → `batch.succeeded` → repo 解析 → `ChartLoad::done(ChartParts)`
5. controller 身份比对通过 → 组 `ChartData``chartReady``detailPanel->openOrUpdate`(清 busy
- **任一项失败fail-fast**:首个失败 → `batch.failed` + abort 其余在飞 → `ChartLoad::failed``loadFailed` → 状态栏提示(清 busy不等其余请求。
**网格数据(首次切到网格页签)**
1. `gridDataNeeded``loadGridData(id, ...)` → emit `loadStarted(id,Grid)`(网格视图遮罩)。
2. abort 旧 `gridLoad_``repo.loadGridAsync(id)`
3. `ApiBatch{getRows(慢14s), getColorScale, getException}` 并发。
4. 全成功到齐 → 解析 → `done(GridParts)``gridReady``setGridData`(隐遮罩)。若 colorScale/exception 先失败 → fail-fast 立即 `failed` + abort rows不干等 14s。
5. 期间用户切走/关页 → controller abort `gridLoad_` → 子 reply 全 abort + `aborted_` 闸门 → 即便有迟到信号也被丢弃,无回灌。
## 7. 错误处理与边界
- **失败判定三要素(`isFailure` 谓词,由现 `must()` 提炼)**:一个 `ApiResponse` 视为失败当:(1) 传输错误 `!rawError.isEmpty()`;或 (2) HTTP 异常(必要时查 `httpStatus`);或 (3) **业务码 `code != 200`**(服务端常返回 HTTP 200 但 `code=500`,这是现 `must()` 的判定口径,**不能只看 httpStatus**)。失败原因取 `msg`(空则 `rawError`)。
- **abort 竞态(核心,见 §5.0**:单靠 disconnect 不足以挡「已入队的迟到信号」。权威闸门 = 各层 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 `deleteLater`。`ApiCall::abort` 置 `aborted_` 后 disconnect+`reply->abort()`。
- **重复触发**abort-and-replace + 身份比对天然幂等(新覆旧,旧迟到信号丢弃)。
- **控制器析构(退出契约)**abort 所有在飞句柄。`ApiCall`/`ApiBatch`/`*Load` **不得在析构或 `deleteLater` 回调里访问 `nam`/`reply`**reply 在 abort 时已 `deleteLater`。main.cpp 栈对象析构逆序为 `detailCtrl``datasetRepo``api`,确保控制器先 abort、ApiClient 最后毁;句柄不持有对 `nam` 的长期引用。
- **共享 NAM 同步/异步并存**:见 §5.1**已知限制**(非"无害"),受 §5.0 三约束兜底,全异步后消除。
- **空/缺数据**:异常列表可能为空——业务 `code==200``done`(空),非 `failed`,与现状一致。
## 8. 测试策略
- **`ApiBatch`(离线单测)**:注入假 `IApiCall`(受测可控 emit `finished`/记录 `abort`)。
- 全成功 → `succeeded` 一次、下标对齐。
- **fail-fast**:某子 call 先返回失败 → `failed(i,resp)` 一次 + 其余在飞子 call 被 `abort`(且不再等待)。
- **abort 闸门**batch `abort()` 后,未完成子 call 均被 abort之后即便假 call 延迟 emit `finished`batch 也不 emit`aborted_` 守卫)。
- **`DatasetDetailController`离线单测QSignalSpy + 事件循环)**:用 `StubAsyncRepo` 返回假 `*Load`,可控 emit `done`/`failed`(即时或 `QMetaObject::invokeMethod(..., QueuedConnection)` 模拟迟到)。异步桩非即时返回,断言需 `spy.wait()` / `QTest::qWait` spin 事件循环。
- `done` → 一次 `chartReady`/`gridReady``failed` → 一次 `loadFailed`;非 `dd_inversion_data` → 直接 `loadFailed`
- 在飞时再 `openDataset` → 旧句柄被 `abort`stub 记录)。
- **回灌防护(回归原 bug 的核心用例)**:① 句柄 abort 后延迟 emit `done` → 控制器**零** `chartReady`(身份比对 + aborted_ 生效);② `openDataset(A)` 在飞 → `openDataset(B)` → A 句柄迟到 emit `done`**仅 B 的数据被 `chartReady`A 丢弃**
- **`ApiCall`/`getAsync`(可选 live test**:依现有 `AuthLiveTest` 先例,标记为 live不计入离线覆盖门槛。
- 现有 75 个测试中,`DatasetDetailController.*` 两个改为异步桩版本(加事件循环 spin其余不动。`LocalRepo.*` 不受影响(同步接口保留)。
- 目标异步新增逻辑batch/controller离线单测覆盖 ≥ 80%。
## 9. 迁移步骤(供 writing-plans 细化)
1. net`IApiCall` + `ApiCall` + `ApiClient::getAsync/postJsonAsync`(保留同步)。`parseBody` 抽为可复用。
2. net`ApiBatch` + 离线单测。
3. data`DatasetLoads.hpp`ChartParts/GridParts+ `ChartLoad`/`GridLoad` + `IAsyncDatasetRepository`
4. data`ApiDatasetRepository` 改实现异步接口(搬入现有 DTO 解析)。
5. controller`DatasetDetailController` 改异步 + abort-and-replace + `loadStarted`;改其单测。
6. `main.cpp` 装配换 `IAsyncDatasetRepository` + 启动注册 `qRegisterMetaType<ApiResponse>()`
7. UI`LoadingOverlay` + `DatasetDetailPanel``loadStarted`。**此步与异步内核无依赖,可在步骤 16 通过测试后再做/并行**,以减小试点 PR 体积surgical
8. 全量构建 + 测试dev-build / dev-test手动验证切换/关页 abort、网格遮罩、并发加载、UI 不冻、失败 fail-fast 即时报错。
## 10. 风险
| 风险 | 缓解 |
|---|---|
| **已入队的迟到信号回灌(最危险)** | §5.0 闸门:各层 `aborted_` 入口守卫 + 控制器句柄身份比对 + 一律 `deleteLater`§8 专项回归用例 |
| abort 后回调悬垂/二次释放 | `QPointer` + abort 先置 `aborted_`+disconnect + **禁止同步 delete仅 `deleteLater`** |
| 句柄/batch 生命周期泄漏 | 完成或 abort 后统一 `deleteLater``ApiBatch` 用 `QPointer` 持子 call |
| 同步/异步共用 NAM 嵌套重入 | **已知限制**非无害§5.0 三约束兜底;全异步后消除 |
| 退出期 UAF句柄 deleteLater 晚于 ApiClient 析构) | 退出契约:句柄不在析构/回调访问 nam/reply栈析构逆序保证 ctrl→repo→api |
| 失败比成功慢gather-all 缺陷) | 改 fail-fast + abort 其余在飞 call |
| DTO 解析逻辑搬迁出错 | 原样搬入、不重写;`must()`→`isFailure` 谓词;保留现有解析单测 |
| 接口改形(原子→复合)波及面 | 仅详情控制器消费该接口§5.2 旧方法去向表;`LocalSampleRepository` 不碰 |
## 11. 相关文件
- 改:`src/net/ApiClient.{hpp,cpp}`、`src/data/api/ApiDatasetRepository.{hpp,cpp}`、`src/controller/DatasetDetailController.{hpp,cpp}`、`src/app/main.cpp`、`src/app/panels/.../DatasetDetailPanel.*`、`tests/controller/test_dataset_detail_controller.cpp`、各 `CMakeLists.txt`
- 新:`src/net/IApiCall.hpp`、`src/net/ApiCall.{hpp,cpp}`、`src/net/ApiBatch.{hpp,cpp}`、`src/data/api/DatasetLoads.hpp`、`src/data/api/DatasetLoadHandles.{hpp,cpp}`(ChartLoad/GridLoad)、`src/data/repo/IAsyncDatasetRepository.hpp`、`src/app/.../LoadingOverlay.{hpp,cpp}`、`tests/net/test_api_batch.cpp`。
- 不动:`src/data/repo/IDatasetRepository.hpp`(同步,留给 LocalSampleRepository、Nav/Auth/Project 路径。
</content>
</invoke>