Compare commits

...

17 Commits

Author SHA1 Message Date
gaozheng 10072eb4b3 feat(dataset-detail+app): 数据集树/按根分页 + 暗色主题保真 + 详情图保真 + 桌面日志崩溃捕获
本分支累积的数据集详情与桌面端健壮性工作(多轮迭代,已逐项实测/用户验收),一次性提交。

数据集列表树化 + 按根分页:
- 原版数据列表为 el-table 树(派生数据按 sourceShowParentId 挂源「原始数据」下);
  DsRow 加 parentId,parseDsRows 解析 sourceShowParentId(回退 parentId),
  DatasetListPanel 由 QListWidget 改建 QTreeWidget(卡片委托泛化到 QAbstractItemView)。
- 后端 data/page 按扁平 DS 分页 → 改为客户端按「第一层节点(根)」分页:
  loadRowsAsync 加 pageSize,selectObject 一次取全,新增 emitNextDataRootPage 按根切页(5根/页),
  loadMoreData 改同步切页;main.cpp 加载更多计数改按根。

暗色主题保真(浅色保持与原版 1:1,仅暗色改 token):
- 新增 ChartTheme::applyChartPlotTheme:按 isDarkTheme() 设 QwtPlot 画布/轴字/网格/零线配色,连 ThemeManager::changed 热切换。
- ColorBarWidget、LoadingOverlay 底色/蒙板/文字同步跟随主题。

详情图渲染保真:
- colorBar alpha 标度修复(混合 hex+rgba 格式,rgba alpha 为 0–1,Bit255→Unit)。
- 散点 cauto 归一化(ColorMapService.setDataRange 解耦色阶形状与数据归一化)。
- 散点 hover 提示(ScatterHoverTip,X/Y/值 3 位小数,canvas mouseTracking)。
- 详情页签用数据名命名。

桌面端日志 + 崩溃捕获:
- Logging:滚动日志 + MiniDump 崩溃捕获 + VEH 抛点符号化;main.cpp GuardedApplication::notify 顶层异常护栏。
- 根 CMakeLists Release 产出 PDB(/Zi /DEBUG);ColorMapService NaN/Inf 守卫。

测试 116→122 全绿(+ParseDsRowsParentIdForTree / DataPaginatesByRootNodeNotFlatCount / 散点/colormap 回归)。
2026-06-12 19:00:32 +08:00
gaozheng 66869a1e2e docs: 更新交接文档 — 数据集详情图表 + 全App网络层异步化(详情/导航/登录/项目列表)完成, 含架构/文件地图/下一步/工作方式备注 2026-06-12 09:44:21 +08:00
gaozheng 067852e08b docs(spec): 异步化主题完成 — 技术债清除(ProjectListDialog异步化, 删同步IProjectRepository/RepoResult/ApiClient.get|postJson), 全App网络层100%异步 2026-06-12 09:38:24 +08:00
gaozheng 5f00cdce7a refactor(net+data+app): ProjectListDialog 异步化 + 删同步 IProjectRepository/RepoResult/ApiClient.get|postJson(清除过渡技术债,全 App 网络层异步)
- ProjectListDialog 迁到 IAsyncProjectRepository:fillTypeFilter/query 改 abort-and-replace + 身份比对 + done/failed 双分支;析构 abort 在飞请求(退出契约)
- main.cpp buildWorkbench 形参改 IAsyncProjectRepository&
- ApiProjectRepository 删 public IProjectRepository 与 9 个同步方法实现;删不再用的 ok()/errorOf() helper
- 删除 src/data/repo/IProjectRepository.hpp(含 RepoResult,已无消费者)
- ApiClient 删同步 get/postJson + Impl::await + <QEventLoop>/ApiResponseParse.hpp include
2026-06-12 09:31:54 +08:00
gaozheng 93462d78ef docs(spec): 回填异步化进度 — 导航(Part A)+登录(Part B)已落地, B3/A6 删同步因 ProjectListDialog 仍同步而 BLOCKED 2026-06-12 09:13:09 +08:00
gaozheng 6b4267d78a harden(net+app): 登录句柄终态置 aborted_ + connect 用成员 QPointer + ApiChain 首步同步契约注释 + LoginLoad step 抛异常离线测 + 注释更正(Part B 评审 I-1/I-2/I-3/M-2/M-4) 2026-06-12 09:11:54 +08:00
gaozheng d1c1bf96b1 feat(net+app): AuthService/登录异步化(CaptchaLoad/LoginLoad+ApiChain, LoginWindow 不冻可取消, test_auth 异步化)
B1: AuthService 改异步——新增 net 层句柄 CaptchaLoad/LoginLoad(AuthLoads.{hpp,cpp}),
    fetchCaptchaAsync 返回 CaptchaLoad;loginAsync 用 ApiChain 编排 verifyCodeCheck->RSA->login2
    返回 LoginLoad。删同步 fetchCaptcha/login/LoginResult。句柄遵 spec §5.0(aborted_ 守卫/deleteLater)。
B2: LoginWindow 异步化——refreshCaptcha/attemptLogin 连句柄信号(身份比对),删 repaint() hack,
    析构 abort 在飞句柄(退出契约)。公共 API(token/remember/exec)不变。
B4: test_auth.cpp 改 QSignalSpy::wait 异步等待(仍 live)。
新增离线句柄单测 test_auth_loads.cpp(CaptchaLoad/LoginLoad done/failed/abort)。
[B3 删 ApiClient 同步 get/postJson 因 ProjectListDialog/ApiProjectRepository 仍同步而 BLOCKED,故保留]
2026-06-12 09:01:07 +08:00
gaozheng 4ca5893800 harden(controller): ChartStrategyRegistry 显式禁拷贝/保留移动(保护 find() 裸指针,评审 I-1) 2026-06-12 08:20:20 +08:00
gaozheng 0cb0ed8aa0 refactor(detail): 控制器按 ddCode 走 ChartStrategyRegistry 分派, 未注册优雅降级 (替代硬编码 dd_inversion_data)
- IDatasetChartStrategy + ChartStrategyRegistry 下移到控制器层 (src/controller, namespace geopro::controller), 删 app 层那份, 修层级倒置 (控制器不得依赖 app)
- 接口加 hasGridPhase(); ErtInversionStrategy 留 app 层, 改继承 controller 接口, hasGridPhase()=true
- DatasetDetailController 构造注入 ChartStrategyRegistry&; openDataset 用 registry.supports 降级; loadGridData 用 strategy->hasGridPhase 判定
- main.cpp 构造 registry 注册 ErtInversionStrategy 并注入 (registry 先于 detailCtrl 声明)
- 测试: registry 加 hasGridPhase 断言; 控制器加空注册表降级 + 无网格阶段跳过网格加载用例; 全量 109/109 绿 (基线 106)
2026-06-12 08:14:19 +08:00
gaozheng 62352395ba harden(controller+net): setCheckedTms 去重 + loadMore 失败回滚页号 + 非拥有所有权注释更正 + ApiChain 待用注释 + selectObject 部分失败测试(Part A 评审 I-2/I-3/I-4/M-1/M-4) 2026-06-12 08:04:08 +08:00
gaozheng b097fa6e56 feat(controller): WorkbenchNavController 异步化(NavRequest续延+并发计数, abort-and-replace+身份比对, 删busy_/drain/BusyGuard, busyChanged=在飞存在性) + 单测
- 控制器依赖切换到 IAsyncProjectRepository(异步句柄)
- 删除 busy_/BusyGuard/drainPendingCheckedTms/checkedTmsPending_/pendingCheckedTms_/friend struct BusyGuard
- start/switchWorkspace 用 NavRequest 续延依赖链(startStepReq_ 跟踪当前在飞级)
- switchProject/loadMore*/selectDataset 单请求 + abort-and-replace + 身份比对
- selectObject 三并发(data/file/detail), 各自身份比对独立 emit
- setCheckedTms 并发拉取未命中缓存项, 计数汇聚; 新勾选 abort 旧批(以最后一次为准); tmExceptionCache_ 命中不发请求
- busyChanged 由 anyInflight() 驱动(emitBusyIfChanged 去抖, 值变才发)
- 析构 abortAll() 退出契约
- 对外信号面零改动, main.cpp 接线据引用绑定自动切换(无需改)
- 新增 9 个控制器单测(依赖链/并发/abort-and-replace/busyChanged/缓存语义/回灌防护/失败路径)
- 测试 96 -> 105 全绿
2026-06-12 07:51:35 +08:00
gaozheng 05f0bf3d4f feat(data): ApiProjectRepository 实现 IAsyncProjectRepository(9方法,Async后缀,薄封装,新旧并存) 2026-06-12 07:42:00 +08:00
gaozheng 2ee1ccdb0f feat(data): IAsyncProjectRepository 异步导航仓储抽象(薄封装,返回NavRequest,Async后缀) 2026-06-12 07:39:41 +08:00
gaozheng 4beb7a9523 feat(data): NavRequest 单请求异步句柄(QVariant payload, abort闸门) + 元类型声明 + 离线单测 2026-06-12 07:38:59 +08:00
gaozheng 22a7f2339e feat(net): ApiChain 顺序依赖链原语(fail-fast+abort闸门+工厂可抛) + 离线单测 2026-06-12 07:36:50 +08:00
gaozheng 751b486254 docs(plan): 异步化铺开(导航+登录)计划 + 其余 dd 类型详情图扩展计划(Phase0 样本探查+策略分派打通) 2026-06-11 21:36:18 +08:00
gaozheng 6d0ec909ec docs(spec): 回填进度现状 — async 仅 DatasetDetail 试点已完成(导航/登录待铺开);详情图仅 dd_inversion_data 完成(QwtPlot 落地, 余 dd 类型待样本) 2026-06-11 21:24:28 +08:00
70 changed files with 4256 additions and 681 deletions

View File

@ -14,6 +14,13 @@ set(CMAKE_AUTOUIC ON)
if(MSVC) if(MSVC)
add_compile_options(/utf-8 /MP /W4 /permissive-) add_compile_options(/utf-8 /MP /W4 /permissive-)
# PDB使 Release 使 minidump /
# /Zi /DEBUG PDB/OPT:REF,ICF /DEBUG
# + Debug
add_compile_options($<$<NOT:$<CONFIG:Debug>>:/Zi>)
add_link_options($<$<NOT:$<CONFIG:Debug>>:/DEBUG>
$<$<NOT:$<CONFIG:Debug>>:/OPT:REF>
$<$<NOT:$<CONFIG:Debug>>:/OPT:ICF>)
endif() endif()
# ===================================================================== # =====================================================================

View File

@ -1,97 +1,225 @@
# 交接文档:数据集详情图表dataset-detail-chart # 交接文档:数据集详情图表 + 全 App 网络层异步化
> 给下一个会话:读完本文件 + 文末"立即要做的事"即可无缝接手。日期 2026-06-11。 > 给下一个会话:读完本文件即可无缝接手。最后更新 2026-06-12。
> 分支 **`feat/dataset-detail-chart`**,领先 main 68 commits。**测试 122/122 全绿**。
> ⚠️ **工作区脏**sessions 0.10.5 的代码(散点 hover/日志崩溃捕获/colormap/数据集树/按根分页/暗色主题)**全部未提交**(用户多次选「保持现状」不提交不合并)。`git status` 一大堆 M/??**别假设干净树**;接手前先 `git status` 看清。新增未跟踪文件:`src/app/Logging.*`、`src/app/panels/chart/{ScatterHoverTip,ChartTheme}.*`、`tests/app/test_scatter_hover.cpp`,以及若干 `*.jpeg/*.yml` 临时抓图/抓取产物(非源码,可忽略/清理)。
---
## 0. 一句话现状
geoproQt6/C++ 离线桌面客户端1:1 复刻赛盈地空 web已完成两大块
1. **数据集详情图表**(仅 ERT 反演 `dd_inversion_data`QwtPlot 落地,用户已验收)。
2. **全 App 网络层 100% 异步化**(详情/导航/登录/项目列表全异步,同步 `QEventLoop` 阻塞路径已彻底删除,无技术债)。
均**未合并入 main**,分支挂起等收尾。
### 0.1 2026-06-12 渲染保真修复(本次会话)
用户报客户端散点/网格颜色与原版差异大(大量点透明发白、图例只有 6 色 + 白缝)、散点缺 hover。经 Playwright 抓原版真实 API + 读源码定位为**通用根因**
- **colorBar alpha 标度 bug**(核心):`lvl/colorGradation/getDetail` 返回的 colorBar 是**混合格式**——hex `#00008B` 与 CSS `rgba(0, 0, 170, 1)`**alpha 是 01 浮点**),共 18 段。`DatasetChartDto.cpp` 用 `AlphaScale::Bit255` 解析 → rgba 的 `a=1` 被当字节 → alpha≈1/255 近透明12 个 rgba 段全成白缝6 个 hex 段因 hex 分支强制 255 才可见)。**修复:`Bit255`→`Unit`**(一行)。散点连续插值 `ColorMapService` 的归一化位置 `val/maxVal` 已证**精确等于原版 Plotly colorscale 位置**,无需改。
- **散点 hover**:新增 `ScatterHoverTip`canvas 事件过滤器,与 LivePanner 共存),最近点命中显示 `X/Y/值`(各 3 位小数,对齐原版 Plotly hovertemplate `<b>X:</b> %{x:.3f}…`)。
- 文件:改 `src/data/dto/DatasetChartDto.cpp`;新增 `src/app/panels/chart/ScatterHoverTip.{hpp,cpp}` + 接线 `RawDataChartView.{hpp,cpp}`;测试 `tests/data/test_dataset_chart_dto.cpp`(改用真实混合格式 + 断言 alpha=255 回归)、新增 `tests/app/test_scatter_hover.cpp`。**测试 116→118 全绿**cpp-reviewer APPROVE。
- ⚠️ **像素级视觉 1:1 待用户在运行的 app 内登录核对**(原生 Qt 窗口无法用 Playwright 驱动;以下各层已机器验证:抓包/源码/RED-GREEN/插值位置匹配)。
- 观察(未改,待定):图例 `ColorBarWidget``stops-1=17` 段,丢弃最后一段最深色;原版疑为 18 段。非本次报障,留待用户决定是否补齐。
### 0.2 2026-06-12 第二轮修复(散点 cauto 归一化 + hover mouseTracking + 页签名)
第一轮 alpha 修复后颜色不再透明,但散点仍与原版不一致(挤在暖色中段、无蓝无紫)。复查 Plotly `_fullData`:散点 **`cmin/cmax` 未设 → `cauto` 自动取 vlist 实际 min/max本例 45.93197.79**,把整段色阶铺满数据范围;而客户端 `ColorMapService` 错用 colorBar 全程 01323 归一化数据 → 压进色阶 0.030.15 段。
- **修复 A散点 cauto**`ColorMapService.setDataRange(dataMin,dataMax)` 解耦「色阶形状位置(按断点值)」与「数据值归一化(按数据 min/max`RawDataChartView::setData` 按 `d.scatter.v` 有限值 min/max 调用。**数值验证 1:1**:手算修复后客户端对样本值的 RGB 与原版 Plotly 实测逐字节相同(如中位 92.01→[168,0,35]、62.34→[255,119,0])。网格仍用绝对阈值(`colorAtDiscrete`),不变。
- **修复 Bhover 仍无效)**:根因 `QwtPlotCanvas` 默认不开 mouseTracking → 无按键时收不到 MouseMove。`ScatterHoverTip` 构造里加 `canvas->setMouseTracking(true)`
- **修复 C页签用数据名**`ChartData` 加 `dsName``openDataset` 加可选第 3 参 `dsName`(默认空,向后兼容测试);`kDsNameRole` 存 dsName`main.cpp` 双击传入,`DatasetDetailPanel` `addTab``dsName`(空回退 dsId+ tooltip。
- 文件:`ColorMapService.{hpp,cpp}`、`RawDataChartView.cpp`、`ScatterHoverTip.cpp`、`DatasetDetailController.{hpp,cpp}`、`DatasetListPanel.{hpp,cpp}`、`DatasetDetailPanel.cpp`、`main.cpp`;测试 `test_colormap_service.cpp`(+DataRangeDecouples)。**119/119 全绿**。
- ⚠️ hover/页签名/视觉仍待用户运行核对GUI 无法自动驱动)。
### 0.3 2026-06-12 桌面端日志 + 崩溃捕获(新基础设施)
用户反馈:双击 ds 后状态栏报「内部系统错误」然后客户端崩溃;后端错误可接受,但客户端不该崩。复测发现 scatter/color 接口当时其实 200/干净数据 → 无日志根本无从定位。遂加生产级日志 + 崩溃捕获(已实测打通)。
- **`src/app/Logging.{hpp,cpp}`**`initLogging()`main.cpp 在 setApplicationName 后调用)。
- `qInstallMessageHandler` 接管全 App `qDebug/qInfo/qWarning/qCritical/qFatal` → 写带时间戳/级别的滚动日志:`%LOCALAPPDATA%/Geomative/Geopro3/logs/geopro_YYYYMMDD.log`按天UTF-8+BOM启动清理 14 天前旧文件)。
- **崩溃捕获**`SetUnhandledExceptionFilter`SEH含未捕获 C++ 异常 0xE06D7363+ `std::set_terminate`;崩溃时 `MiniDumpWriteDump``crash_*.dmp`(链 `Dbghelp`+ 记一行 `[FATAL] 崩溃 code=… addr=… dump=…`。`SetErrorMode` 抑制系统弹窗。**已用临时 env 自检实测**:空指针崩溃 → 生成 6MB dmp + 日志记录 code=0xc0000005自检块已删
- dmp 可 VS/WinDbg 加载看完整调用栈。
- **埋点**`ApiCall::onFinished` 记录每个 API 响应URL/http/code/err成功 INFO/失败 WARN`DatasetDetailController` openDataset + 各 loadFailedApp 启动版本/路径。net 层 `qWarning` 自动收录。
- **顺带堵崩溃根因**(评审 H-2 + 防脏数据):`ColorMapService::colorAtContinuous` 对 NaN/Inf 的 t 回退首断点色(原会 `upper_bound` 返回 end() 后解引用越界崩溃);`RawDataChartView` vlist min/max 用 `isfinite` 跳过 NaN/±Inf。+ 单测 `ColorAtContinuousNaNSafe`
- **测试 119→120 全绿**
### 0.4 2026-06-12 崩溃定位 + 顶层异常护栏
用户提供 dump + log。日志序列`openDataset(ERT2-WS_result.dat)` → **`dynamicForm` 后端返回 `code=500 sys.internalServerError`**(即"内部系统错误")→ scatter 200 → color 200 → `Qt has caught an exception thrown from an event handler` → 崩溃。
**dump 分析winget 装 Microsoft.WinDbg`!analyze -v`**:异常 `e06d7363`C++ EH`VCRUNTIME140!CxxThrowException ← msvcp140!std::_Xlength_error ← geopro_desktop+0x9ad0 ← +0xa94ee ← +0x24b71 ← +0x235c0`。即 **`std::length_error`**STL 容器/字符串用非法 size resize/reserve/构造)。栈帧都在 geopro_desktop含静态链接的 Qwt但**该 dump 是旧构建、当前 PDB 已不匹配 → 符号化不出函数名**。静态排查 dynamicForm 解析 / DynamicFormView / selectDataset 失败路径 / 散点解析(已 try/catch) / 渲染 / ColorMapService 均未见明显抛点 → 确切行**待运行期护栏日志点名**。
**修复(主,根治"不该崩"**`main.cpp` 加 `GuardedApplication : QApplication`override `notify()` try/catch任何 slot/事件处理器抛出的异常被拦截 + `qCritical` 记录 `[guard] 拦截... what | type | receiver=<对象类名> | event=<事件类型>`,然后吞掉(不终止)。后端故障等异常**不再使整个客户端退出**。
**附带**:修 `appendCrashLine` 文件共享 bug崩溃行原写不进日志——g_logFile 独占,第二句柄 open 失败;改为复用已打开句柄);`colorAtContinuous` NaN/Inf 守卫。
**护栏实测**:用户复现,护栏拦到 → 日志 `[guard] 拦截未捕获异常: vector too long | type=std::length_error | receiver=QNetworkReplyHttpImpl | event=43(MetaCall)`。即异常在**网络响应 finished 同步链**里(最后一个 chart 请求 color 返回 → `chartReady→openOrUpdate→渲染`)抛出 `std::length_error`"vector too long" = std::vector 用了非法尺寸,典型负数转 size_t。进程**未崩**(护栏吞掉),但图表没渲染出来。
**诊断基础设施(关键补强)**
1. **Release 构建之前不生成 PDB** → dump/崩溃栈对自家代码全部符号化失败(只有系统 DLL 导出表能解析)。已在根 `CMakeLists.txt` MSVC 块加 `/Zi`(编译) + `/DEBUG /OPT:REF /OPT:ICF`(链接)Release 保持优化的同时产出匹配 PDB。**生产桌面端排障必需。**
2. **VEH 抛点堆栈符号化**`Logging.cpp``AddVectoredExceptionHandler` 在 C++ 异常0xE06D7363**抛出瞬间**栈未展开、PDB 匹配)用 DbgHelp`SymInitialize`+`SymLoadModuleExW`+`SymFromAddrW`+`SymGetLineFromAddrW64`**宽字符**——UNICODE 下必须)打印 `模块+RVA 函数名+偏移 (文件:行)`。自测验证:`reserve(-1)` → 正确打印 `geopro::app::initLogging (Logging.cpp:245)`。即使异常被护栏吞掉也留下抛点栈。
**下一步定位 length_error 确切行**:用当前构建复现一次 → 日志 `[THROW]` 段直接给出 `geopro::app::<类>::<方法> (文件:行)`。**测试 120/120 全绿。**
### 0.5 2026-06-12 数据集列表树化 + 按根分页 + 暗色主题保真(本次会话)
用户三组报障,均已修复、构建链接通过、应用真实流程跑通无崩溃、**测试 120→122 全绿**
**(1) 数据集列表应是树(原为平铺)**
- **实地确认(铁律 Playwright**:原版「数据管理」选 TM 后的数据列表是 **el-table tree-data**(有展开箭头)。脚本直连 API 验证 TM E3项目 1458977804960256 垃圾掩埋場10 个 DS → **4 个根**(默认折叠)= 3×源「ERT原始数据」+ 1×「ERT电极坐标」派生「反演/接地电阻」按 `parentId`(==`sourceShowParentId`)挂源「原始数据」下。根 = parentId 为空或指向不在本批的「源文件节点」。
- **改**`DsRow` 加 `parentId``NavDto::parseDsRows` 解析 `sourceShowParentId`(回退 `parentId`)`DatasetListPanel::populateDatasetList` 由 `QListWidget` 改建 **`QTreeWidget`**(两遍法按 parentId 嵌套,卡片委托 `applyDatasetCardDelegate` 泛化到 `QAbstractItemView``main.cpp` `datasetList``QTreeWidget``setExpandsOnDoubleClick(false)`:双击=开详情、展开靠箭头),点击/双击/反向高亮/加载更多 4 处改 `QTreeWidget` API。文件页签仍平铺 `QListWidget`。默认折叠(对齐原版)。
- 测试:`test_nav_dto.cpp::ParseDsRowsParentIdForTree`。
**(2) 分页应按「第一层节点(根)」算(原按扁平 DS**
- **根因**:后端 `dsObject/data/page` 按**扁平 DS** 分页脚本验证total=10、pageSize=5 返回 5 条扁平行)——子节点的父常落在下一页 → 按页建树出孤儿根、首层数错乱。
- **改**`IAsyncProjectRepository::loadRowsAsync` 加 `int pageSize=5` 参数(接口/`ApiProjectRepository`/测试 stub 同步);`WorkbenchNavController::selectObject` 数据页改用大 pageSize(`kFetchAllPageSize=1000`)**一次取全**整棵,缓存 `allDataRows_`;新私有 `emitNextDataRootPage(bool append)` **客户端按根切页**(每页 `kDataRootPageSize=5` 个根 + 各自整棵子树DFS 收集后按原序输出,`total`=根总数);`loadMoreData` 改同步切下一页(无请求);`dataRootsShown_` 游标,`resetSelectionState` 清空。删因此空置的 `moreDataReq_`。`main.cpp` `addTreeLoadMore` 计数改按顶层根。若 `listCount<total`pageSize 不足取全)`qWarning` 告警。
- 测试:`test_workbench_nav_controller.cpp::DataPaginatesByRootNodeNotFlatCount`6 根→首页 5 根+子=7 行/total=6续页第 6 根;用 `qRegisterMetaType<std::vector<DsRow>>` 读 spy 行参)。
**(3) 暗色主题图表样式未跟随**
- 原详情图 `QwtPlot`、`ColorBarWidget`、`LoadingOverlay` 全硬编码白底/浅色 → 暗色主题下刺眼白底/白蒙板。**原版 web 无暗色,故暗色为客户端自定****浅色分支保持原硬编码值=与原版 1:1 不动,仅暗色改 token**。
- 新增 **`src/app/panels/chart/ChartTheme.{hpp,cpp}`** `applyChartPlotTheme(QwtPlot*)`:按 `isDarkTheme()` 设画布底色(`bg/panel`)/轴字(`text/secondary`)/网格(`border/default`)/零线(`border/strong`),遍历 itemList 重着色 grid/marker。`Raw/GridDataChartView` 删硬编码白块、ctor 末尾调用 + 连 `ThemeManager::changed` 热切换。
- `ColorBarWidget::paintEvent` 底色/边框/刻度字按 `isDarkTheme()`(暗色 `bg/panel`/`border/strong`/`text/secondary`;色带格=数据色不变)+ ctor 连 changed→update。
- `LoadingOverlay` 遮罩纱色由 `rgba(255,255,255,160)` 改按主题(暗色 `bg/app` 深纱)+ label 文字 `text/primary`,连 changed 热切换。
- token 表见 `src/app/Theme.cpp``bg/panel` 白/`#161A20`、`text/secondary`、`border/*`)。
**新文件**`src/app/panels/chart/ChartTheme.{hpp,cpp}`(已加 `src/app/CMakeLists.txt`)。
**改动文件**`RepoTypes.hpp`、`NavDto.cpp`、`IAsyncProjectRepository.hpp`、`ApiProjectRepository.{hpp,cpp}`、`WorkbenchNavController.{hpp,cpp}`、`DatasetListPanel.{hpp,cpp}`、`main.cpp`、`RawDataChartView.cpp`、`GridDataChartView.cpp`、`ColorBarWidget.cpp`、`LoadingOverlay.cpp`、`src/app/CMakeLists.txt`、`tests/{data/test_nav_dto,controller/test_workbench_nav_controller}.cpp`。
**记忆新增**`dataset-list-is-tree`(树结构 + 扁平分页坑 + 按根分页解法 + 暗色图表方案)。
- ⚠️ **待用户运行核对GUI 无法自动驱动)**:① 暗色下网格详情的「加载中…」蒙板是深纱、色阶条深底浅字;② 数据列表按 5 根分页(根多的 TM 才出「加载更多」)+ 树嵌套正确。机器侧已验证:脚本抓原版结构 + 构建 + 122 测试 + 真实登录流程日志跑通(项目 1458977804960256→E3→data/page→getDetail→dynamicForm无崩溃
---
## 1. 背景 ## 1. 背景
geopro 是 **Qt6 / C++ 离线桌面客户端**,目标是**像素级 1:1 复刻** web 系统 - geopro = **Qt6 / C++ 离线桌面客户端**,目标**像素级 1:1 复刻** web 系统 `http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo`(赛盈地空)。
`http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo`(赛盈地空)的「数据集详情」视图。 - **硬约束**:禁 QWebEngine/Chromium离线。图表用本地 **QwtPlot轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**。验收 = **视觉等价 + 交互一致**(非字节级 diff
本轮专做 ERT 反演数据(`ddCode=dd_inversion_data`)的详情视图。 - **标准测试 ds**`id=1458990939709440`ddCode=dd_inversion_data
- 站点 baseUrl`http://tenant.geomative.cn/pop-api`。Auth header`geomativeauthorization: Geomative <token>`。
**硬约束**:禁止 QWebEngine/Chromium离线要求。图表用本地 **QwtPlot轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**,不是 web 渲染。验收标准是**视觉等价 + 交互一致**(非字节级像素 diff ---
**标准测试 ds**`id=1458990939709440`ddCode=dd_inversion_data。原版网格视图参考截图仓库根 `web-grid-E3b.png`(未入库,可重新用 Playwright 截)。 ## 2. 技术栈与构建/测试
## 2. 目标 - Qt6 Widgets + **Qwt 6.2**(源码 `external/qwt-src/`**用 `cmake/qwt.cmake` 以 CMake 构建**静态库,**不要用 qmake**——本机 VS2026 缺 vswhere
- **VTK 9.6 仅算法层**`vtkBandedPolyDataContourFilter`/`vtkStripper`/`vtkSplineFilter`/`vtkDataSetSurfaceFilter`),不做 VTK 渲染。
数据详情含两个页签,均已完成: - CMakeVS 自带)+ Ninja + MSVC 14.51GoogleTest/CTestvcpkg仅非 Qt 依赖)。
- **原数据**Plotly scattergl 风格散点(方形点/白描边/连续色阶/x 轴顶部)。
- **网格数据**:填充等值面 + 黑色等值线 + 沿线数值标注 + 不规则白边NaN 裁剪)+ 底部异常表/描述。
## 3. 技术栈与构建
- Qt6 Widgets**Qwt 6.2**(源码在 `external/qwt-src/`**用 `cmake/qwt.cmake` 以 CMake 构建**静态库,不要用 qmake——本机 VS2026 缺 vswhereqmake 不可用)。
- 关键qwt.cmake 必须 `target_compile_definitions(qwt PRIVATE QWT_MOC_INCLUDE=1)`,否则 61 个链接错误;需链接 Qt6 Widgets/Concurrent/PrintSupport/Svg。
- **VTK 9.6 仅用算法层**`vtkBandedPolyDataContourFilter` / `vtkStripper` / `vtkSplineFilter` / `vtkDataSetSurfaceFilter`),不做 VTK 渲染。
- CMakeVS 自带 4.2.3+ Ninja + MSVC 14.51GoogleTest/CTestvcpkg仅非 Qt 依赖)。
- **构建**`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1` - **构建**`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
- ⚠️ 若报 `LNK1104 无法打开 geopro_desktop.exe`,是 exe 正在运行:先 `Get-Process geopro_desktop | Stop-Process -Force` 再构建。 - ⚠️ `LNK1104 无法打开 geopro_desktop.exe` = exe 在运行:先 `Get-Process geopro_desktop -ErrorAction SilentlyContinue | Stop-Process -Force` 再构建。
- **测试**`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(当前 **75/75 绿**)。 - ⚠️ **改头文件后偶发** ninja 增量陈旧导致链接报「符号在 main.cpp.obj 重复定义」:删 `build/release/src/app/CMakeFiles/geopro_desktop.dir/main.cpp.obj` 后重建。
- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=ContourBands.*` - **测试**`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`**只跑 ctest 不构建——必须先 dev-build**)。当前 **116/116 绿**
- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=ApiBatch.*`
- ⚠️ 直接跑 exe 时 4 个 `CrsTransform/VoxelRegister/Terrain` 失败是缺 `PROJ_DATA` 环境项;**ctestdev-test会注入环境、全绿**——以 dev-test 的 "X/X passed" 为准。
- **网络/署名**AuthLiveTest 联网真打站点;提交信息**全局禁用署名**(勿加 Co-Authored-By
## 4. 数据 API均经 Playwright 实测) ---
Auth header`geomativeauthorization: Geomative <token>`。ApiClient 把非 object 的 `data` 包成 `{"value": <data>}` ## 3. 第一块:数据集详情图表(已完成,仅 dd_inversion_data
**原版也是分页懒加载**(客户端已对齐):
| 页 | 接口 | 说明 | 详情含两页签,均经用户逐项验收:
|---|---|---| - **原数据**Plotly scattergl 风格散点(方形点/白描边/连续色阶/x 轴顶部)。
| 原数据 | `lvl/colorGradation/getDetail`(type1) + `dd/ert/inversion/getErtRawDataScatterGraph/{id}` | 2 个请求,~0.8s | - **网格数据**:填充等值面(栅格 QImage+ 黑色等值线vtkStripper+vtkSplineFilter 样条平滑,**每条线只标一个**数值)+ NaN 白边裁剪 + 底部异常表/描述。
| 网格数据 | `dd/ert/inversion/rows/{id}` + `lvl/colorGradation/getDetail`(type2) + `exception/queryException/{id}` | 3 个rows 服务端网格化**波动 14s** |
- `rows``x[nx]`、`y[ny]`、`v[ny][nx]`(电阻率,含 NaN=无数据)、`z[ny][nx]`(高程)、vmin/vmax。标准 ds 为 nx=300,ny=100。 数据 API均经 Playwright 实测):
- `colorGradation``properties.colorBar=[[值,"rgba()"],…]`type1 散点连续/type2 网格离散,~17 段)+ `lineConfig{showLines,color,lineType}` + `labelConfig{showLabels,color}` | 页 | 接口 |
- **配色机制(实测纠正过)**:不是 log**colorBar 值线性归一化到 [0,1] + 非均匀色阶停靠点 + 连续插值(散点)/离散分带(网格)** |---|---|
| 原数据 | `lvl/colorGradation/getDetail`(type1) + `dd/ert/inversion/getErtRawDataScatterGraph/{id}` |
| 网格 | `dd/ert/inversion/rows/{id}`(服务端网格化,**波动 14s**) + `colorGradation/getDetail`(type2) + `exception/queryException/{id}` |
## 5. 架构与关键文件 > ⚠️ 架构偏离:原 spec 定 QGraphicsView**实际落地用 QwtPlot**(见 `plans/2026-06-11-dataset-detail-chart-v2-qwt.md`,权威)。
数据流:`DatasetListPanel` 双击 → `DatasetDetailController.openDataset`(同步阻塞拉原数据 2 请求)→ `chartReady(ChartData)``DatasetDetailPanel`(多 ds 的 QTabWidget) → `DatasetDetailPage`(buildTabbedPanel 原数据/网格 两页签) → 两个 View。切到网格页签 → `gridDataNeeded` 冒泡 → `loadGridData`(懒加载 3 请求)→ `gridReady(GridData)``GridDataChartView.setGridData` **策略分派已打通**(本次会话):控制器从硬编码 `dd_inversion_data` 改为走 `ChartStrategyRegistry`(在 **controller 层** `src/controller/IDatasetChartStrategy.hpp`,含 `hasGridPhase()`),未注册类型优雅降级「暂不支持」。`ErtInversionStrategy`app 层)已注册。**这是接其余 dd 类型的地基**
- `src/controller/DatasetDetailController.{hpp,cpp}` — openDataset / **loadGridData**懒加载busy_ 重入守卫 + catch(...) 防死锁。signal: chartReady/gridReady/loadFailed/focusRequested。 ---
- `src/app/panels/chart/`
- `RawDataChartView.*` — 散点视图模板(白底浅 palette、QwtPlotGrid 网格线、过原点 QwtPlotMarker 零线、**QwtPlotRescaler 锁定 x:y 真实比尺(aspect=1, ref=xTop)**、**LivePanner**、x 轴在 **xTop**、独立 ColorBarWidget。析构 delete colorSvc_。
- `GridDataChartView.*` — 网格视图。x 轴在 **xBottom**。布局toolbar(固定) + **QScrollArea(setWidgetResizable)** 内含 **QSplitter(竖直)**{ chartArea(plot+colorBar, minH280) | 异常表/描述(minH160) }。→ 页签内滚动 + 可拖分割条。
- `ContourPlotItem.*`QwtPlotItem**填充走栅格**QImage(ARGB32, K=4 上采样, 双线性插值 + `colorAtDiscrete` 离散着色 + 任一邻格 NaN→像素透明=白边裁剪)draw 时 blit**等值线走矢量**`buildContourBands` 取 lines标注`resolveLineLevels` 采样吸附级别,**每条线只标一个**(弧长中点,旋转)。异常叠加(点/线/面)。
- `ScatterPlotItem.*` — 散点项xTop/yLeft7px 方块白描边连续配色n=min(x,y) 防越界)。
- `ColorMapService.*` — colorAtContinuous(线性插值)/colorAtDiscrete(阶梯)from core::ColorScale。
- `ColorBarWidget.*` — 独立色阶条,**居中约 74% 宽**(非满宽)、等宽色带、白底深字。
- `LivePanner.*` — canvas 事件过滤器:**左键实时平移**(连续平移两轴+replot) + **滚轮缩放**(以光标为中心,上滚=放大,**消费事件**避免冒泡触发外层滚动条)。取代了 QwtPlotPanner/QwtPlotMagnifier。
- `src/render/ContourBands.{hpp,cpp}``buildContourBands(Grid, ColorScale, ContourOptions{upsample=2,smooth=0.3,makeLines})``{bands, lines}`。VTK网格→上采样→平滑→toCellGrid(剔 NaN 格)→surface→banded→(lines: **vtkStripper 连段 + vtkSplineFilter 样条平滑**, 不再 DP 简化)。
- `src/data/dto/DatasetChartDto.*`、`src/data/api/ApiDatasetRepository.*` — JSON 解析v 行数校验 qWarning、markType 钳制)。
- `src/app/main.cpp` — 装配detailDock `setWidget(..., ads::CDockWidget::ForceNoScrollArea)`**禁 ADS 把标题/页签卷入整体滚动条**gridDataNeeded→loadGridData、gridReady→setGridData 接线。
## 6. 关键设计决策(及为什么) ## 4. 第二块:全 App 网络层异步化(本次会话完成,无技术债)
- **真实比尺锁定**用户选定区别于原版的响应式填充QwtPlotRescaler aspect=1剖面呈真实"宽扁"。 **动机**:原 `ApiClient``QEventLoop` 死等每个请求 → 全 App 冻 UI网格 rows 14s 最痛)。
- **填充用栅格而非多边形**300×100 网格 banded 多边形约 3 万个,逐帧+实时拖动会卡QImage 一次成图 + blit 流畅。
- **等值线样条平滑**banded 边是大量 2 点短线段→ vtkStripper 连接 + vtkSplineFilter 拟合平滑曲线(贴近原版圆滑)。
- **滚轮事件必须消费**:否则冒泡触发 ADS/外层滚动条。
- **页签内滚动**ForceNoScrollArea(dock) + 视图内 QScrollArea(裹 splitter),使标题/页签/工具条固定、仅内容区滚动。
- **同步阻塞是全 App 共性问题**ApiClient 用 QEventLoop 死等→每次请求冻 UI。已定**单独立项异步化**(见下)。
## 7. 当前进度 **核心安全不变量spec §5.0,务必遵守)**「abort 后绝不回灌」靠三件套——
1. 每层 `aborted_` 入口守卫(`disconnect` 只是尽力而为,挡不住已入队的迟到信号);
2. 控制器**句柄身份比对**`if (load != current_) return;` 丢弃迟到信号);
3. **一律 `deleteLater`**,禁止同步 delete。
错误判定口径:业务 `code != 200 || !rawError.isEmpty()`HTTP 200 也可能 code=500
- 分支 **`feat/dataset-detail-chart`**,领先 main **37 commits**,工作区干净(除未入库的 `web-grid-E3b.png`)。 **net 层原语**`src/net/`AUTOMOC 已 ON
- 两个视图 + 交互 + 懒加载 + 布局**全部完成并经用户逐项验收通过**。 - `IApiCall`/`ApiCall`:单请求句柄(包 QNetworkReply`finished(ApiResponse)` + `abort()`,自管理 deleteLater。
- **cpp-reviewer 审查已做**HIGH 全修(散点越界 / colorSvc 析构泄漏 / QwtPlot autoDelete 注释 / 控制器 catch(...) 防 busy 死锁)+ 值得的 MEDIUM/LOW清死代码、填充等比限幅、DTO 校验/枚举钳制、ContourLine.level 默认 NaN。提交 `78f96db`。75/75 测试绿。 - `ApiBatch`**并发汇聚** N 个 IApiCall全成功 `succeeded(QList)` / 任一失败 **fail-fast** `failed(i,resp)`+abort 其余。
- `ApiChain`**串行依赖链**(上步结果喂下步工厂,工厂可抛转 failed。**契约:首个 step 工厂不得同步抛/同步 fire**(否则信号在调用方连接前丢失;生产路径都发异步请求,满足)。
- `ApiResponseParse::buildResponse`sync/async 共用解析(已无 sync 调用方,仅 ApiCall 用)。
- `ApiClient`:仅 `getAsync/postJsonAsync`**同步 `get/postJson`/`await`/QEventLoop 已删除**)。
## 8. 立即要做的事(接手第一步) **data 层**
- 详情:`ChartLoad`/`GridLoad`(抽象基 + `ApiChartLoad`/`ApiGridLoad` 实现,包 ApiBatch + 注入 parse、`IAsyncDatasetRepository`、`ApiDatasetRepository.loadChartAsync/loadGridAsync`、`DatasetLoads.hpp`(ChartParts/GridParts)。
- 导航:`NavRequest`(单非模板句柄,`done(QVariant)`/`failed(QString)``ApiNavRequest` 包 IApiCall + 解析器)、`NavLoads.hpp`(各类型 `Q_DECLARE_METATYPE`)、`IAsyncProjectRepository`9 方法 `...Async` 后缀,返回 `NavRequest*`,薄封装;汇聚/链编排放控制器)、`ApiProjectRepository` 仅实现异步接口。
**收尾本分支**——已问用户"如何收尾"4 选项待其回答:①合并回 main(本地) ②推送建 PR(origin=gitea) ③保持现状 ④丢弃。 **controller 层**
合并/PR 前先清掉未入库的 `web-grid-E3b.png`(保持工作区干净)。**等用户给出选择后执行**(用 superpowers:finishing-a-development-branch - `DatasetDetailController`abort-and-replace + 句柄身份比对 + `loadStarted(dsId,Phase)` + 析构 abort`ChartStrategyRegistry` 分派。
- `WorkbenchNavController`全异步NavRequest 续延依赖链 + 控制器内并发计数 + abort-and-replace + 身份比对);**删除 `busy_`/`BusyGuard`/`drainPendingCheckedTms`**`busyChanged(bool)` 语义改为「有在飞句柄」(去抖);`setCheckedTms` 入口去重 + 「最后一次为准」由 abort-and-replace 承接 + `tmExceptionCache_` 缓存命中不发请求。对外信号面**零改动**main.cpp 接线没动)。
## 9. 后续计划(本分支之后) **app 层**
- `AuthService`net 层):`fetchCaptchaAsync()→CaptchaLoad`、`loginAsync()→LoginLoad`(内用 ApiChainverifyCodeCheck→RSA(step2 工厂内)→login2。`LoginWindow`:不冻 + 可取消(析构 abort`repaint()` hack。
- `ProjectListDialog`:改用 `IAsyncProjectRepository`NavRequest + abort-and-replace + 身份比对 + 析构 abort过滤/分页/选项目行为等价。
- `LoadingOverlay`:网格懒加载「加载中」遮罩,接 `loadStarted`、就绪/失败隐藏。
- `main.cpp``qRegisterMetaType<ApiResponse>()`;装配注入 registry/异步 repo引用绑定接线信号面不变
1. **工具条编辑类功能**(网格页占位按钮):白化 / 滤波处理 / 色阶配置 / 异常框注 / 自动标注 / 网格 / 另存为 / 导出。交互较重,建议先 brainstorm 拆解。 **执行方式**:全程 subagent-driven每块 implementer + spec 符合度评审 + 代码质量评审 opus + follow-up 加固)。测试 75 → 116+41含 abort 回灌防护回归用例)。
2. **ApiClient 异步化(已单独立项)**:把全局同步阻塞 ApiClient 改异步QNetworkReply 信号,去 QEventLoop+ Repository/控制器信号化→全 App 不冻 UI、可并发。架构改动建议单独 spec+plan。
3. **更广项目**:数据详情只是一个特性;原型/`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`(客户端 tab) 里还有其他数据类型、三维视图、其他菜单。
## 10. 相关文档与记忆 ---
- 设计/计划:`docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`、`docs/superpowers/plans/2026-06-11-dataset-detail-chart-v2-qwt.md`v2 QwtPlot 返工方案,权威)、`docs/superpowers/plans/2026-06-11-dataset-detail-chart.md`。 ## 5. 关键文件地图
- 外部返工方案:`docs/Geopro3.0_二维图表返工技术方案.md`。
- net`src/net/{IApiCall.hpp,ApiCall.*,ApiBatch.*,ApiChain.*,ApiResponseParse.*,ApiClient.*,AuthService.*,AuthLoads.*}`
- data`src/data/api/{DatasetLoads.hpp,DatasetLoadHandles.*,ApiDatasetRepository.*,NavRequest.*,NavLoads.hpp,ApiProjectRepository.*,DatasetChartDto.*}`、`src/data/repo/{IAsyncDatasetRepository.hpp,IAsyncProjectRepository.hpp,IDatasetRepository.hpp(本地样例同步,保留),RepoTypes.hpp}`
- controller`src/controller/{DatasetDetailController.*,WorkbenchNavController.*,IDatasetChartStrategy.hpp}`
- app`src/app/main.cpp`、`src/app/panels/{DatasetDetailPanel.*,DatasetDetailPage.*,LoadingOverlay.*,chart/*}`、`src/app/login/LoginWindow.*`、`src/app/ProjectListDialog.*`、`src/app/panels/chart/ErtInversionStrategy.hpp`
- 测试:`tests/net/{FakeApiCall.hpp,test_api_batch.cpp,test_api_chain.cpp,test_auth.cpp(live),test_auth_loads.cpp}`、`tests/data/{test_nav_request.cpp,test_dataset_load_handles.cpp}`、`tests/controller/{test_dataset_detail_controller.cpp,test_workbench_nav_controller.cpp}`、`tests/app/test_chart_strategy_registry.cpp`
---
## 6. 尚未完成 / 下一步(按优先级)
### A. 收尾本分支(最先)
分支领先 main 68 commits、**122/122 绿**,但**工作区脏**sessions 0.10.5 代码全未提交,见顶部 ⚠️)。用户多次选「保持现状」未提交未合并。**先决**:本次会话两组改动(数据集树/按根分页、暗色主题)**待用户在运行的 app 内目视核对**(见 0.5 末尾清单)——核对通过再谈提交。下一步可问用户:①目视核对本次改动 ②提交这批未提交工作(建议按主题分多个 commit详情图保真 / 日志崩溃捕获 / 数据集树+分页 / 暗色主题)③合并回 main(本地) ④推送建 PR(origin=gitea `https://gitea.geomative.cn/gaozheng/geopro.git`) ⑤保持现状。用 `superpowers:finishing-a-development-branch`
### B. 其余 dd 类型详情图(计划已写,多数 BLOCKED
计划:`plans/2026-06-11-dataset-detail-other-dd-types.md`。
- **Phase 1策略分派打通✅ 已完成**(本次会话)。
- **Phase 0样本探查需做**——用 Playwright 登录原版 web对每类 dd 找有数据对象、抓真实响应存 fixtures + 渲染规格。**这是后续所有类型的前置。**
- **Phase 2ERT 测量散点)/ Phase 3TEM 折线,新 LineChartView**:仅当 Phase 0 确认有样本才解锁。
- **Phase 4BLOCKED**——dd_grid/轨迹/测井/GPR**当前租户无活数据样本**GPR 对象无数据、无测井数据),按 1:1 复刻铁律不能凭空实现。
- 矩阵详见计划文件。
### C. 工具条编辑类功能(网格页占位按钮,未做,单独立项)
白化 / 滤波处理 / 色阶配置 / 异常框注 / 自动标注 / 网格化 / 另存为 / 导出 / 描述富文本 / 大视图(Esc)全屏。当前是占位按钮。交互较重,建议先 brainstorm 拆解。spec §2.3 列为范围外。
### D. 纯整洁 follow-up非债、非阻断
- 删 `DatasetDetailController::ChartData.grid/gridScale` 死字段(从未填充,预存)。
- 若未来引入 cross-thread/QueuedConnection再补 `qRegisterMetaType<QList<ApiResponse>>()`
---
## 7. 相关文档
- **spec**`docs/superpowers/specs/2026-06-11-apiclient-async-design.md`(异步设计 + §5.0 安全不变量 + §7 错误判定/退出契约 + 顶部「状态更新」=已完成)、`docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`(详情视图,顶部状态=仅 ERT 反演完成 + QwtPlot 偏离)。
- **plan**`docs/superpowers/plans/2026-06-11-apiclient-async-datasetdetail.md`(详情试点)、`2026-06-11-apiclient-async-rollout.md`(导航 Part A + 登录 Part B均落地、`2026-06-11-dataset-detail-other-dd-types.md`(其余 dd 类型Phase 1 done、`2026-06-11-dataset-detail-chart-v2-qwt.md`QwtPlot 返工,权威)。
- API 文档:`docs/apis`business_OpenAPI.json - API 文档:`docs/apis`business_OpenAPI.json
- **记忆(务必遵守)**`~/.claude/projects/D--Git-lanbingtech-geopro/memory/` - 外部方案:`docs/Geopro3.0_二维图表返工技术方案.md`。
- `reply-in-chinese` — 全程中文回复。
- `study-original-via-playwright`**任何不确定必须用 Playwright 实地看原版,禁止联想猜测** ---
- `no-embellishment-replicate-exactly` — 严格 1:1不加原版没有的特性曾因等值线"周期重复标注"被纠正,原版每条线只标一个)。
## 8. 记忆(务必遵守,`~/.claude/projects/D--Git-lanbingtech-geopro/memory/`
- `reply-in-chinese` — 全程中文回复。
- `study-original-via-playwright`**任何不确定必须用 Playwright 实地看原版,禁止臆测**(尤其做新 dd 类型详情图前抓样本/规格)。
- `no-embellishment-replicate-exactly` — 严格 1:1不加原版没有的特性曾因等值线「周期重复标注」被纠正
---
## 9. 工作方式备注(本次会话沿用,效果好)
- 用户偏好 **subagent-driven** 执行 + 实现后 **code review + spec 符合度** 双评审opus 评审);「非必要不停,一口气做完」。
- **真并行构建在本项目不安全**dev-build 硬编码单一 build 目录 + 多任务共改 main.cpp/CMakeLists→ 用顺序执行worktree 隔离不了构建(硬编码路径 + 冷配置开销)。
- 破坏性接口改形需**原子落地**(同批提交)保持构建绿(详情试点 Task5+6 合并、Part A A4+A5、本次清债 都是此教训)。
- 评审 follow-up 多为小加固直接在新代码上补「不为不可能的场景写错误处理」CLAUDE.md §2——评审若建议为构造保证不会发生的情况加防御可不采纳。
</content>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,235 @@
# 数据集详情图:扩展到其余 dd 类型 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: 用 `superpowers:subagent-driven-development` 执行(每个 bite-sized 任务派一个 subagentTDD频繁提交。步骤用 `- [ ]`
> **铁律(项目记忆,务必遵守):** 任何对原版的不确定,**必须用 Playwright 实地学习原版**,禁止联想猜测;严格 **1:1 复刻**,不加原版没有的特性。**没有活样本的 dd 类型一律 BLOCKED禁止凭想象写渲染代码。**
**Goal:** 把「数据集详情图」从只支持 ERT 反演(`dd_inversion_data`)扩展到其余 dd 类型。先打通「控制器按 ddCode 走策略注册表」的分派骨架(未注册类型优雅降级「暂不支持」),再按"样本可得性"逐类落地有活样本的ERT 测量类、TEM/timeSensor做完整复刻无活样本的dd_grid / 轨迹 / 测井 / GPR只列调研占位 + 取样前置条件BLOCKED。
**Architecture:** 沿用现有分层,不重造:
- 编排层 `DatasetDetailController`(现状对非 `dd_inversion_data` 直接拒绝)→ 改为持 `ChartStrategyRegistry`,按 ddCode 选策略;策略决定「拉哪些接口 + 用哪个 View 渲染」。
- 异步数据层 `IAsyncDatasetRepository` + `ChartLoad`/`GridLoad` 句柄(`ApiBatch` 多请求聚合 + 注入解析函数)。每类新 dd 视形态新增句柄类型(如折线 `LineLoad`、图像 `ImageLoad`)或复用现有句柄。
- DTO 解析层 `src/data/dto/`(纯函数,单测友好,用抓到的真实响应做夹具)。
- 渲染层 `src/app/panels/chart/`:复用 QwtPlot轴/交互/图例)+ VTK 算法(等值线几何)。按形态归类复用或新建 View等值面类复用 `ContourPlotItem`/`GridDataChartView`;散点类复用 `ScatterPlotItem`/`RawDataChartView`;折线类(测井)新建 `LineChartView`图像类GPR新建 `ImageChartView`
- 页面壳 `DatasetDetailPanel`(多 Tab) / `DatasetDetailPage`(单 ds 内部页签)。
**Tech Stack:** C++17、Qt6 Widgets、Qwt 6.2`cmake/qwt.cmake`,目标名 `qwt`,头在 `external/qwt-src/src`、VTK 9.6仅算法层、GoogleTest/CTest、vcpkg仅非 Qt 依赖)。
**依据文档:**
- `docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md`§2.4 后续 dd 类型、§5.3 策略框架、§3 原 web 分析方法)。
- `docs/superpowers/HANDOFF-dataset-detail-chart.md`(技术栈/构建/已完成范围)。
- `docs/apis/business_OpenAPI.json`(各 dd 类型取数接口,下文已核对路径/参数)。
**构建/测试命令(本机工具链,固定):**
- 构建:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-build.ps1`
- ⚠️ 若 `LNK1104 无法打开 geopro_desktop.exe`:先 `Get-Process geopro_desktop | Stop-Process -Force` 再构建。
- 测试:`powershell.exe -ExecutionPolicy Bypass -File scripts/dev-test.ps1`(先 dev-build 再 dev-test。**基线 89/89 绿。**
- 单测过滤:`build/release/tests/geopro_tests.exe --gtest_filter=<Suite>.*`
- 运行(视觉验收):`build/release/src/app/geopro_desktop.exe`(需登录)。
---
## 0. 现状核实(已读真实代码,勿臆测)
**关键结论:策略框架已存在但未接入控制器。** 详情:
- `src/app/panels/chart/IDatasetChartStrategy.hpp``IDatasetChartStrategy`(只有纯虚 `std::string ddCode()`+ `ChartStrategyRegistry``add/find/supports``std::map<string, unique_ptr>`)。**已写好,但全仓只有单测引用它,控制器/main.cpp 从未构造/使用注册表。**
- `src/app/panels/chart/ErtInversionStrategy.hpp`:仅一个 stub —— `ddCode()` 返回 `"dd_inversion_data"`**无任何 load/render 逻辑**。
- `src/controller/DatasetDetailController.cpp`
- `openDataset(dsId, ddCode)``if (ddCode != "dd_inversion_data") { emit loadFailed("暂不支持该数据类型的预览"); return; }` —— **硬编码**,未走注册表。
- `loadGridData(dsId, ddCode)`:同样 `if (ddCode != "dd_inversion_data") return;` 硬编码。
- `tests/app/test_chart_strategy_registry.cpp`:仅测 `add/find/supports/降级`**未与控制器联动**。
- `src/data/repo/IAsyncDatasetRepository.hpp``loadChartAsync`scatter+色阶type1/ `loadGridAsync`rows+色阶type2+异常),均**写死 ERT 反演接口**(见 `ApiDatasetRepository.cpp`)。
- ddCode 来源:`src/data/dto/NavDto.cpp:124` 从数据集列表项 `o["ddCode"]` 解析;`main.cpp:501` 双击时从 `kDsDdCodeRole` 取出传给 `openDataset`。**ddCode 是 API 给的字符串,源码里除 `dd_inversion_data` 外无其它 dd code 常量**grep 到的 `dd_custom_command` 等是 CMake `add_custom_command` 等构建符号,非数据类型)。
**结论:本 plan 的 Phase 1 必须先「打通控制器→注册表分派」,这是所有后续 dd 类型的前置。** 现有 `IDatasetChartStrategy` 接口过窄(只有 ddCode需扩展为能驱动「加载 + 渲染」。
---
## 1. 各 dd 类型实测编目2026-06-12 全量遍历,已坐实)
> **本节已由 2026-06-12 全量实测替换原"推断矩阵"。** 用脚本直连 API 遍历**两个账号**(威立雅租户 + 赛盈地空"数据多"账号 20 项目 / 108 TM / 752 DS逐个 ds 核对 `ddCode` + `dsTypeCode` + 真实渲染。Phase 0 探查目标基本达成。
### 1.0 实测方法(脚本,已验证可用,勿臆测照此复刻)
枚举 DS 的 API 链(**踩过的坑都在这**
1. 项目列表:`POST /business/my/profile/project/page` body `{pageNo, pageSize}`(注意 **`pageNo` 不是 `pageNum`**)。
2. 结构树:`GET /business/projectStruct/queryProjectStruct/{projectId}` → **扁平节点数组**,每节点 `{id, parentId, type, name, confCode, typeId}`。**`type=1`=项目根或 GS 节点,`type=2`=TM**。层级靠 `parentId` 链:项目根(parentId="0") → GS(type=1) → TM(type=2) → DS。**TM 可能直挂项目、也可能挂在 GS 下**(如雷达项目 项目→GS"北京"→TM
3. DS 列表:`POST /business/dsObject/data/page` body **`{projectId, structParentId:<tmId>, structParentConfType:2, classifyTypeList:[3], pageNo, pageSize}`**。
- ⚠️ **字段是 `structParentId`=tmId+ `structParentConfType:2`,不是 `tmObjectId`**
- ⚠️ **必须带 `classifyTypeList:[3]`**=数据,缺了/换值后端直接 `code:500 sys.internalServerError`
- ⚠️ `pageNo` 不是 `pageNum`
- 时序类另走 `POST /business/dsObject/timeSensor/data/page`(同 body
4. DS 对象关键字段:`ddCode`(如 `dd_grid`)、`dsTypeCode`(英文,如 `WhitenedData`)、`name`(中文数据类型名)、`dsName`(文件名)、`id`dsId
5. 后端对该接口**间歇 500**脚本需重试48 次)。
**铁律纠正(重要):详情渲染由数据集的"真实数据类型"决定URL 详情链接里的 `ddCode` 经常标错。** 实测:「视电阻率数据」「接地电阻」的详情链接都带 `ddCode=dd_inversion_data`,但渲染分别是反演剖面 / 柱状图,完全不同。**客户端策略分派必须用真实数据类型ds 元数据的 `dsTypeCode`/`ddCode`),绝不能用详情链接的 ddCode。**
### 1.1 已确认数据类型10 种 ddCode均有活样本
| ddCode | dsTypeCode | 数据类型(中) | 渲染视图(实测) | 客户端 | 样本 |
|---|---|---|---|---|---|
| `dd_inversion_data` | ERT inversion data | 电阻率(反演)数据TEM反演剖面视电阻率数据 | **① 原数据(Plotly散点,cauto) + 网格数据(等值面+等值线)** 2 页签 | ✅ 已做 | 多 |
| `dd_ert_measurement_data` | ERT raw data | ERT原始数据 | **③ 散点伪剖面**(x=斜距/y=伪深度, 视电阻率着色, A/B/M/N 悬浮, 含反演运算/生成视电阻率/色阶工具) + 数据列表 | 可做 | 多 |
| `dd_ert_measurement_gr_data` | earth resistance | ERT接地电阻 | **② 柱状图**(Y=电阻/欧姆, X=电极点) + 列表 | 可做 | 多 |
| `dd_trajectory_data` | Platform trajectory | ERT电极坐标TEM坐标 | **④ 轨迹**:地图(测线折线) + 列表 + 高程 3 页签 | 可做 | 多 |
| `dd_grid` | WhitenedData | 白化数据 | **⑤ 列表**(序号/x/y 地表点) | 可做 | 有 |
| `dd_gpr_channel_detail` | RADAR_SINGLE_CHANNEL_PROFILE | 雷达单通道剖面数据 | **⑥ 雷达剖面灰度图像(B-scan radargram)** 左 + **单道波形(A-scan wiggle)** 右;工具:对比度滑块/灰度色阶/显示频谱图 | 待需求确认 | 雷达项目 |
| `dd_gpr_channel_image` | RADAR_SINGLE_CHANNEL_IMAGE_LIST | 雷达单通道图片列表 | **⑥ 同上**B-scan 灰度图像 + A-scan 波形,与 channel_detail 同视图) | 待需求确认 | 雷达项目 |
| `dd_radar_channel_trajectory` | RADAR_SINGLE_CHANNEL_TRAJECTORY | 雷达单通道轨迹坐标 | **⑦ 地图轨迹**:真实地理底图(街道/河流) + GPS 测线路径(黄/绿线) + 起点/终点/轨迹点标记 | 待需求确认 | 雷达项目 |
| `dd_radar_rtk_trajectory` | rtk_trajectory | RTK 轨迹坐标 | **⑦ 地图轨迹 + RTK 坐标点设置面板**GPS定位/解状态过滤:固定解/浮点解/单点解/差分、最大距离过滤、数据还原) | 待需求确认 | 雷达项目 |
| `dd_radar_preprocess_data` | RADAR_PREPROCESS_DATA | 雷达预处理数据 | **空白**(无标题/内容,仅空 map 容器)——疑数据中间态、无独立可视详情 | 待需求确认 | 雷达项目 |
> 渲染视图编号①②③④⑤⑥⑦。**全 10 种 ddCode 渲染均已逐个打开实测(看截图)确认。** 归并后**共 7 种渲染视图**(① 散点+网格[已做] / ② 柱状图 / ③ 散点伪剖面 / ④ 轨迹(地图/列表/高程) / ⑤ 列表 / ⑥ 雷达剖面图像(B-scan+A-scan) / ⑦ 地图轨迹(GIS底图+GPS路径)),外加 `dd_radar_preprocess_data` 无独立详情。
>
> 注:④(ERT/TEM 坐标 `dd_trajectory_data` 的"地图/列表/高程") 与 ⑦(雷达 `dd_radar_*_trajectory` 的"GIS底图+GPS路径") 都是"轨迹/地图"族但组件形态不同——④是平面测线轨迹+高程剖面页签,⑦是真实地理底图叠 GPS 路径(+RTK 解过滤);实现时可能各自一个 View 或共用底图 View 加配置。
**5 个 radar/gpr 样本路径**(同一条 GPR 测线):项目 **雷达0331**(projectId=`1454042286333952`) → GS **北京**(`1454042309754880`) → TM **1229cx1-0_160**(`1454042386726912`, confCode=gpr) → DS
- `dd_gpr_channel_detail` dsId=`1454042504011776`、`dd_gpr_channel_image`=`1454042523484160`、`dd_radar_channel_trajectory`=`1454042504060928`、`dd_radar_rtk_trajectory`=`1454042387619840`、`dd_radar_preprocess_data`=`1454042387505152`。
### 1.2 仍无样本(保持 BLOCKED
| 类型 | 接口OpenAPI | 状态 |
|---|---|---|
| TEM 时序 / timeSensor | `POST dd/ert/timeSensor/rows`、`dsObject/timeSensor/data/page` | 🚫 两账号均无此 ds 类型样本TEM 数据实为 dd_inversion_data + dd_trajectory_data。`LineChartView` 折线视图无依据,不实现。 |
| 测井 well logging | OpenAPI 无明确接口 | 🚫 无样本、无接口。 |
| 电流法 currentmethod | `dd/indicator/currentmethod/rows`、`scatter/graph/{dsObjectId}` | 🚫 项目「填埋场监测」有 CurrentMethod 方法类型 TM但无带数据 DS 样本。 |
**小结:** 实测确认 **10 种 ddCode 有样本**ERT/通用 5 种渲染已定 + radar/gpr 5 种渲染待定本轮确认timeSensor/测井/电流法 仍无样本,保持 BLOCKED。客户端已做 1 种(`dd_inversion_data`)。
---
## 文件结构(新建/修改总览)
| 文件 | 动作 | 职责 |
|---|---|---|
| `src/app/panels/chart/IDatasetChartStrategy.hpp` | 改 | 扩展接口:除 `ddCode()` 外,加描述「加载/渲染契约」的虚方法(详见 Task 1.1 |
| `src/app/panels/chart/ErtInversionStrategy.{hpp,cpp}` | 改 | 把 stub 实化为「ERT 反演策略」:声明它需要 chart+grid 加载、用散点/等值面视图 |
| `src/controller/DatasetDetailController.{hpp,cpp}` | 改 | 持 `ChartStrategyRegistry&``openDataset`/`loadGridData` 改为走注册表分派,未注册→`loadFailed("暂不支持…")` |
| `src/app/main.cpp` | 改 | 构造 `ChartStrategyRegistry`,注册 `ErtInversionStrategy`,注入控制器 |
| `tests/controller/test_dataset_detail_controller.cpp` | 改 | 加「未注册 ddCode 优雅降级 / 已注册走加载」用例 |
| `tests/app/test_chart_strategy_registry.cpp` | 改 | 已有降级用例;按接口扩展补充 |
| `docs/superpowers/sample-probe-other-dd-types.md` | 建Phase 0 产出) | 各类 dd 的「样本可得性矩阵 + 渲染规格 + 真实 API 响应夹具索引」 |
| `tests/fixtures/dd/*.json` | 建Phase 0 产出) | 抓到的真实响应,做 DTO 单测夹具(仅有样本的类型) |
| `src/data/dto/DatasetChartDto.{hpp,cpp}` | 改/拆 | 新增各类 dd 的 parse 函数(如 `parseMeasurementScatter`、`parseTimeSensorSeries`)。若文件超 ~400 行则按类型拆 `MeasurementDto.*` / `TimeSensorDto.*` |
| `src/data/api/ApiDatasetRepository.{hpp,cpp}` | 改 | 新增对应 `load*Async`(仅有样本类型)|
| `src/data/api/DatasetLoads.hpp` | 改 | 新增 `*Parts` 结构(仅有样本类型)|
| `src/data/api/DatasetLoadHandles.hpp` + `.cpp` | 改 | 视形态新增句柄类型(如 `LineLoad`/`ImageLoad`),或复用现有 |
| `src/app/panels/chart/LineChartView.{hpp,cpp}` | 建(仅当 TEM/测井解锁) | 折线视图QwtPlot + QwtPlotCurve |
| `src/app/panels/chart/ImageChartView.{hpp,cpp}` | 建(仅当 GPR 解锁) | 图像剖面视图 |
| `src/app/CMakeLists.txt` / `tests/CMakeLists.txt` | 改 | 注册新文件/测试 |
---
## Phase 0样本探查必做先于一切实现
> 目的:把矩阵里的「待核对/BLOCKED」逐一坐实。**不写任何渲染代码。**产出是「样本规格 + 真实响应夹具」,作为后续 TDD 的 RED 依据。
- [ ] **Task 0.1(核对 ddCode 真值)** 用 Playwright 登录原版 `http://tenant.geomative.cn`,进入 `#/projectSpace/datasetMange/datasetInfo`,遍历可见数据集,抓每个数据集列表项的 `ddCode` 字段(网络响应或前端状态)。把真实 ddCode 回填到本 plan §1 矩阵。→ verify矩阵「推断 ddCode」列全部替换为实测值无残留「待核对」。
- [ ] **Task 0.2(逐类样本探查)** 对 ERT 测量原始 / ERT 测量gr / TEM / dd_grid / 轨迹 / 测井 / GPR 七类,逐一在原版找「有数据的对象」:
- 打开其详情页,截图渲染形态(散点?等值面?折线?图像?);
- 在 Network 抓其取数请求的**完整 URL含 query/path 参数)+ 完整响应 JSON**
- 记录坐标轴语义x/y 单位、方向、是否等比)、色阶/图例有无、是否有异常叠加。
- → verify每类得出「有样本 / 无样本」结论,有样本的把响应存到 `tests/fixtures/dd/<type>.json`,写进 `docs/superpowers/sample-probe-other-dd-types.md`
- [ ] **Task 0.3(产出规格文档)**`docs/superpowers/sample-probe-other-dd-types.md`:每类一节,含「样本可得性 / 真实接口 / 响应结构 / 渲染规格 / 渲染归类 / 是否解锁」。无样本类明确标 **BLOCKED待样本**,列「解锁前置条件」(如「需租户导入 GPR 数据 / 需测井样本数据集」)。→ verify文档七类齐全与 §1 矩阵一致。
- [ ] **Task 0.4(提交)** `docs: dd 类型样本探查矩阵 + 真实响应夹具`。提交规格文档与 fixtures。
**Phase 0 决策门:** 完成后回到本 plan把 §1 矩阵中实测有样本的类型从「候选」升为 Phase 2+ 的实现任务;无样本的保持 BLOCKED不进入实现 Phase。
---
## Phase 1打通策略分派骨架前置无须样本即可做
> 把控制器从硬编码 `dd_inversion_data` 改为走 `ChartStrategyRegistry`。**行为对 `dd_inversion_data` 必须零回归(仍正常出图),对未注册类型仍降级「暂不支持」。** 这是所有后续 dd 类型的地基。
- [ ] **Task 1.1(扩展策略接口)** 先读 `IDatasetChartStrategy.hpp`、`DatasetDetailController.{hpp,cpp}` 真实签名。把 `IDatasetChartStrategy` 从「只有 `ddCode()`」扩展为能表达「该类型支持哪些加载阶段」的最小契约,**避免过度设计YAGNI**。建议最小形:
- 保留 `std::string ddCode() const`
- 加 `bool hasGridPhase() const`ERT 反演=true纯散点/折线/图像类=false—— 让控制器据此决定是否允许 `loadGridData`,替代当前对 `loadGridData` 的硬编码 ddCode 判断。
- (渲染分派暂不进接口:现阶段 `chartReady`/`gridReady` 信号 + 现有 `DatasetDetailPage` 已驱动渲染;待新 View 真要接入时再在对应 Phase 扩展,不在 Phase 1 预先抽象。)
- 先改 `tests/app/test_chart_strategy_registry.cpp`:加「策略报告 hasGridPhase」的断言RED→ 改接口 + `ErtInversionStrategy`GREEN
- → verify`dev-test` `ChartStrategyRegistry.*` 绿。
- [ ] **Task 1.2(控制器走注册表 — openDataset**`DatasetDetailController`:构造函数加 `app::ChartStrategyRegistry& registry`(与现有 `IAsyncDatasetRepository&` 并列)。`openDataset`:把 `if (ddCode != "dd_inversion_data")` 改为 `if (!registry.supports(ddCode)) { emit loadFailed(dsId, "暂不支持该数据类型的预览"); return; }`,其余加载逻辑暂不变(仍走 `loadChartAsync`)。
- 先改 `tests/controller/test_dataset_detail_controller.cpp`:用「空注册表 → openDataset 任意 ddCode → 收到 loadFailed」+「注册了 dd_inversion_data → 走加载mock repo 的 loadChartAsync 被调用」两个用例RED。读现有该测试看 mock repo/句柄如何桩(`tests/data/test_dataset_load_handles.cpp` 有句柄桩可参考)。
- → verify`DatasetDetailController.*` 测试绿。
- [ ] **Task 1.3(控制器走注册表 — loadGridData** `loadGridData`:把 `if (ddCode != "dd_inversion_data") return;` 改为「查策略,`!strategy || !strategy->hasGridPhase()` → return」。补单测注册一个 `hasGridPhase()==false` 的 fake 策略 → `loadGridData` 不触发 `loadGridAsync`。→ verify测试绿。
- [ ] **Task 1.4main.cpp 接线)**`main.cpp` 构造 `geopro::app::ChartStrategyRegistry registry;``registry.add(std::make_unique<ErtInversionStrategy>());`,把 `registry` 注入 `DatasetDetailController detailCtrl(datasetRepo, registry);`。注意生命周期registry 须比 detailCtrl 活得久同作用域、registry 在前声明)。
- → verify`dev-build` 通过;启动 app双击 ERT 反演 ds **零回归**(原数据散点 + 网格等值面 + 异常均正常出图);双击其它类型仍显示「暂不支持」。
- [ ] **Task 1.5(提交)** `refactor(detail): 控制器按 ddCode 走 ChartStrategyRegistry 分派, 未注册优雅降级 (替代硬编码 dd_inversion_data)`
---
## Phase 2ERT 测量类(散点形态,仅当 Task 0.2 确认有样本才进行)
> 🔓 解锁条件Phase 0 确认 ERT 测量(`measurement/scatter/graph` 或 `measurement/rows`)有活样本且抓到真实响应。**未解锁则本 Phase 全部 BLOCKED跳过。**
> 渲染归类:散点类 → **复用** `ScatterPlotItem`/`RawDataChartView`,不新建 ViewDRY
> 接口(已核对):`GET /business/dd/ert/measurement/scatter/graph?dsObjectId=&vFieldCode=`、`GET /business/dd/ert/measurement/rows?dsObjectId=`。注意:**dsObjectId 是 query 参数(≠ 反演的 path 参数)**,且 scatter 需 `vFieldCode`Phase 0 抓其真实取值)。
- [ ] **Task 2.1DTO 解析 + 单测)**`tests/fixtures/dd/ert-measurement-scatter.json` 真实响应做夹具,先写 `tests/data/test_dataset_chart_dto.cpp` 新用例断言字段映射RED→ 在 `DatasetChartDto.cpp`(或新拆 `MeasurementDto.cpp`,若主文件超 ~400 行)实现 `parseMeasurementScatter(QJsonObject)`GREEN。映射须严格按真实响应字段**不臆造字段名**。→ verifyDTO 测试绿。
- [ ] **Task 2.2(色阶处置)** Phase 0 确认测量散点是否有独立色阶接口/type反演散点用 `colorGradation/getDetail` type1。若有按真实 type 拉若无色阶纯散点ColorBar 隐藏。补单测覆盖色阶解析或缺省。→ verify测试绿。
- [ ] **Task 2.3(异步句柄 + 仓储)** 复用 `ChartLoad`/`ChartParts`(结构相同则直接复用;字段不同则在 `DatasetLoads.hpp``MeasurementParts` + 句柄)。在 `ApiDatasetRepository``loadMeasurementChartAsync(dsId)``ApiBatch` 组 scatter[+色阶] 请求 + 注入解析)。注意 query 参数编码(参考现有 `enc()`)。补 `tests/data/test_dataset_load_handles.cpp` 用例。→ verify测试绿。
- [ ] **Task 2.4(策略 + 控制器分派)** 新建 `MeasurementStrategy``ddCode()` 用 Task 0.1 实测值,`hasGridPhase()` 按 rows 是否等值面而定)。`DatasetDetailController::openDataset` 据策略选择调 `loadMeasurementChartAsync`(若加载形态与反演不同,控制器按 ddCode/策略分支;保持函数 <50 必要时抽私有 helper)。`main.cpp` 注册该策略补控制器单测。→ verify测试绿
- [ ] **Task 2.5(接入视图 + 视觉验收)** `DatasetDetailPage`/`RawDataChartView` 接收 `chartReady` 渲染散点。启动 app 双击测量类 ds**对照 Phase 0 截图逐项验收**(点形/色阶/轴/等比),截图发用户确认。→ verify视觉等价。
- [ ] **Task 2.6rows 形态)** 若 Phase 0 显示 `measurement/rows` 是另一种展示(如伪剖面/列表),据规格归类(等值面→复用 `ContourPlotItem`;表格→另议),重复 2.12.5 节奏。**形态不明则 BLOCKED 待 Phase 0 规格。**
- [ ] **Task 2.7(提交)** 每 12 个 Task 一次原子提交,保持构建绿。`feat(detail): ERT 测量散点详情图 (DTO+仓储+策略+视图)`。
---
## Phase 3TEM / 设备时序(折线形态,仅当 Task 0.2 确认有样本才进行)
> 🔓 解锁条件Phase 0 确认 TEM/timeSensor 有活样本且抓到响应。**未解锁 BLOCKED跳过。**
> 渲染归类:时序折线类 → **新建** `LineChartView`QwtPlot + `QwtPlotCurve`x=时间y=数值)。
> 接口(已核对):`POST /business/dd/ert/timeSensor/rows`body `DDTimeSensorDataQueryReqVO`Phase 0 抓真实 body 字段)、`GET dd/ert/timeSensor/page`。
- [ ] **Task 3.1DTO 解析 + 单测)** 用真实响应夹具 `tests/fixtures/dd/tem-timesensor.json`先写测试RED→ 实现 `parseTimeSensorSeries`(→ 时间数组 + 多通道数值序列模型;模型若无现成 core 类型,加最小 `core::TimeSeries`YAGNI。→ verify测试绿。
- [ ] **Task 3.2(异步句柄 + 仓储)** 因是 POST + 不同返回,加 `SeriesLoad`/`SeriesParts` 句柄(参照 `ApiChartLoad`/`ApiGridLoad` 写 `ApiSeriesLoad`+ `loadTimeSensorAsync(dsId)``postJsonAsync` 组 body。补句柄桩单测。→ verify测试绿。
- [ ] **Task 3.3LineChartView + 组件测)** 新建 `src/app/panels/chart/LineChartView.{hpp,cpp}`QwtPlot + 每通道一条 `QwtPlotCurve` + 图例 + 平移/缩放(复用 `LivePanner` 模式)。组件测:给定 series → 断言曲线条数/点数。→ verify测试绿。
- [ ] **Task 3.4(策略 + 控制器 + 页面接入)** `TimeSensorStrategy`(实测 ddCode`hasGridPhase()==false`)。控制器据策略走 `loadTimeSensorAsync` → 新增 `seriesReady` 信号 → `DatasetDetailPage``LineChartView` 渲染(页内页签按类型选 View。main.cpp 注册。补控制器单测。→ verify测试绿 + 启动 app 双击 TEM ds对照 Phase 0 截图验收。
- [ ] **Task 3.5(提交)** `feat(detail): TEM 时序折线详情图 (LineChartView+DTO+仓储+策略)`
---
## Phase 4BLOCKED待样本—— dd_grid / 轨迹 / 测井 / GPR
> 以下类型当前租户**无活样本**spec §2.4 + Phase 0 复核)。**绝不规划凭想象的渲染任务**(违反 1:1 复刻原则)。本 Phase 只列「解锁前置条件 + 调研占位」,待样本到位后各自展开为类似 Phase 2/3 的 TDD 任务序列。
- [ ] **Task 4.1dd_grid 网格)** 🚫 BLOCKED。
- 解锁前置:租户内出现带数据的 dd_grid 对象Phase 0 抓 `GET dd/ert/grid/rows?dsObjectId&pageNo&pageSize` 真实响应 + 详情页截图。
- 预归类:等值面类 → 复用 `ContourPlotItem`/`GridDataChartView`(与反演 rows 同构则可大量复用 `parseInversionGrid` 思路;**须实测响应字段是否一致再决定复用/新 parse**)。
- 注意grid/rows 带分页参数,须确认是否需多页拼接。
- [ ] **Task 4.2(轨迹 trajectory** 🚫 BLOCKED。
- 解锁前置:带数据的轨迹对象,抓 `GET dd/ert/trajectory/rows?dsObjectId` + `GET dd/ert/trajectory/line?dsObjectId&frontCrsCode` 响应 + 截图。
- 预归类:路径/折线类 → 待样本定(散点连线 or `LineChartView`)。`frontCrsCode` 取值须 Phase 0 抓。
- [ ] **Task 4.3(测井 well logging** 🚫 BLOCKED双重阻塞无样本 + 接口未确认)。
- 解锁前置:(a) 拿到测井数据样本;(b) **OpenAPI 未见明确测井 rows 接口** → Phase 0 须在原版「测井参数表」相关页面抓真实请求确认接口。
- 预归类:折线类 → 复用 `LineChartView`y=深度向下 / x=数值;或 x=时间 y=数值曲线,按菜单「测井参数表」两种形态,实测后定)。
- [ ] **Task 4.4GPR 雷达剖面)** 🚫 BLOCKED。
- 解锁前置GPR 对象有数据spec 明确当前 GPR 对象无数据),抓 `GET dd/gpr/channel/image/{dsObjectId}` 响应(确认返回是图片 URL / base64 / 像素数组)+ 详情页截图。
- 预归类:图像类 → 新建 `ImageChartView`(位图剖面 + 坐标轴叠加)。返回形态决定加载方式(图片下载 vs JSON 像素)。
- 参考相关接口:`dd/gpr/channel/trace/spectrogram`、`dd/gpr/channel/querySegmentation`、`radar/trajectory/ds/{dsObjectId}`。
---
## 任务顺序与可构建性
1. **Phase 0 先行**无样本不写代码。Phase 0 是纯调研 + 夹具,零代码风险。
2. **Phase 1 是地基**:策略分派打通后,每个新 dd 类型才能「注册即生效」。Phase 1 必须保证 ERT 反演零回归(已有 89/89 测试 + 视觉验收兜底)。
3. **接口/控制器改形原子落地**`DatasetDetailController` 构造函数签名变更(加 registry+ `main.cpp` 接线**须在同一提交**完成(否则构建红)。参照详情图 v2 「Task5+6 合并」教训:跨文件签名变更不拆提交。
4. **Phase 2/3 仅在 Phase 0 解锁后展开**Phase 4 永远 BLOCKED 直到样本到位。
5. **每 12 个 bite-sized Task 一次提交**,每次提交前 `dev-test` 必须绿。
## 范围边界
- 本 plan 仅「详情图渲染扩展」。**不含**工具条编辑功能(白化/滤波/色阶配置/异常框注/自动标注/网格化/另存为/导出/描述富文本/大视图全屏)—— 另案spec §2.3)。
- 不改中央 2D/3D VTK 地图视图。
- 不改 ApiClient 异步机制(已单独立项,详情详情链路已异步)。
---
## Self-Review写完计划的自查结论
- **核实优先**:所有接口路径/参数query vs path、POST body schema均已对 `docs/apis/business_OpenAPI.json` 核对(见 §1 表未臆造。ddCode 除 `dd_inversion_data` 外源码无常量,已明确标注「待 Phase 0 实测核对」而非编造。
- **关键现状坐实**:已读 `DatasetDetailController.cpp` 确认控制器硬编码 `dd_inversion_data`、未用注册表;`ErtInversionStrategy` 是空 stub。Phase 1「打通分派」据此设计是真实缺口而非假想。
- **诚实对待样本约束**:唯一确定有样本的是已完成的反演;其余全部置于 Phase 0 探查门之后。无样本类dd_grid/轨迹/测井/GPR一律 BLOCKED + 解锁前置条件,**未规划任何凭想象的渲染任务**,符合 1:1 复刻铁律。
- **DRY/复用**:散点复用 `ScatterPlotItem`、等值面复用 `ContourPlotItem`、异步复用 `ApiBatch`/句柄模式仅折线TEM/测井与图像GPR两种新形态新建 View且各自有样本/解锁条件兜底。
- **可构建性**:标注了「构造函数签名变更 + main.cpp 接线须同提交」的原子落地约束,避免中间态构建红。
- **TDD 无占位符**:每个可推进 Task 给出确切文件路径、RED→GREEN 顺序、verify 命令BLOCKED Task 给出明确解锁前置条件而非空泛描述。
- **风险点**(1) Phase 0 可能发现 ERT 测量/TEM 也无具体带数据对象 → 则 Phase 2/3 同样 BLOCKEDplan 仍成立Phase 1 独立有价值)。(2) `IDatasetChartStrategy` 接口扩展刻意保守(只加 `hasGridPhase()`),避免为未解锁类型过度抽象;待新 View 真接入时再演进。

View File

@ -3,6 +3,23 @@
> 日期 2026-06-11。范围把数据集详情路径从同步阻塞改为异步非阻塞作为全 App 异步化的模式样板。 > 日期 2026-06-11。范围把数据集详情路径从同步阻塞改为异步非阻塞作为全 App 异步化的模式样板。
> 后续会按此模式铺开到导航/登录路径(本期不做)。 > 后续会按此模式铺开到导航/登录路径(本期不做)。
## 状态更新2026-06-11
**DatasetDetail 试点:✅ 已完成并通过评审。** 实现计划 `plans/2026-06-11-apiclient-async-datasetdetail.md`8 任务,逐任务 spec+质量双评审 + 整体评审)。测试 75 → 89+14 离线用例)全绿。落地原语:`IApiCall`/`ApiCall`/`ApiBatch`net、`ChartLoad`/`GridLoad`/`IAsyncDatasetRepository`data、控制器 abort-and-replace + 句柄身份比对 + 退出契约、`LoadingOverlay` 网格懒加载遮罩。核心收益落地:详情路径不冻 UI、慢请求可 abort 不回灌、多请求并发 + fail-fast。
**铺开进展2026-06-12 更新):**
- **导航路径 ✅**(计划 `plans/2026-06-11-apiclient-async-rollout.md` Part A新增 `ApiChain`(串行依赖链原语)、`NavRequest`单请求句柄QVariant payload、`IAsyncProjectRepository``WorkbenchNavController` 全异步NavRequest 续延 + 并发计数 + abort-and-replace + 身份比对,删 busy_/drainbusyChanged=在飞存在性)。
- **登录路径 ✅**(同计划 Part BB1/B2/B4`AuthService` 异步(`CaptchaLoad`/`LoginLoad` + `ApiChain` 编排 verify→RSA→login2`LoginWindow` 不冻 + 可取消(析构 abort`test_auth` live 异步化。
- 测试 89 → 116。每块逐任务 spec+质量双评审 + 整体评审通过。
**技术债清除 ✅2026-06-12** `ProjectListDialog` 已迁到 `IAsyncProjectRepository`NavRequest + abort-and-replace + 身份比对 + 析构 abort随即删除同步 `IProjectRepository`、`RepoResult`、`ApiProjectRepository` 9 个同步方法、`ApiClient` 同步 `get/postJson`+`await`A6+B3 解锁完成)。**全 App 网络层现已 100% 异步,无 `QEventLoop` 阻塞、无过渡双接口债。** 测试 116/116。
**结论:异步化主题完成。** 数据详情(试点)+ 导航Part A+ 登录Part B+ 项目列表弹窗全部异步;同步路径彻底移除。
可选 follow-up评审建议非阻断纯整洁`DatasetDetailController::ChartData.grid/gridScale` 死字段;如未来引入 cross-thread 再补 `qRegisterMetaType<QList<ApiResponse>>()`
> 铺开实现计划:`plans/2026-06-11-apiclient-async-rollout.md`Part A/B + 债务清除均已落地)。
## 1. 背景 ## 1. 背景
geopro 现网络栈三层全同步阻塞: geopro 现网络栈三层全同步阻塞:

View File

@ -2,7 +2,20 @@
- 日期2026-06-11 - 日期2026-06-11
- 分支建议:`feat/dataset-detail-chart` - 分支建议:`feat/dataset-detail-chart`
- 状态:设计(待评审) - 状态:**ERT 反演dd_inversion_data展示功能已落地并经用户验收**;其余 dd 类型 + 工具条编辑功能待后续
## 状态更新2026-06-11
**架构偏离(重要):** spec 原定渲染器为 **QGraphicsView**,实际落地改用 **QwtPlot轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**(见返工方案 `plans/2026-06-11-dataset-detail-chart-v2-qwt.md`,权威)。展示结果视觉等价,下文 §5.2/§8 的 QGraphicsView 细节已被 QwtPlot 方案取代,保留作背景参考。
**已完成(仅 `dd_inversion_data` ERT 反演§2.2 展示范围内):** 原数据散点(方形点/白描边/连续色阶/x 轴顶部 + **hover 显 X/Y/值**+ 网格等值面(填充栅格 + 黑色等值线 + 沿线数值标注 + NaN 白边裁剪)+ 色阶图例 + 异常叠加 + 底部异常表/描述 + 多 Tab + 网格数据懒加载 + 页签内滚动/分割条 + 实时平移/滚轮缩放。数据加载已异步化(见 `specs/2026-06-11-apiclient-async-design.md`)。
**2026-06-12 渲染保真修复:** colorBar 是混合 hex+CSS-rgba 格式且 rgba 的 **alpha 为 01 浮点**;原 `DatasetChartDto``AlphaScale::Bit255` 解析致 12/18 段近透明(散点/网格/图例全发白)。修为 `AlphaScale::Unit`(一行根因修复,通用生效)。散点 `ColorMapService` 连续插值的归一化位置已证 1:1 等于原版 Plotly colorscale。新增 `ScatterHoverTip` 复刻原版 hovertemplate。详见 `HANDOFF-dataset-detail-chart.md §0.1`
**未完成:**
- **其余 dd 类型的详情图渲染**§2.4`dd_ert_measurement_data`、`dd_ert_measurement_gr_data`、`dd_grid`、`dd_trajectory_data`、测井(深度/时序折线、GPR`dd/gpr/channel/image`、TEM 等。控制器目前对非 `dd_inversion_data` 直接「暂不支持该类型预览」。**现实约束:当前租户仅 ERT/TEM/GPR 三类GPR 对象无数据、无测井数据 → 多数类型无活样本,须先取样本。** 实现计划见 `plans/2026-06-11-dataset-detail-other-dd-types.md`(如已生成)。
- **工具条编辑功能**§2.3,范围外/后续单独立项):白化 / 滤波处理 / 色阶配置 / 异常框注 / 自动标注 / 网格化 / 另存为 / 导出 / 描述富文本 / 大视图全屏。当前为占位按钮。
- 加载态:网格懒加载已有「加载中」遮罩;原数据初次加载仅 busy 光标,未做骨架屏。
- 参考材料: - 参考材料:
- 客户端菜单:`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`「客户端」页签R051R096、「测井参数表」「DD类型」 - 客户端菜单:`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`「客户端」页签R051R096、「测井参数表」「DD类型」
- 原 web 系统:`http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo`(经 Playwright 操作页面 + 抓取 JS chunk 做源码级分析) - 原 web 系统:`http://tenant.geomative.cn/#/projectSpace/datasetMange/datasetInfo`(经 Playwright 操作页面 + 抓取 JS chunk 做源码级分析)
@ -43,8 +56,53 @@
网格化参数GridDialog、色阶配置colorEditor、白化WhiteningDialog、滤波/迭代处理、异常框注/自动标注AutoAnnotationDialog、另存为SaveAsDialog、导出ExportDialog、描述富文本编辑、大视图(Esc) 全屏。 网格化参数GridDialog、色阶配置colorEditor、白化WhiteningDialog、滤波/迭代处理、异常框注/自动标注AutoAnnotationDialog、另存为SaveAsDialog、导出ExportDialog、描述富文本编辑、大视图(Esc) 全屏。
### 2.4 后续 dd 类型(框架内扩展,非本 spec ### 2.4 后续 dd 类型(框架内扩展,非本 spec
`dd_ert_measurement_data` / `dd_ert_measurement_gr_data` / `dd_grid` / `dd_trajectory_data`、测井(`y深度-x数值折线` / `x时间-y数值曲线`见「测井参数表」、GPR`dd/gpr/channel/image`、TEM 等。 其余 dd 类型详情图见下文 **§2.52026-06-12 全量实测编目)** 与实现计划 `plans/2026-06-11-dataset-detail-other-dd-types.md`。客户端按真实数据类型走 `ChartStrategyRegistry` 分派。
> 现实约束:当前可访问租户只有 ERT/TEM/GPR 三类,且 GPR 对象无数据、无测井数据。其它 dd 类型详情页**无活样本可参照**,须待拿到数据样本后再精确复刻。
### 2.5 数据类型全景:设计 taxonomyExcelvs 实测运行 ddCode + 渲染映射2026-06-12
> 用脚本直连 API 全量遍历两个账号(威立雅 + 赛盈地空"数据多"账号20 项目/108 TM/752 DS+ 逐个打开详情看截图确认渲染。**关键纠正:详情渲染由数据集"真实数据类型"决定URL 详情链接的 `ddCode` 经常标错,客户端分派须用 ds 元数据的真实类型。**
#### 2.5.1 实测 10 种 ddCode → 7 种渲染视图(均有活样本、看截图确认)
| 渲染视图 | ddCode | 数据类型 | 客户端 |
|---|---|---|---|
| **① 原数据(Plotly散点,cauto) + 网格数据(等值面+等值线)** | `dd_inversion_data` | ERT/TEM 反演剖面、视电阻率数据 | ✅ 已做 |
| **② 柱状图(Y=电阻欧姆,X=电极点) + 列表** | `dd_ert_measurement_gr_data` | ERT接地电阻 | ❌ |
| **③ 散点伪剖面(斜距/伪深度,视电阻率着色,反演运算工具) + 数据列表** | `dd_ert_measurement_data` | ERT原始数据 | ❌ |
| **④ 轨迹:地图 + 列表 + 高程 3页签** | `dd_trajectory_data` | ERT电极坐标、TEM坐标 | ❌ |
| **⑤ 列表(序号/x/y)** | `dd_grid` | 白化数据 | ❌ |
| **⑥ 雷达剖面灰度图像(B-scan) + 单道波形(A-scan) + 对比度/灰度色阶/频谱** | `dd_gpr_channel_detail`、`dd_gpr_channel_image` | 雷达单通道剖面/图片列表 | ❌ |
| **⑦ 地图轨迹(真实GIS底图 + GPS路径 + 起点/终点)**RTK 另带坐标点设置/解状态过滤面板) | `dd_radar_channel_trajectory`、`dd_radar_rtk_trajectory` | 雷达单通道轨迹/RTK轨迹 | ❌ |
| (无独立可视详情,疑数据中间态) | `dd_radar_preprocess_data` | 雷达预处理数据 | — |
**共 7 种渲染视图,客户端已做 1 种(①);待做 6 种:② 柱状图、③ 散点伪剖面、④ 轨迹、⑤ 列表、⑥ 雷达剖面图像、⑦ 地图轨迹。**
#### 2.5.2 与 `Geopro3.0 菜单.xlsx`「DD类型」设计 taxonomy 的对应
Excel「DD类型」页签是**设计期格式蓝图**,用的是另一套命名(`dd_Section`/`dd_Track`/`dd_ERTRawData`/`dd_GPRSection`/`dd_Images`/`dd_TimeVarious`…)。与实际运行 ddCode **命名对不上、语义对得上**
| Excel 设计格式 | 描述(Excel) | 对应实测运行 ddCode | 样本 |
|---|---|---|---|
| `dd_Section`"最重要":水平/垂直剖面) | 反演剖面 | `dd_inversion_data`(①)、`dd_gpr_channel_detail`(⑥) | ✅ |
| `dd_ERTRawData` | ERT原始数据 | `dd_ert_measurement_data`(③)、`dd_ert_measurement_gr_data`(②) | ✅ |
| `dd_GPRSection`(设计注:讨论是否并入 dd_Section | 雷达剖面 | `dd_gpr_channel_detail`/`image`(⑥) | ✅ |
| `dd_Track`(坐标+轨迹合并) | 电极坐标/采集轨迹 | `dd_trajectory_data`(④)、`dd_radar_*_trajectory`(⑦) | ✅ |
| `dd_Images` | 图片类 | `dd_gpr_channel_image`(⑥) | ✅ |
| `dd_Files` | 文件打包(如三维雷达原始包) | `dd_radar_preprocess_data`(疑) | ✅部分 |
| `dd_TimeVarious` | 时间连续变量 | =设计中的 TEM时序/timeSensor 折线 | ❌ 无样本 |
| `dd_Stratigraphy` | 地层编录/**测井** | =设计中的测井折线/深度 | ❌ 无样本 |
| `dd_Segy` | 地震 SEGY(只展示) | — | ❌ 无地震数据 |
| `dd_Sampling` | 采样/化学(XRF/VOCs) | — | ❌ |
| `dd_LinearVarious` / `dd_Structual3D` / `dd_Property3D` / `dd_Video` | 线性变量/三维结构/三维属性/视频 | — | ❌ |
| `dd_KML`/`dd_GeoJson`/`dd_shp`/`dd_dem`/`dd_bim`/`dd_czml`… | 背景资料**图层**(导入/预览/坐标对齐) | — | 非"数据详情图",是地图图层 |
**结论:** Excel 是"应有哪些数据格式"的设计蓝图;实测 10 种是"现在真有数据、详情页真能出图"的子集(电法 ERT + 地质雷达 GPR 两大类的落地。两者语义吻合Excel 里 时序(dd_TimeVarious)/测井(dd_Stratigraphy)/地震(dd_Segy)/采样/三维模型/视频 等当前**无活样本BLOCKED**(与 plan §1.2 一致KML/shp/dem/bim 等是地图图层,不属本 spec 的详情图范畴。
#### 2.5.3 枚举 DS 的 API实测可用踩坑记录
`POST my/profile/project/page`{pageNo,pageSize} → `GET projectStruct/queryProjectStruct/{pid}`扁平节点type=1=项目/GStype=2=TMparentId 链表层级TM 可挂项目或 GS 下)→ `POST dsObject/data/page` body **`{projectId, structParentId:<tmId>, structParentConfType:2, classifyTypeList:[3], pageNo, pageSize}`**(字段是 `structParentId` 不是 `tmObjectId`;必须带 `classifyTypeList:[3]` 否则后端 `code:500``pageNo` 不是 `pageNum`;时序类另走 `dsObject/timeSensor/data/page`;后端间歇 500 需重试。DS 字段:`ddCode`+`dsTypeCode`(英文)+`name`(中文类型名)+`dsName`(文件)。
> 现实约束:当前可访问租户实测有样本的方法类为 ERT/TEM/GPR测井/地震/采样/三维/时序无活样本。无样本类详情页**无可参照**,须待数据样本到位后再精确 1:1 复刻。
--- ---

View File

@ -29,18 +29,21 @@ add_executable(geopro_desktop WIN32
panels/DescriptionPanel.cpp panels/DescriptionPanel.cpp
panels/chart/RawDataChartView.cpp panels/chart/RawDataChartView.cpp
panels/chart/GridDataChartView.cpp panels/chart/GridDataChartView.cpp
panels/chart/ChartTheme.cpp
panels/chart/ColorMapService.cpp panels/chart/ColorMapService.cpp
panels/chart/ColorBarWidget.cpp panels/chart/ColorBarWidget.cpp
panels/chart/ScatterPlotItem.cpp panels/chart/ScatterPlotItem.cpp
panels/chart/ContourPlotItem.cpp panels/chart/ContourPlotItem.cpp
panels/chart/LivePanner.cpp panels/chart/LivePanner.cpp
panels/chart/ScatterHoverTip.cpp
panels/AnomalyTablePanel.cpp panels/AnomalyTablePanel.cpp
panels/LoadingOverlay.cpp panels/LoadingOverlay.cpp
panels/DatasetDetailPage.cpp panels/DatasetDetailPage.cpp
panels/DatasetDetailPanel.cpp panels/DatasetDetailPanel.cpp
CentralScene.cpp CentralScene.cpp
ProjectListDialog.cpp ProjectListDialog.cpp
SettingsDialog.cpp) SettingsDialog.cpp
Logging.cpp)
target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
# QtKeychain FetchContent target / export # QtKeychain FetchContent target / export
@ -68,6 +71,11 @@ endif()
vtk_module_autoinit(TARGETS geopro_desktop MODULES ${VTK_LIBRARIES}) vtk_module_autoinit(TARGETS geopro_desktop MODULES ${VTK_LIBRARIES})
# minidumpDbgHelpWindows MiniDumpWriteDump
if(WIN32)
target_link_libraries(geopro_desktop PRIVATE Dbghelp)
endif()
if(WIN32) if(WIN32)
add_custom_command(TARGET geopro_desktop POST_BUILD add_custom_command(TARGET geopro_desktop POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different COMMAND ${CMAKE_COMMAND} -E copy_if_different

243
src/app/Logging.cpp Normal file
View File

@ -0,0 +1,243 @@
#include "Logging.hpp"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfoList>
#include <QMutex>
#include <QStandardPaths>
#include <QtGlobal>
#include <cstdio>
#include <cstdlib>
#include <exception>
#ifdef Q_OS_WIN
// clang-format off
#include <windows.h>
#include <dbghelp.h> // MiniDumpWriteDump链接 Dbghelp
// clang-format on
#endif
namespace geopro::app {
namespace {
QFile g_logFile;
QMutex g_mutex;
QString g_logDir;
constexpr int kRetentionDays = 14; // 旧日志/dump 保留天数
const char* levelStr(QtMsgType t) {
switch (t) {
case QtDebugMsg: return "DEBUG";
case QtInfoMsg: return "INFO";
case QtWarningMsg: return "WARN";
case QtCriticalMsg: return "ERROR";
case QtFatalMsg: return "FATAL";
}
return "INFO";
}
// 线程安全写一行(落盘 + 同步到 stderr 便于开发期观察)。
void writeLine(const QString& line) {
const QByteArray utf8 = line.toUtf8();
QMutexLocker lock(&g_mutex);
if (g_logFile.isOpen()) {
g_logFile.write(utf8);
g_logFile.write("\n", 1);
g_logFile.flush();
}
std::fwrite(utf8.constData(), 1, static_cast<size_t>(utf8.size()), stderr);
std::fputc('\n', stderr);
}
void messageHandler(QtMsgType type, const QMessageLogContext& ctx, const QString& msg) {
const QString ts = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz"));
QString line = QStringLiteral("%1 [%2] %3").arg(ts, QString::fromLatin1(levelStr(type)), msg);
if (ctx.file && type >= QtWarningMsg) // 警告及以上带源码定位,便于排查
line += QStringLiteral(" (%1:%2)").arg(QString::fromUtf8(ctx.file)).arg(ctx.line);
writeLine(line);
if (type == QtFatalMsg) {
std::abort(); // qFatal 语义:记录后终止(触发崩溃捕获 → dump
}
}
void pruneOldFiles(const QString& dir) {
const QDateTime cutoff = QDateTime::currentDateTime().addDays(-kRetentionDays);
const QFileInfoList files =
QDir(dir).entryInfoList({QStringLiteral("geopro_*.log"), QStringLiteral("crash_*.dmp")},
QDir::Files);
for (const QFileInfo& fi : files)
if (fi.lastModified() < cutoff) QFile::remove(fi.absoluteFilePath());
}
#ifdef Q_OS_WIN
// 崩溃时(写完 dump 后)追加一行摘要。直接写已打开的 g_logFile进程将终止不抢 g_mutex —
// 否则崩溃发生在持锁线程时会死锁另开第二句柄又会因独占共享冲突失败。best-effort。
void appendCrashLine(const QString& line) {
const QByteArray utf8 = line.toUtf8();
if (g_logFile.isOpen()) {
g_logFile.write(utf8);
g_logFile.write("\n", 1);
g_logFile.flush();
}
std::fwrite(utf8.constData(), 1, static_cast<size_t>(utf8.size()), stderr);
std::fputc('\n', stderr);
}
LONG WINAPI crashFilter(EXCEPTION_POINTERS* info) {
const DWORD code = (info && info->ExceptionRecord) ? info->ExceptionRecord->ExceptionCode : 0;
const void* addr = (info && info->ExceptionRecord) ? info->ExceptionRecord->ExceptionAddress : nullptr;
const QString ts = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss_zzz"));
const QString dumpPath = g_logDir + QStringLiteral("/crash_") + ts + QStringLiteral(".dmp");
bool dumped = false;
HANDLE hFile = CreateFileW(reinterpret_cast<const wchar_t*>(dumpPath.utf16()), GENERIC_WRITE, 0,
nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (hFile != INVALID_HANDLE_VALUE) {
MINIDUMP_EXCEPTION_INFORMATION mei{};
mei.ThreadId = GetCurrentThreadId();
mei.ExceptionPointers = info;
mei.ClientPointers = FALSE;
const MINIDUMP_TYPE flags = static_cast<MINIDUMP_TYPE>(
MiniDumpWithDataSegs | MiniDumpWithThreadInfo | MiniDumpWithHandleData);
dumped = MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), hFile, flags,
info ? &mei : nullptr, nullptr, nullptr);
CloseHandle(hFile);
}
appendCrashLine(QStringLiteral("%1 [FATAL] 崩溃 code=0x%2 addr=0x%3 dump=%4")
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")))
.arg(static_cast<quint32>(code), 0, 16)
.arg(reinterpret_cast<quintptr>(addr), 0, 16)
.arg(dumped ? dumpPath : QStringLiteral("写入失败")));
return EXCEPTION_EXECUTE_HANDLER; // 记录后终止进程
}
#endif // Q_OS_WIN
#ifdef Q_OS_WIN
// 向量化异常处理器:在 C++ 异常0xE06D7363**抛出瞬间**(栈未展开、进程健康、符号匹配)
// 捕获并符号化调用栈写日志。即使异常随后被 try/catch如顶层护栏吞掉也已留下抛点堆栈。
constexpr DWORD kCppExceptionCode = 0xE06D7363;
thread_local bool g_inVeh = false; // 防符号化过程自身再抛异常导致的重入
LONG WINAPI throwStackVeh(EXCEPTION_POINTERS* info) {
if (!info || !info->ExceptionRecord) return EXCEPTION_CONTINUE_SEARCH;
if (info->ExceptionRecord->ExceptionCode != kCppExceptionCode) return EXCEPTION_CONTINUE_SEARCH;
if (g_inVeh) return EXCEPTION_CONTINUE_SEARCH;
g_inVeh = true;
void* frames[32];
const USHORT n = CaptureStackBackTrace(1, 32, frames, nullptr);
const HANDLE proc = GetCurrentProcess();
SymRefreshModuleList(proc); // 确保已加载模块(含本 exe 的 PDB在符号表中
alignas(SYMBOL_INFOW) char symBuf[sizeof(SYMBOL_INFOW) + 1024] = {};
auto* sym = reinterpret_cast<SYMBOL_INFOW*>(symBuf);
sym->SizeOfStruct = sizeof(SYMBOL_INFOW);
sym->MaxNameLen = 1024 / sizeof(wchar_t) - 1;
appendCrashLine(QStringLiteral("%1 [THROW] C++ 异常抛出,调用栈:")
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz"))));
for (USHORT i = 0; i < n; ++i) {
const DWORD64 addr = reinterpret_cast<DWORD64>(frames[i]);
// 模块名 + RVA总是可得即使符号未解析也能离线用匹配 PDB 还原。
QString modRva;
HMODULE mod = nullptr;
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(addr), &mod) &&
mod) {
wchar_t mpath[MAX_PATH] = {};
GetModuleFileNameW(mod, mpath, MAX_PATH);
QString base = QString::fromWCharArray(mpath);
base = base.mid(base.lastIndexOf('\\') + 1);
modRva = QStringLiteral("%1+0x%2").arg(base).arg(addr - reinterpret_cast<DWORD64>(mod), 0, 16);
} else {
modRva = QStringLiteral("0x%1").arg(addr, 0, 16);
}
// 符号名宽字符UNICODE 下 SymFromAddrW + WCHAR Name
QString fn;
DWORD64 symDisp = 0;
if (SymFromAddrW(proc, addr, &symDisp, sym))
fn = QStringLiteral(" %1+0x%2").arg(QString::fromWCharArray(sym->Name)).arg(symDisp, 0, 16);
// 文件:行。
QString loc;
DWORD lineDisp = 0;
IMAGEHLP_LINEW64 line = {};
line.SizeOfStruct = sizeof(IMAGEHLP_LINEW64);
if (SymGetLineFromAddrW64(proc, addr, &lineDisp, &line))
loc = QStringLiteral(" (%1:%2)").arg(QString::fromWCharArray(line.FileName)).arg(line.LineNumber);
appendCrashLine(QStringLiteral(" #%1 %2%3%4").arg(i).arg(modRva).arg(fn).arg(loc));
}
g_inVeh = false;
return EXCEPTION_CONTINUE_SEARCH; // 不处理,交回正常流程(顶层护栏 try/catch 仍会接住)
}
#endif // Q_OS_WIN
void installCrashHandlers() {
#ifdef Q_OS_WIN
SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); // 抑制系统崩溃弹窗
SetUnhandledExceptionFilter(crashFilter);
SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME | SYMOPT_FAIL_CRITICAL_ERRORS);
// 显式把 exe 目录作为符号搜索路径geopro_desktop.pdb 与 exe 同目录、匹配);立即(非 deferred)加载。
const QByteArray searchPath = QCoreApplication::applicationDirPath().toLocal8Bit();
if (!SymInitialize(GetCurrentProcess(), searchPath.constData(), TRUE))
std::fprintf(stderr, "[Logging] SymInitialize 失败 err=%lu\n", GetLastError());
// 显式加载本 exe 的符号invade 偶尔不加载主模块的私有符号 → 内部函数无名)。
wchar_t selfPath[MAX_PATH] = {};
GetModuleFileNameW(nullptr, selfPath, MAX_PATH);
const DWORD64 selfBase =
SymLoadModuleExW(GetCurrentProcess(), nullptr, selfPath, nullptr,
reinterpret_cast<DWORD64>(GetModuleHandleW(nullptr)), 0, nullptr, 0);
if (selfBase == 0 && GetLastError() != ERROR_SUCCESS)
std::fprintf(stderr, "[Logging] SymLoadModuleExW 失败 err=%lu\n", GetLastError());
AddVectoredExceptionHandler(1, throwStackVeh); // first=1先于 SEH 链,抛出瞬间捕获
#endif
// 未捕获 C++ 异常(跨事件循环逃逸)→ 记录后终止。SEH 过滤器通常已先写 dump。
std::set_terminate([] {
QString what = QStringLiteral("(无异常信息)");
if (std::exception_ptr e = std::current_exception()) {
try {
std::rethrow_exception(e);
} catch (const std::exception& ex) {
what = QString::fromUtf8(ex.what());
} catch (...) {
what = QStringLiteral("(非 std::exception)");
}
}
writeLine(QStringLiteral("%1 [FATAL] std::terminate 未捕获异常: %2")
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")), what));
std::abort();
});
}
} // namespace
void initLogging() {
const QString base = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
g_logDir = base + QStringLiteral("/logs");
QDir().mkpath(g_logDir);
pruneOldFiles(g_logDir);
const QString fname = QStringLiteral("geopro_%1.log")
.arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd")));
g_logFile.setFileName(g_logDir + QStringLiteral("/") + fname);
const bool opened = g_logFile.open(QIODevice::Append | QIODevice::Text);
if (!opened) { // 打不开(权限等)则仅写 stderrwriteLine 已容错)
std::fprintf(stderr, "[Logging] 无法打开日志文件: %s\n", qUtf8Printable(g_logFile.fileName()));
} else if (g_logFile.size() == 0) {
g_logFile.write("\xEF\xBB\xBF", 3); // 新建文件写 UTF-8 BOM便于中文 Windows 记事本正确识别编码
}
qInstallMessageHandler(messageHandler);
installCrashHandlers();
qInfo("=== Geopro 启动 v%s | 日志目录 %s ===",
qUtf8Printable(QCoreApplication::applicationVersion().isEmpty()
? QStringLiteral("dev")
: QCoreApplication::applicationVersion()),
qUtf8Printable(g_logDir));
}
QString logDirectory() { return g_logDir; }
} // namespace geopro::app

18
src/app/Logging.hpp Normal file
View File

@ -0,0 +1,18 @@
#pragma once
#include <QString>
namespace geopro::app {
// 初始化全局日志与崩溃捕获。需在 QApplication 构造、setOrganizationName/setApplicationName
// 之后调用一次(依赖 AppLocalDataLocation 定位日志目录)。
// - 安装 qInstallMessageHandler全 App 的 qDebug/qInfo/qWarning/qCritical/qFatal 写入
// 带时间戳/级别的滚动日志文件(%LOCALAPPDATA%/<Org>/<App>/logs/geopro_YYYYMMDD.log
// - 安装崩溃捕获:未处理 SEH 异常(段错误等)+ std::terminate未捕获 C++ 异常)→
// 记录异常码/地址 + flush 日志,并在 Windows 上用 DbgHelp 写 crash_*.dmp可 VS/WinDbg 事后分析)。
// - 启动时清理 14 天前的旧日志/dump。
void initLogging();
// 当前日志目录绝对路径(供 UI「打开日志目录」等使用可选
QString logDirectory();
} // namespace geopro::app

View File

@ -15,6 +15,8 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include "Theme.hpp" #include "Theme.hpp"
#include "api/NavLoads.hpp"
#include "api/NavRequest.hpp"
namespace geopro::app { namespace geopro::app {
@ -37,7 +39,7 @@ QColor statusColor(int s) {
} }
} // namespace } // namespace
ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* parent) ProjectListDialog::ProjectListDialog(data::IAsyncProjectRepository& repo, QWidget* parent)
: QDialog(parent), repo_(repo) { : QDialog(parent), repo_(repo) {
setWindowTitle(QStringLiteral("全部项目")); setWindowTitle(QStringLiteral("全部项目"));
resize(980, 560); resize(980, 560);
@ -125,57 +127,85 @@ ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* pa
query(); query();
} }
ProjectListDialog::~ProjectListDialog() {
// 退出契约:模态 exec 关窗后本对话框析构 → abort 在飞请求,防回调打到已析构窗口。
if (typesReq_) typesReq_->abort();
if (queryReq_) queryReq_->abort();
}
void ProjectListDialog::fillTypeFilter() { void ProjectListDialog::fillTypeFilter() {
typeCombo_->addItem(QStringLiteral("全部类型"), QString()); typeCombo_->addItem(QStringLiteral("全部类型"), QString());
const auto r = repo_.listProjectTypes(); // abort-and-replace最新一次过滤填充为准。
if (!r.ok) return; if (typesReq_) typesReq_->abort();
for (const auto& t : r.value) auto* r = repo_.listProjectTypesAsync();
typeCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id)); typesReq_ = r;
QObject::connect(r, &data::NavRequest::done, this, [this, r](const QVariant& v) {
if (r != typesReq_) return; // 身份比对:丢弃迟到/被替换信号
typesReq_.clear();
const auto types = qvariant_cast<std::vector<data::ProjectType>>(v);
for (const auto& t : types)
typeCombo_->addItem(QString::fromStdString(t.name), QString::fromStdString(t.id));
});
QObject::connect(r, &data::NavRequest::failed, this, [this, r](const QString&) {
if (r != typesReq_) return;
typesReq_.clear();
// 失败时仅保留“全部类型”项(与原同步版失败仅 return 一致)。
});
} }
void ProjectListDialog::query() { void ProjectListDialog::query() {
const std::string name = nameEdit_->text().trimmed().toStdString(); const std::string name = nameEdit_->text().trimmed().toStdString();
const std::string typeId = typeCombo_->currentData().toString().toStdString(); const std::string typeId = typeCombo_->currentData().toString().toStdString();
const auto r = repo_.pageProjects(name, typeId, pageNo_, pageSize_); // abort-and-replace丢弃上一查询仅最新结果落表。
if (!r.ok) { if (queryReq_) queryReq_->abort();
auto* r = repo_.pageProjectsAsync(name, typeId, pageNo_, pageSize_);
queryReq_ = r;
QObject::connect(r, &data::NavRequest::done, this, [this, r](const QVariant& v) {
if (r != queryReq_) return; // 身份比对
queryReq_.clear();
const auto page = qvariant_cast<data::ProjectListPage>(v);
total_ = page.total;
const auto& rows = page.rows;
table_->setRowCount(static_cast<int>(rows.size()));
for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
const auto& p = rows[i];
auto set = [&](int col, const QString& text) {
table_->setItem(i, col, new QTableWidgetItem(text));
};
set(0, QString::number((pageNo_ - 1) * pageSize_ + i + 1));
auto* nameItem = new QTableWidgetItem(QString::fromStdString(p.name));
nameItem->setData(Qt::UserRole, QString::fromStdString(p.id));
nameItem->setForeground(tokenColor("accent/primary"));
table_->setItem(i, 1, nameItem);
set(2, QString::fromStdString(p.code));
// 状态列语义着色:颜色承载“未开始/进行中”分类,进行中加粗强调(不只靠颜色)。
auto* statusItem = new QTableWidgetItem(statusText(p.status));
statusItem->setForeground(statusColor(p.status));
if (p.status == 2) {
QFont f = statusItem->font();
f.setBold(true);
statusItem->setFont(f);
}
table_->setItem(i, 3, statusItem);
set(4, QString::fromStdString(p.typeName));
set(5, QString::fromStdString(p.ownerCompany));
set(6, QString::fromStdString(p.responsiblePerson));
set(7, QString::fromStdString(p.createTime));
}
const int pages = total_ > 0 ? (total_ + pageSize_ - 1) / pageSize_ : 1;
pageLabel_->setText(
QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages));
prevBtn_->setEnabled(pageNo_ > 1);
nextBtn_->setEnabled(pageNo_ < pages);
});
QObject::connect(r, &data::NavRequest::failed, this, [this, r](const QString& msg) {
if (r != queryReq_) return;
queryReq_.clear();
table_->setRowCount(0); table_->setRowCount(0);
pageLabel_->setText(QStringLiteral("加载失败:%1").arg(QString::fromStdString(r.error))); pageLabel_->setText(QStringLiteral("加载失败:%1").arg(msg));
prevBtn_->setEnabled(false); prevBtn_->setEnabled(false);
nextBtn_->setEnabled(false); nextBtn_->setEnabled(false);
return; });
}
total_ = r.value.total;
const auto& rows = r.value.rows;
table_->setRowCount(static_cast<int>(rows.size()));
for (int i = 0; i < static_cast<int>(rows.size()); ++i) {
const auto& p = rows[i];
auto set = [&](int col, const QString& text) {
table_->setItem(i, col, new QTableWidgetItem(text));
};
set(0, QString::number((pageNo_ - 1) * pageSize_ + i + 1));
auto* nameItem = new QTableWidgetItem(QString::fromStdString(p.name));
nameItem->setData(Qt::UserRole, QString::fromStdString(p.id));
nameItem->setForeground(tokenColor("accent/primary"));
table_->setItem(i, 1, nameItem);
set(2, QString::fromStdString(p.code));
// 状态列语义着色:颜色承载“未开始/进行中”分类,进行中加粗强调(不只靠颜色)。
auto* statusItem = new QTableWidgetItem(statusText(p.status));
statusItem->setForeground(statusColor(p.status));
if (p.status == 2) {
QFont f = statusItem->font();
f.setBold(true);
statusItem->setFont(f);
}
table_->setItem(i, 3, statusItem);
set(4, QString::fromStdString(p.typeName));
set(5, QString::fromStdString(p.ownerCompany));
set(6, QString::fromStdString(p.responsiblePerson));
set(7, QString::fromStdString(p.createTime));
}
const int pages = total_ > 0 ? (total_ + pageSize_ - 1) / pageSize_ : 1;
pageLabel_->setText(QStringLiteral("共 %1 条 第 %2 / %3 页").arg(total_).arg(pageNo_).arg(pages));
prevBtn_->setEnabled(pageNo_ > 1);
nextBtn_->setEnabled(pageNo_ < pages);
} }
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,7 +1,8 @@
#pragma once #pragma once
#include <QDialog> #include <QDialog>
#include <QPointer>
#include "repo/IProjectRepository.hpp" #include "repo/IAsyncProjectRepository.hpp"
class QLineEdit; class QLineEdit;
class QComboBox; class QComboBox;
@ -9,13 +10,16 @@ class QTableWidget;
class QLabel; class QLabel;
class QPushButton; class QPushButton;
namespace geopro::data { class NavRequest; }
namespace geopro::app { namespace geopro::app {
// 项目列表弹窗:名称/类型过滤 + 分页表格;点项目名 → 切换项目并关闭。 // 项目列表弹窗:名称/类型过滤 + 分页表格;点项目名 → 切换项目并关闭。
class ProjectListDialog : public QDialog { class ProjectListDialog : public QDialog {
Q_OBJECT Q_OBJECT
public: public:
explicit ProjectListDialog(data::IProjectRepository& repo, QWidget* parent = nullptr); explicit ProjectListDialog(data::IAsyncProjectRepository& repo, QWidget* parent = nullptr);
~ProjectListDialog() override;
signals: signals:
void projectChosen(const QString& projectId, const QString& projectName); void projectChosen(const QString& projectId, const QString& projectName);
@ -24,7 +28,9 @@ private:
void query(); void query();
void fillTypeFilter(); void fillTypeFilter();
data::IProjectRepository& repo_; data::IAsyncProjectRepository& repo_;
QPointer<data::NavRequest> typesReq_;
QPointer<data::NavRequest> queryReq_;
QLineEdit* nameEdit_ = nullptr; QLineEdit* nameEdit_ = nullptr;
QComboBox* typeCombo_ = nullptr; QComboBox* typeCombo_ = nullptr;
QTableWidget* table_ = nullptr; QTableWidget* table_ = nullptr;

View File

@ -17,6 +17,7 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidget> #include <QWidget>
#include "AuthLoads.hpp"
#include "AuthService.hpp" #include "AuthService.hpp"
#include "Theme.hpp" #include "Theme.hpp"
@ -219,20 +220,36 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
userEdit_->setFocus(); // 焦点落在第一个待填字段 userEdit_->setFocus(); // 焦点落在第一个待填字段
} }
LoginWindow::~LoginWindow()
{
// 退出契约:窗口析构时 abort 在飞句柄防回调打到已析构窗口spec §5.0)。
if (captchaLoad_) captchaLoad_->abort();
if (loginLoad_) loginLoad_->abort();
}
void LoginWindow::refreshCaptcha() void LoginWindow::refreshCaptcha()
{ {
codeEdit_->clear(); codeEdit_->clear();
try { if (captchaLoad_) captchaLoad_->abort(); // 取消上一张在飞请求
const auto cap = auth_.fetchCaptcha(); refreshBtn_->setEnabled(false);
codeId_ = cap.codeId;
captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code)); auto* l = auth_.fetchCaptchaAsync();
} catch (const std::exception& e) { captchaLoad_ = l;
showError(QStringLiteral("获取验证码失败:%1").arg(QString::fromUtf8(e.what()))); connect(l, &geopro::net::CaptchaLoad::done, this,
[this, l](const geopro::net::AuthService::Captcha& cap) {
if (l != captchaLoad_) return; // 身份比对:仅处理最新请求
captchaLoad_.clear();
codeId_ = cap.codeId;
captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code));
refreshBtn_->setEnabled(true);
});
connect(l, &geopro::net::CaptchaLoad::failed, this, [this, l](const QString& msg) {
if (l != captchaLoad_) return;
captchaLoad_.clear();
showError(QStringLiteral("获取验证码失败:%1").arg(msg));
captchaLabel_->setText(QStringLiteral("加载失败")); captchaLabel_->setText(QStringLiteral("加载失败"));
} catch (...) { refreshBtn_->setEnabled(true);
showError(QStringLiteral("获取验证码失败")); });
captchaLabel_->setText(QStringLiteral("加载失败"));
}
} }
void LoginWindow::attemptLogin() void LoginWindow::attemptLogin()
@ -249,31 +266,25 @@ void LoginWindow::attemptLogin()
errorLabel_->clear(); errorLabel_->clear();
loginBtn_->setEnabled(false); loginBtn_->setEnabled(false);
const QString origText = loginBtn_->text(); const QString origText = loginBtn_->text();
loginBtn_->setText(QStringLiteral("登录中...")); loginBtn_->setText(QStringLiteral("登录中...")); // 异步不冻 UI无需 repaint hack
loginBtn_->repaint(); // 同步阻塞前刷新按钮文案
geopro::net::AuthService::LoginResult result; if (loginLoad_) loginLoad_->abort(); // 取消上一次在飞登录
try { auto* l = auth_.loginAsync(user, pwd, code, codeId_);
result = auth_.login(user, pwd, code, codeId_); loginLoad_ = l;
} catch (const std::exception& e) { connect(l, &geopro::net::LoginLoad::done, this, [this, l](const QString& token) {
result.ok = false; if (l != loginLoad_) return; // 身份比对
result.error = QStringLiteral("登录异常:%1").arg(QString::fromUtf8(e.what())); loginLoad_.clear();
} catch (...) { token_ = token;
result.ok = false;
result.error = QStringLiteral("登录发生未知错误");
}
loginBtn_->setText(origText);
loginBtn_->setEnabled(true);
if (result.ok) {
token_ = result.token;
accept(); accept();
return; });
} connect(l, &geopro::net::LoginLoad::failed, this, [this, l, origText](const QString& msg) {
if (l != loginLoad_) return;
showError(result.error.isEmpty() ? QStringLiteral("登录失败") : result.error); loginLoad_.clear();
refreshCaptcha(); // 失败刷新验证码 loginBtn_->setText(origText);
loginBtn_->setEnabled(true);
showError(msg.isEmpty() ? QStringLiteral("登录失败") : msg);
refreshCaptcha(); // 失败刷新验证码
});
} }
bool LoginWindow::remember() const bool LoginWindow::remember() const

View File

@ -4,6 +4,7 @@
// 成功后 accept() 并经 token() 暴露 accessToken 给主流程注入 ApiClient。 // 成功后 accept() 并经 token() 暴露 accessToken 给主流程注入 ApiClient。
#include <QDialog> #include <QDialog>
#include <QPointer>
#include <QString> #include <QString>
class QCheckBox; class QCheckBox;
@ -13,6 +14,8 @@ class QPushButton;
namespace geopro::net { namespace geopro::net {
class AuthService; class AuthService;
class CaptchaLoad;
class LoginLoad;
} }
namespace geopro::app { namespace geopro::app {
@ -22,6 +25,7 @@ class LoginWindow : public QDialog {
public: public:
explicit LoginWindow(geopro::net::AuthService& auth, QWidget* parent = nullptr); explicit LoginWindow(geopro::net::AuthService& auth, QWidget* parent = nullptr);
~LoginWindow() override;
// 登录成功后的 accessToken形如 "Geomative <hash>");未登录为空。 // 登录成功后的 accessToken形如 "Geomative <hash>");未登录为空。
QString token() const { return token_; } QString token() const { return token_; }
@ -31,12 +35,14 @@ public:
private: private:
void refreshCaptcha(); // 拉新验证码并重绘图片 void refreshCaptcha(); // 拉新验证码并重绘图片
void attemptLogin(); // 校验输入并发起阻塞登录 void attemptLogin(); // 校验输入并发起异步登录
void showError(const QString& msg); void showError(const QString& msg);
geopro::net::AuthService& auth_; geopro::net::AuthService& auth_;
QString token_; QString token_;
QString codeId_; QString codeId_;
QPointer<geopro::net::CaptchaLoad> captchaLoad_;
QPointer<geopro::net::LoginLoad> loginLoad_;
QLineEdit* userEdit_ = nullptr; QLineEdit* userEdit_ = nullptr;
QLineEdit* pwdEdit_ = nullptr; QLineEdit* pwdEdit_ = nullptr;

View File

@ -21,6 +21,7 @@
#include <memory> #include <memory>
#include <sstream> #include <sstream>
#include <string> #include <string>
#include <typeinfo>
#include <vector> #include <vector>
#include <QActionGroup> #include <QActionGroup>
@ -56,6 +57,7 @@
#include <QToolBar> #include <QToolBar>
#include <QTreeWidget> #include <QTreeWidget>
#include <QTreeWidgetItem> #include <QTreeWidgetItem>
#include <QTreeWidgetItemIterator>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidget> #include <QWidget>
@ -73,6 +75,7 @@
#include "AuthService.hpp" #include "AuthService.hpp"
#include "Credential.hpp" #include "Credential.hpp"
#include "Glyphs.hpp" #include "Glyphs.hpp"
#include "Logging.hpp"
#include "PanelHeader.hpp" #include "PanelHeader.hpp"
#include "Theme.hpp" #include "Theme.hpp"
#include "SettingsDialog.hpp" #include "SettingsDialog.hpp"
@ -81,6 +84,7 @@
#include "ProjectListDialog.hpp" #include "ProjectListDialog.hpp"
#include "WorkbenchNavController.hpp" #include "WorkbenchNavController.hpp"
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include "panels/chart/ErtInversionStrategy.hpp"
#include "api/ApiProjectRepository.hpp" #include "api/ApiProjectRepository.hpp"
#include "api/ApiDatasetRepository.hpp" #include "api/ApiDatasetRepository.hpp"
#include "panels/ObjectTreePanel.hpp" #include "panels/ObjectTreePanel.hpp"
@ -192,7 +196,7 @@ constexpr const char* kWgs84 = "EPSG:4326";
// 在给定 QMainWindow 上构建 M1 工作台。 // 在给定 QMainWindow 上构建 M1 工作台。
// repo 生命周期须覆盖到事件循环结束(由调用方保证)。 // repo 生命周期须覆盖到事件循环结束(由调用方保证)。
void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo, void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo,
geopro::data::IProjectRepository& projectRepo, geopro::data::IAsyncProjectRepository& projectRepo,
geopro::controller::WorkbenchNavController& nav, geopro::controller::WorkbenchNavController& nav,
geopro::controller::DatasetDetailController& detailCtrl) geopro::controller::DatasetDetailController& detailCtrl)
{ {
@ -414,7 +418,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// 左下 dock数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。 // 左下 dock数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。
auto* datasetTabs = new QTabWidget(); auto* datasetTabs = new QTabWidget();
auto* datasetList = new QListWidget(); // 数据页签:树形列表(原版 el-table 树——派生数据挂源数据下,按 DsRow.parentId 嵌套)。
auto* datasetList = new QTreeWidget();
datasetList->setHeaderHidden(true);
datasetList->setColumnCount(1);
datasetList->setRootIsDecorated(true); // 显展开/折叠箭头
datasetList->setIndentation(geopro::app::scaledPx(14));
datasetList->setExpandsOnDoubleClick(false); // 双击=打开详情,不切展开(展开靠箭头)
datasetList->setSelectionBehavior(QAbstractItemView::SelectRows);
geopro::app::applyDatasetCardDelegate(datasetList); geopro::app::applyDatasetCardDelegate(datasetList);
datasetTabs->addTab(datasetList, QStringLiteral("数据")); datasetTabs->addTab(datasetList, QStringLiteral("数据"));
auto* fileList = new QListWidget(); auto* fileList = new QListWidget();
@ -482,24 +493,25 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
}; };
// ── 单击左下数据列表的采集批次(DS) → 属性表单 + 聚焦详情已开页 ── // ── 单击左下数据列表的采集批次(DS) → 属性表单 + 聚焦详情已开页 ──
QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, QObject::connect(datasetList, &QTreeWidget::itemClicked, datasetList,
[&nav, &detailCtrl](QListWidgetItem* item) { [&nav, &detailCtrl](QTreeWidgetItem* item, int) {
if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { if (item->data(0, geopro::app::kDsLoadMoreRole).toBool()) {
nav.loadMoreData(); nav.loadMoreData();
return; return;
} }
const QString dsId = item->data(geopro::app::kDsIdRole).toString(); const QString dsId = item->data(0, geopro::app::kDsIdRole).toString();
if (dsId.isEmpty()) return; if (dsId.isEmpty()) return;
nav.selectDataset(dsId); // 属性表单(现状) nav.selectDataset(dsId); // 属性表单(现状)
detailCtrl.focusDataset(dsId); // 单击=聚焦已开页 detailCtrl.focusDataset(dsId); // 单击=聚焦已开页
}); });
// ── 双击 → 打开/聚焦该数据集的详情图表页(拉真实反演剖面/散点/异常/色阶)── // ── 双击 → 打开/聚焦该数据集的详情图表页(拉真实反演剖面/散点/异常/色阶)──
QObject::connect(datasetList, &QListWidget::itemDoubleClicked, datasetList, QObject::connect(datasetList, &QTreeWidget::itemDoubleClicked, datasetList,
[&detailCtrl](QListWidgetItem* item) { [&detailCtrl](QTreeWidgetItem* item, int) {
const QString dsId = item->data(geopro::app::kDsIdRole).toString(); const QString dsId = item->data(0, geopro::app::kDsIdRole).toString();
const QString ddCode = item->data(geopro::app::kDsDdCodeRole).toString(); const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString();
if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode); const QString dsName = item->data(0, geopro::app::kDsNameRole).toString();
if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName);
}); });
// ── 控制器信号 → 详情面板:数据就绪开页 / 聚焦请求 ── // ── 控制器信号 → 详情面板:数据就绪开页 / 聚焦请求 ──
@ -540,10 +552,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
// ── 详情面板切 Tab → 反向高亮数据集列表对应行 ── // ── 详情面板切 Tab → 反向高亮数据集列表对应行 ──
QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::activeDatasetChanged, QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::activeDatasetChanged,
datasetList, [datasetList](const QString& dsId) { datasetList, [datasetList](const QString& dsId) {
for (int i = 0; i < datasetList->count(); ++i) for (QTreeWidgetItemIterator it(datasetList); *it; ++it)
if (datasetList->item(i)->data(geopro::app::kDsIdRole).toString() == if ((*it)->data(0, geopro::app::kDsIdRole).toString() == dsId) {
dsId) datasetList->setCurrentItem(*it);
datasetList->setCurrentRow(i); break;
}
}); });
// 「视图详情」浮层显隐:仅三维显示,置于 QVTK 左上(工具条下方)并置顶。 // 「视图详情」浮层显隐:仅三维显示,置于 QVTK 左上(工具条下方)并置顶。
@ -634,6 +647,25 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
} }
return loaded; return loaded;
}; };
// 数据树的「加载更多」:末尾顶层项;已加载数=树中非"加载更多"项总数(含各层子节点)。
auto removeTreeLoadMore = [](QTreeWidget* tw) {
const int n = tw->topLevelItemCount();
if (n > 0 && tw->topLevelItem(n - 1)->data(0, geopro::app::kDsLoadMoreRole).toBool())
delete tw->takeTopLevelItem(n - 1);
};
// total = 根节点总数控制器按根分页loaded 也按「第一层节点(根)」计 → 加载更多/页签数一致。
auto addTreeLoadMore = [](QTreeWidget* tw, int total) {
int loaded = 0;
for (int i = 0; i < tw->topLevelItemCount(); ++i)
if (!tw->topLevelItem(i)->data(0, geopro::app::kDsLoadMoreRole).toBool()) ++loaded;
if (loaded < total) {
auto* m = new QTreeWidgetItem(tw);
m->setText(0, QStringLiteral("加载更多(%1/%2").arg(loaded).arg(total));
m->setData(0, geopro::app::kDsLoadMoreRole, true);
m->setTextAlignment(0, Qt::AlignCenter);
}
return loaded;
};
QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav, QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav,
&geopro::controller::WorkbenchNavController::switchWorkspace); &geopro::controller::WorkbenchNavController::switchWorkspace);
QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav, QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav,
@ -710,12 +742,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
datasetTabs->setTabText(1, QStringLiteral("文件")); datasetTabs->setTabText(1, QStringLiteral("文件"));
}); });
QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList, QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList,
[removeLoadMore, addLoadMore, datasetList, datasetTitle, datasetTabs]( [removeTreeLoadMore, addTreeLoadMore, datasetList, datasetTitle, datasetTabs](
const QString&, const std::vector<geopro::data::DsRow>& rows, int total, const QString&, const std::vector<geopro::data::DsRow>& rows, int total,
bool append) { bool append) {
removeLoadMore(datasetList); removeTreeLoadMore(datasetList);
geopro::app::populateDatasetList(datasetList, rows, append); geopro::app::populateDatasetList(datasetList, rows, append);
const int loaded = addLoadMore(datasetList, total); const int loaded = addTreeLoadMore(datasetList, total);
if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集")); if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集"));
datasetTabs->setTabText( datasetTabs->setTabText(
0, total > 0 ? QStringLiteral("数据 (%1/%2)").arg(loaded).arg(total) 0, total > 0 ? QStringLiteral("数据 (%1/%2)").arg(loaded).arg(total)
@ -788,6 +820,32 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
} // namespace } // namespace
namespace {
// 顶层异常护栏:任何 slot / 事件处理器抛出的 C++ 异常都会经过 QApplication::notify。
// 默认 Qt 不允许异常穿透事件循环 → terminate崩溃。这里拦截 + 记录(异常信息 + 接收者
// 对象类名 + 事件类型,足以定位崩点),并吞掉以**保证后端故障等异常不致整个客户端退出**。
// 注:吞异常后该次事件处理中断,可能留下局部不一致;但"不崩 + 有日志"优先于直接退出。
class GuardedApplication : public QApplication {
public:
using QApplication::QApplication;
bool notify(QObject* receiver, QEvent* e) override {
try {
return QApplication::notify(receiver, e);
} catch (const std::exception& ex) {
qCritical("[guard] 拦截未捕获异常: %s | type=%s | receiver=%s | event=%d",
ex.what(), typeid(ex).name(),
receiver ? receiver->metaObject()->className() : "null",
e ? static_cast<int>(e->type()) : 0);
} catch (...) {
qCritical("[guard] 拦截未捕获非 std 异常 | receiver=%s | event=%d",
receiver ? receiver->metaObject()->className() : "null",
e ? static_cast<int>(e->type()) : 0);
}
return false;
}
};
} // namespace
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
// 高 DPI 缩放采用直通策略:在 125%/150% 等分数缩放下字体/图标按真实比例渲染,更清晰。 // 高 DPI 缩放采用直通策略:在 125%/150% 等分数缩放下字体/图标按真实比例渲染,更清晰。
@ -797,7 +855,7 @@ int main(int argc, char* argv[])
// QVTK 默认 surface format 必须在 QApplication 之前设置(全局一次,两个 QVTK widget 共用)。 // QVTK 默认 surface format 必须在 QApplication 之前设置(全局一次,两个 QVTK widget 共用)。
QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat());
QApplication app(argc, argv); GuardedApplication app(argc, argv); // 顶层异常护栏slot/事件里的异常不致客户端崩溃
// 异步 ApiCall::finished 等信号携带 ApiResponse注册元类型以支持跨 QueuedConnection 传递 // 异步 ApiCall::finished 等信号携带 ApiResponse注册元类型以支持跨 QueuedConnection 传递
// (当前详情链路为同线程 DirectConnection非严格必需但作防御性注册见 spec §5.1)。 // (当前详情链路为同线程 DirectConnection非严格必需但作防御性注册见 spec §5.1)。
@ -807,6 +865,9 @@ int main(int argc, char* argv[])
QCoreApplication::setOrganizationName(QStringLiteral("Geomative")); QCoreApplication::setOrganizationName(QStringLiteral("Geomative"));
QCoreApplication::setApplicationName(QStringLiteral("Geopro3")); QCoreApplication::setApplicationName(QStringLiteral("Geopro3"));
// 日志 + 崩溃捕获:尽早安装(依赖上面的 Org/App 名定位日志目录)。生产桌面端问题可回溯。
geopro::app::initLogging();
// 主题与字号:应用持久化偏好(跟随系统/浅/深 + 字号缩放),再套用 Fusion + 全局 QSS + 调色板。 // 主题与字号:应用持久化偏好(跟随系统/浅/深 + 字号缩放),再套用 Fusion + 全局 QSS + 调色板。
// 在弹登录窗之前完成 → 登录页与主页 明暗/字号 统一。 // 在弹登录窗之前完成 → 登录页与主页 明暗/字号 统一。
geopro::app::applyPersistedThemeMode(); geopro::app::applyPersistedThemeMode();
@ -860,7 +921,10 @@ int main(int argc, char* argv[])
// 数据详情仓储 + 控制器(接真实反演 API同一共享会话 ApiClient。 // 数据详情仓储 + 控制器(接真实反演 API同一共享会话 ApiClient。
geopro::data::ApiDatasetRepository datasetRepo(api); geopro::data::ApiDatasetRepository datasetRepo(api);
geopro::controller::DatasetDetailController detailCtrl(datasetRepo); // 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。
geopro::controller::ChartStrategyRegistry chartRegistry;
chartRegistry.add(std::make_unique<geopro::app::ErtInversionStrategy>());
geopro::controller::DatasetDetailController detailCtrl(datasetRepo, chartRegistry);
// ── 外壳:标准 QMainWindow原生标题栏。buildWorkbench 直接用其 // ── 外壳:标准 QMainWindow原生标题栏。buildWorkbench 直接用其
// setCentralWidget/setMenuWidget/statusBar 承载工作台。 // setCentralWidget/setMenuWidget/statusBar 承载工作台。

View File

@ -22,7 +22,9 @@ void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailCon
auto* p = pageFor(d.dsId); auto* p = pageFor(d.dsId);
if (!p) { if (!p) {
p = new DatasetDetailPage(this); p = new DatasetDetailPage(this);
addTab(p, d.dsId); // 标题后续可换 ds 名 const QString title = d.dsName.isEmpty() ? d.dsId : d.dsName; // 页签标题用数据名(空则回退 id
const int idx = addTab(p, title);
setTabToolTip(idx, title); // 名称过长被省略时悬停可见全名
// 页内「网格数据」页签首次激活 → 冒泡为面板信号(外部接控制器懒加载)。 // 页内「网格数据」页签首次激活 → 冒泡为面板信号(外部接控制器懒加载)。
connect(p, &DatasetDetailPage::gridDataNeeded, this, &DatasetDetailPanel::gridDataNeeded); connect(p, &DatasetDetailPage::gridDataNeeded, this, &DatasetDetailPanel::gridDataNeeded);
} }

View File

@ -1,6 +1,8 @@
#include "panels/DatasetListPanel.hpp" #include "panels/DatasetListPanel.hpp"
#include <QAbstractItemView>
#include <QColor> #include <QColor>
#include <QHash>
#include <QListWidget> #include <QListWidget>
#include <QListWidgetItem> #include <QListWidgetItem>
#include <QObject> #include <QObject>
@ -8,6 +10,9 @@
#include <QPainterPath> #include <QPainterPath>
#include <QString> #include <QString>
#include <QStyledItemDelegate> #include <QStyledItemDelegate>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QTreeWidgetItemIterator>
#include "Theme.hpp" #include "Theme.hpp"
@ -104,20 +109,53 @@ public:
}; };
} // namespace } // namespace
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append) { namespace {
if (!list) return; // 建一条数据集树项不挂载列0 文本 = dsName +「创建时间 · 类型名」data 存各角色。
if (!append) list->clear(); QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) {
for (const auto& d : rows) { QString text = QString::fromStdString(d.dsName);
QString text = QString::fromStdString(d.dsName); QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间
QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间 if (!d.typeName.empty())
if (!d.typeName.empty()) sub += QStringLiteral(" · %1").arg(QString::fromStdString(d.typeName)); // 再跟类型
sub += QStringLiteral(" · %1").arg(QString::fromStdString(d.typeName)); // 再跟类型 if (!sub.isEmpty()) text += QStringLiteral("\n%1").arg(sub);
if (!sub.isEmpty()) text += QStringLiteral("\n%1").arg(sub); auto* item = new QTreeWidgetItem();
auto* item = new QListWidgetItem(text, list); item->setText(0, text);
item->setData(kDsIdRole, QString::fromStdString(d.id)); item->setData(0, kDsIdRole, QString::fromStdString(d.id));
item->setData(kDsDdTypeRole, QString::fromStdString(d.ddCode)); item->setData(0, kDsDdTypeRole, QString::fromStdString(d.ddCode));
item->setData(kDsDdCodeRole, QString::fromStdString(d.ddCode)); item->setData(0, kDsDdCodeRole, QString::fromStdString(d.ddCode));
item->setData(0, kDsNameRole, QString::fromStdString(d.dsName));
return item;
}
} // namespace
void populateDatasetList(QTreeWidget* tree, const std::vector<geopro::data::DsRow>& rows, bool append) {
if (!tree) return;
if (!append) tree->clear();
// id→已在树中的项!append 时为空append分页时含已加载行使新行能挂到既有父下。
QHash<QString, QTreeWidgetItem*> byId;
for (QTreeWidgetItemIterator it(tree); *it; ++it) {
const QString id = (*it)->data(0, kDsIdRole).toString();
if (!id.isEmpty()) byId.insert(id, *it);
} }
// 第一遍:本批全部建项(不挂载)并登记 id使同批内的父子也能互相找到。
std::vector<QTreeWidgetItem*> batch;
batch.reserve(rows.size());
for (const auto& d : rows) {
auto* item = makeDatasetItem(d);
byId.insert(QString::fromStdString(d.id), item);
batch.push_back(item);
}
// 第二遍:按 parentId 挂载。父在集合内→作其子;否则(父是源文件节点/不在本批)→作树根。
for (std::size_t i = 0; i < rows.size(); ++i) {
const QString pid = QString::fromStdString(rows[i].parentId);
QTreeWidgetItem* parent = pid.isEmpty() ? nullptr : byId.value(pid, nullptr);
if (parent && parent != batch[i])
parent->addChild(batch[i]);
else
tree->addTopLevelItem(batch[i]);
}
// 默认折叠(对齐原版:仅显源数据根行,派生数据收在展开箭头内)。
} }
void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append) { void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append) {
@ -141,13 +179,13 @@ void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>&
} }
} }
void applyDatasetCardDelegate(QListWidget* list) { void applyDatasetCardDelegate(QAbstractItemView* view) {
if (!list) return; if (!view) return;
list->setItemDelegate(new DatasetCardDelegate(list)); view->setItemDelegate(new DatasetCardDelegate(view));
list->setMouseTracking(true); // 让委托收到 hover 状态 view->setMouseTracking(true); // 让委托收到 hover 状态
list->setSpacing(0); // 卡间距由委托内边距控制 if (auto* list = qobject_cast<QListWidget*>(view)) list->setSpacing(0); // 卡间距由委托内边距控制
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, list, QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, view,
[list]() { list->viewport()->update(); }); [view]() { view->viewport()->update(); });
} }
} // namespace geopro::app } // namespace geopro::app

View File

@ -4,6 +4,8 @@
#include "repo/RepoTypes.hpp" #include "repo/RepoTypes.hpp"
class QListWidget; class QListWidget;
class QTreeWidget;
class QAbstractItemView;
namespace geopro::app { namespace geopro::app {
@ -13,13 +15,17 @@ constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1
constexpr int kDsFileUrlRole = 0x0102; // Qt::UserRole + 2文件下载 url备用 constexpr int kDsFileUrlRole = 0x0102; // Qt::UserRole + 2文件下载 url备用
constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行 constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行
constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4ddCode双击详情选策略用 constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4ddCode双击详情选策略用
constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5dsName详情页签标题用
// 数据页签:每条 = dsName +类型名UserRole 存 dsId、+1 存 ddCode。 // 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。
void populateDatasetList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append); // 每项列0文本 = dsName +「创建时间 · 类型名」data(0,角色) 存 dsId/ddCode/dsName。
// append=true 时把新行挂到已加载的父节点下(分页)。
void populateDatasetList(QTreeWidget* tree, const std::vector<geopro::data::DsRow>& rows, bool append);
// 文件页签:每条 = 文件名 +可读大小UserRole 存 dsId、+2 存文件 url。空时显示占位。 // 文件页签:每条 = 文件名 +可读大小UserRole 存 dsId、+2 存文件 url。空时显示占位。
void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append); void populateFileList(QListWidget* list, const std::vector<geopro::data::DsRow>& rows, bool append);
// 给数据/文件列表套用卡片委托(标题+元信息双行、悬停/选中圆角高亮+左强调竖条规范§6.2)。 // 给数据/文件列表套用卡片委托(标题+元信息双行、悬停/选中圆角高亮+左强调竖条规范§6.2)。
void applyDatasetCardDelegate(QListWidget* list); // 接受 QListWidget文件或 QTreeWidget数据树——故形参为其共同基类 QAbstractItemView。
void applyDatasetCardDelegate(QAbstractItemView* view);
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,19 +1,32 @@
#include "panels/LoadingOverlay.hpp" #include "panels/LoadingOverlay.hpp"
#include <QColor>
#include <QEvent> #include <QEvent>
#include <QLabel> #include <QLabel>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "Theme.hpp"
namespace geopro::app { namespace geopro::app {
LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) { LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) {
Q_ASSERT(parent); // 契约:必须有父(遮罩几何跟随父,无父无法工作) Q_ASSERT(parent); // 契约:必须有父(遮罩几何跟随父,无父无法工作)
setAttribute(Qt::WA_StyledBackground, true); setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral("background: rgba(255,255,255,160);"));
label_->setText(QStringLiteral("加载中…")); label_->setText(QStringLiteral("加载中…"));
label_->setAlignment(Qt::AlignCenter); label_->setAlignment(Qt::AlignCenter);
auto* lay = new QVBoxLayout(this); auto* lay = new QVBoxLayout(this);
lay->addWidget(label_); lay->addWidget(label_);
if (parent) parent->installEventFilter(this); if (parent) parent->installEventFilter(this);
// 半透明遮罩跟随主题:浅色白纱深字(原版),暗色深纱浅字,避免暗色下刺眼白蒙板。
const auto applyTheme = [this]() {
const QColor veil = isDarkTheme() ? tokenColor("bg/app") : QColor(255, 255, 255);
setStyleSheet(QStringLiteral("background: rgba(%1,%2,%3,160);")
.arg(veil.red()).arg(veil.green()).arg(veil.blue()));
label_->setStyleSheet(QStringLiteral("background: transparent; color: %1;")
.arg(tokenColor("text/primary").name()));
};
applyTheme();
connect(&ThemeManager::instance(), &ThemeManager::changed, this, applyTheme);
hide(); hide();
} }

View File

@ -0,0 +1,45 @@
#include "panels/chart/ChartTheme.hpp"
#include <QColor>
#include <QPalette>
#include <qwt_plot.h>
#include <qwt_plot_grid.h>
#include <qwt_plot_item.h>
#include <qwt_plot_marker.h>
#include "Theme.hpp"
namespace geopro::app {
void applyChartPlotTheme(QwtPlot* plot) {
if (!plot) return;
const bool dark = isDarkTheme();
// 浅色分支=原有硬编码值(保持与原版 1:1暗色分支=主题 token。
const QColor bg = dark ? tokenColor("bg/panel") : QColor(Qt::white);
const QColor axisText = dark ? tokenColor("text/secondary"): QColor(90, 90, 90);
const QColor gridMajor = dark ? tokenColor("border/default"): QColor(225, 225, 225);
const QColor gridMinor = dark ? tokenColor("bg/hover") : QColor(240, 240, 240);
const QColor zeroPen = dark ? tokenColor("border/strong") : QColor(180, 180, 180);
plot->setCanvasBackground(QBrush(bg));
plot->setAutoFillBackground(true);
QPalette pal = plot->palette();
pal.setColor(QPalette::Window, bg);
pal.setColor(QPalette::WindowText, axisText);
pal.setColor(QPalette::Text, axisText);
plot->setPalette(pal);
// 网格线 / 零线line marker按主题重着色——不动其它 item散点/网格填充自带配色)。
const QwtPlotItemList items = plot->itemList();
for (QwtPlotItem* it : items) {
if (auto* g = dynamic_cast<QwtPlotGrid*>(it)) {
g->setMajorPen(gridMajor, 1.0, Qt::SolidLine);
g->setMinorPen(gridMinor, 1.0, Qt::DotLine);
} else if (auto* m = dynamic_cast<QwtPlotMarker*>(it)) {
if (m->lineStyle() != QwtPlotMarker::NoLine) m->setLinePen(zeroPen, 1.0);
}
}
plot->replot();
}
} // namespace geopro::app

View File

@ -0,0 +1,14 @@
#pragma once
class QwtPlot;
namespace geopro::app {
// 按当前主题给 QwtPlot 套底色/轴文字/网格/零线配色,并 replot。
// 浅色:与原版 web 图表一致(白底 + 深灰轴字)——保持 1:1不动。
// 暗色:改深色画布(bg/panel) + 浅色轴字,避免暗色主题下刺眼白底。
// 网格线 / 零线标记按主题重新着色(遍历 plot 的 item 列表,无需各视图持有指针)。
// 在视图构造末尾调用一次;并连 ThemeManager::changed 再调用以热切换。
void applyChartPlotTheme(QwtPlot* plot);
} // namespace geopro::app

View File

@ -2,6 +2,8 @@
#include <QPainter> #include <QPainter>
#include <QPaintEvent> #include <QPaintEvent>
#include "Theme.hpp"
namespace geopro::app { namespace geopro::app {
static constexpr int kBarHeight = 18; // 色带高度px static constexpr int kBarHeight = 18; // 色带高度px
@ -11,6 +13,9 @@ static constexpr int kFontSize = 9; // 刻度字号
ColorBarWidget::ColorBarWidget(QWidget* parent) ColorBarWidget::ColorBarWidget(QWidget* parent)
: QWidget(parent) { : QWidget(parent) {
setFixedHeight(36); setFixedHeight(36);
// 主题热切换:底色/边框/刻度字跟随主题重绘(色带本身=数据色,不变)。
connect(&ThemeManager::instance(), &ThemeManager::changed, this,
qOverload<>(&QWidget::update));
} }
void ColorBarWidget::setColorScale(const core::ColorScale& scale) { void ColorBarWidget::setColorScale(const core::ColorScale& scale) {
@ -23,7 +28,12 @@ void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) {
p.setRenderHint(QPainter::Antialiasing, false); p.setRenderHint(QPainter::Antialiasing, false);
const int W = width(); const int W = width();
const int H = height(); const int H = height();
p.fillRect(0, 0, W, H, Qt::white); // 白底,对齐原版 // 浅色=白底深字(原版 1:1暗色=深底浅字,避免刺眼白条。色带格(数据色)两者不变。
const bool dark = isDarkTheme();
const QColor bgColor = dark ? tokenColor("bg/panel") : QColor(Qt::white);
const QColor borderColor = dark ? tokenColor("border/strong") : QColor(120, 120, 120);
const QColor textColor = dark ? tokenColor("text/secondary"): QColor(60, 60, 60);
p.fillRect(0, 0, W, H, bgColor);
auto stops = scale_.stops(); auto stops = scale_.stops();
if (stops.size() < 2) return; if (stops.size() < 2) return;
@ -47,14 +57,14 @@ void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) {
p.fillRect(xL, barY, xR - xL, barH, QColor(c.r, c.g, c.b, c.a)); p.fillRect(xL, barY, xR - xL, barH, QColor(c.r, c.g, c.b, c.a));
} }
// 外边框 // 外边框
p.setPen(QPen(QColor(120, 120, 120), 1)); p.setPen(QPen(borderColor, 1));
p.drawRect(barLeft, barY, barW - 1, barH - 1); p.drawRect(barLeft, barY, barW - 1, barH - 1);
// 边界值标签(深色文字,白底可见),在各分格边界下方。 // 边界值标签(随主题:浅色深字 / 暗色浅字),在各分格边界下方。
QFont font = p.font(); QFont font = p.font();
font.setPixelSize(kFontSize); font.setPixelSize(kFontSize);
p.setFont(font); p.setFont(font);
p.setPen(QColor(60, 60, 60)); p.setPen(textColor);
QFontMetrics fm(font); QFontMetrics fm(font);
const int tickY = barY + barH + 1; const int tickY = barY + barH + 1;
for (int i = 0; i <= nSeg; ++i) { for (int i = 0; i <= nSeg; ++i) {

View File

@ -19,10 +19,14 @@ ColorMapService::ColorMapService(const core::ColorScale& scale)
if (raw.empty()) { if (raw.empty()) {
minVal_ = 0.0; minVal_ = 0.0;
maxVal_ = 1.0; maxVal_ = 1.0;
dataMin_ = minVal_;
dataMax_ = maxVal_;
return; return;
} }
minVal_ = raw.front().first; minVal_ = raw.front().first;
maxVal_ = raw.back().first; maxVal_ = raw.back().first;
dataMin_ = minVal_; // 默认数据范围 = 断点范围setDataRange 可改为数据 min/maxcauto
dataMax_ = maxVal_;
double range = maxVal_ - minVal_; double range = maxVal_ - minVal_;
normStops_.reserve(raw.size()); normStops_.reserve(raw.size());
for (const auto& [val, color] : raw) { for (const auto& [val, color] : raw) {
@ -31,10 +35,15 @@ ColorMapService::ColorMapService(const core::ColorScale& scale)
} }
} }
void ColorMapService::setDataRange(double dataMin, double dataMax) {
dataMin_ = dataMin;
dataMax_ = dataMax;
}
double ColorMapService::normalized(double v) const { double ColorMapService::normalized(double v) const {
double range = maxVal_ - minVal_; double range = dataMax_ - dataMin_;
if (range <= 0.0) return 0.0; if (range <= 0.0) return 0.0;
return clamp01((v - minVal_) / range); return clamp01((v - dataMin_) / range);
} }
core::Rgba ColorMapService::colorAtContinuous(double v) const { core::Rgba ColorMapService::colorAtContinuous(double v) const {
@ -42,6 +51,9 @@ core::Rgba ColorMapService::colorAtContinuous(double v) const {
if (normStops_.size() == 1) return normStops_.front().color; if (normStops_.size() == 1) return normStops_.front().color;
double t = normalized(v); double t = normalized(v);
// 非有限值NaN/Inf可能来自降级后端的脏数据或退化数据范围回退首断点色
// 避免下方 upper_bound 用 NaN 比较返回 end() 后解引用越界(崩溃)。
if (!std::isfinite(t)) return normStops_.front().color;
// 找到 t 落在哪两个 normStop 之间 // 找到 t 落在哪两个 normStop 之间
if (t <= normStops_.front().pos) return normStops_.front().color; if (t <= normStops_.front().pos) return normStops_.front().color;

View File

@ -11,7 +11,12 @@ class ColorMapService {
public: public:
explicit ColorMapService(const core::ColorScale& scale); explicit ColorMapService(const core::ColorScale& scale);
// 将数据值归一化到 [0,1]min=首断点值, max=末断点值),超范围 clamp。 // 设置数据值归一化范围(散点用,对齐原版 Plotly cauto=数据 min/max
// 与色阶形状(断点位置,按断点值范围)解耦:色阶位置不变,只改输入值→[0,1] 的映射。
// 默认(不调用)数据范围 = 断点值范围(首/末断点)。
void setDataRange(double dataMin, double dataMax);
// 将数据值归一化到 [0,1](按数据范围,默认=断点值范围),超范围 clamp。
double normalized(double v) const; double normalized(double v) const;
// 连续插值取色(散点用):按断点位置线性插值 RGB。 // 连续插值取色(散点用):按断点位置线性插值 RGB。
@ -31,8 +36,10 @@ private:
core::Rgba color; core::Rgba color;
}; };
std::vector<NormStop> normStops_; std::vector<NormStop> normStops_;
double minVal_; double minVal_; // 断点值范围下界(色阶形状用)
double maxVal_; double maxVal_; // 断点值范围上界
double dataMin_; // 数据归一化下界(默认=minVal_setDataRange 可改)
double dataMax_; // 数据归一化上界(默认=maxVal_
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -1,7 +1,9 @@
#pragma once #pragma once
#include "panels/chart/IDatasetChartStrategy.hpp" #include "IDatasetChartStrategy.hpp" // geopro::controllergeopro_controller PUBLIC include
namespace geopro::app { namespace geopro::app {
struct ErtInversionStrategy : IDatasetChartStrategy { // ERT 反演策略:散点(chart) + 网格等值面(grid) 两阶段。
struct ErtInversionStrategy : controller::IDatasetChartStrategy {
std::string ddCode() const override { return "dd_inversion_data"; } std::string ddCode() const override { return "dd_inversion_data"; }
bool hasGridPhase() const override { return true; } // 反演有网格数据阶段
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -15,8 +15,10 @@
#include <qwt_plot_rescaler.h> #include <qwt_plot_rescaler.h>
#include "PanelHeader.hpp" #include "PanelHeader.hpp"
#include "Theme.hpp"
#include "panels/AnomalyTablePanel.hpp" #include "panels/AnomalyTablePanel.hpp"
#include "panels/DescriptionPanel.hpp" #include "panels/DescriptionPanel.hpp"
#include "panels/chart/ChartTheme.hpp"
#include "panels/chart/ColorBarWidget.hpp" #include "panels/chart/ColorBarWidget.hpp"
#include "panels/chart/ColorMapService.hpp" #include "panels/chart/ColorMapService.hpp"
#include "panels/chart/ContourPlotItem.hpp" #include "panels/chart/ContourPlotItem.hpp"
@ -103,16 +105,8 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) {
plot_->enableAxis(QwtPlot::xTop, false); plot_->enableAxis(QwtPlot::xTop, false);
plot_->enableAxis(QwtPlot::yLeft, true); plot_->enableAxis(QwtPlot::yLeft, true);
// 白底浅色(对齐原版 web 图表)。 // 底色/轴字由 applyChartPlotTheme 按主题设置ctor 末尾 + 主题热切换):
plot_->setCanvasBackground(QBrush(Qt::white)); // 浅色=原版白底深灰字1:1暗色=深色画布避免刺眼白底。
plot_->setAutoFillBackground(true);
{
QPalette pal = plot_->palette();
pal.setColor(QPalette::Window, Qt::white);
pal.setColor(QPalette::WindowText, QColor(90, 90, 90));
pal.setColor(QPalette::Text, QColor(90, 90, 90));
plot_->setPalette(pal);
}
// 交互LivePanner 统一左键实时平移 + 滚轮缩放(消费滚轮事件,不冒泡触发滚动条)。 // 交互LivePanner 统一左键实时平移 + 滚轮缩放(消费滚轮事件,不冒泡触发滚动条)。
new LivePanner(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this); new LivePanner(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this);
@ -171,6 +165,11 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) {
showLabels_ = on; showLabels_ = on;
if (contourItem_) { contourItem_->setShowLabels(on); plot_->replot(); } if (contourItem_) { contourItem_->setShowLabels(on); plot_->replot(); }
}); });
// 主题配色:当前主题套一次 + 监听切换热更新。
applyChartPlotTheme(plot_);
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_,
[this]() { applyChartPlotTheme(plot_); });
} }
GridDataChartView::~GridDataChartView() { GridDataChartView::~GridDataChartView() {

View File

@ -1,29 +0,0 @@
#pragma once
#include <map>
#include <memory>
#include <string>
namespace geopro::app {
class DatasetDetailPage; // 前置
// dd 类型驱动的图表策略:决定某 ddCode 的详情页如何加载/渲染。
struct IDatasetChartStrategy {
virtual ~IDatasetChartStrategy() = default;
virtual std::string ddCode() const = 0;
};
class ChartStrategyRegistry {
public:
void add(std::unique_ptr<IDatasetChartStrategy> s) {
const std::string code = s->ddCode();
map_[code] = std::move(s);
}
IDatasetChartStrategy* find(const std::string& ddCode) const {
auto it = map_.find(ddCode);
return it == map_.end() ? nullptr : it->second.get();
}
bool supports(const std::string& ddCode) const { return map_.count(ddCode) > 0; }
private:
std::map<std::string, std::unique_ptr<IDatasetChartStrategy>> map_;
};
} // namespace geopro::app

View File

@ -1,5 +1,7 @@
#include "panels/chart/RawDataChartView.hpp" #include "panels/chart/RawDataChartView.hpp"
#include "panels/chart/ChartTheme.hpp"
#include "panels/chart/ColorBarWidget.hpp" #include "panels/chart/ColorBarWidget.hpp"
#include "panels/chart/ScatterHoverTip.hpp"
#include "panels/chart/ScatterPlotItem.hpp" #include "panels/chart/ScatterPlotItem.hpp"
#include <QComboBox> #include <QComboBox>
@ -14,7 +16,12 @@
#include <qwt_plot_grid.h> #include <qwt_plot_grid.h>
#include <qwt_plot_rescaler.h> #include <qwt_plot_rescaler.h>
#include <algorithm>
#include <cmath>
#include <limits>
#include "panels/chart/LivePanner.hpp" #include "panels/chart/LivePanner.hpp"
#include "Theme.hpp"
namespace geopro::app { namespace geopro::app {
@ -61,18 +68,10 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
plot_->enableAxis(QwtPlot::xBottom, false); plot_->enableAxis(QwtPlot::xBottom, false);
plot_->enableAxis(QwtPlot::yLeft, true); plot_->enableAxis(QwtPlot::yLeft, true);
// 白底浅色(对齐原版 web 图表,与 App 暗色主题独立):画布白、轴文字深灰。 // 底色/轴字/网格/零线配色由 applyChartPlotTheme 按主题统一设置(见 ctor 末尾 + 主题热切换)。
plot_->setCanvasBackground(QBrush(Qt::white)); // 浅色与原版 web 一致(白底深灰字);暗色改深色画布避免刺眼白底。
plot_->setAutoFillBackground(true);
{
QPalette pal = plot_->palette();
pal.setColor(QPalette::Window, Qt::white);
pal.setColor(QPalette::WindowText, QColor(90, 90, 90));
pal.setColor(QPalette::Text, QColor(90, 90, 90));
plot_->setPalette(pal);
}
// 横纵网格线(对齐原版浅灰网格)。 // 横纵网格线(浅灰,暗色下由 applyChartPlotTheme 重着色)。
auto* grid = new QwtPlotGrid(); auto* grid = new QwtPlotGrid();
grid->setMajorPen(QColor(225, 225, 225), 1.0, Qt::SolidLine); grid->setMajorPen(QColor(225, 225, 225), 1.0, Qt::SolidLine);
grid->setMinorPen(QColor(240, 240, 240), 1.0, Qt::DotLine); grid->setMinorPen(QColor(240, 240, 240), 1.0, Qt::DotLine);
@ -98,6 +97,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
// 交互LivePanner 统一处理左键实时平移 + 滚轮缩放(并消费滚轮事件,不冒泡触发滚动条)。 // 交互LivePanner 统一处理左键实时平移 + 滚轮缩放(并消费滚轮事件,不冒泡触发滚动条)。
new LivePanner(plot_, QwtPlot::xTop, QwtPlot::yLeft, this); new LivePanner(plot_, QwtPlot::xTop, QwtPlot::yLeft, this);
// 散点 hover 提示X/Y/值)。后装(晚于 LivePanner→ 事件链中先收到 MouseMove
// 无按键时弹提示且不消费;有按键(拖动)跳过,交给 LivePanner。数据地址稳定装配期绑定一次。
hoverTip_ = new ScatterHoverTip(plot_, QwtPlot::xTop, QwtPlot::yLeft, this);
hoverTip_->setField(&data_.scatter);
// 允许随停靠面板自由收缩(不强制最小宽度)。 // 允许随停靠面板自由收缩(不强制最小宽度)。
plot_->setMinimumSize(0, 0); plot_->setMinimumSize(0, 0);
@ -113,6 +117,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
colorBar_ = new ColorBarWidget(this); colorBar_ = new ColorBarWidget(this);
colorBar_->setObjectName(QStringLiteral("rawColorScaleBar")); colorBar_->setObjectName(QStringLiteral("rawColorScaleBar"));
lay->addWidget(colorBar_); lay->addWidget(colorBar_);
// 主题配色:当前主题套一次 + 监听切换热更新(暗色给深底,浅色保持白底=原版)。
applyChartPlotTheme(plot_);
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_,
[this]() { applyChartPlotTheme(plot_); });
} }
RawDataChartView::~RawDataChartView() { RawDataChartView::~RawDataChartView() {
@ -127,6 +136,7 @@ QWidget* RawDataChartView::plotArea() const {
void RawDataChartView::setData( void RawDataChartView::setData(
const geopro::controller::DatasetDetailController::ChartData& d) { const geopro::controller::DatasetDetailController::ChartData& d) {
data_ = d; data_ = d;
if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖)
if (d.scatterScale.empty()) return; if (d.scatterScale.empty()) return;
@ -134,6 +144,17 @@ void RawDataChartView::setData(
delete colorSvc_; delete colorSvc_;
colorSvc_ = new ColorMapService(d.scatterScale); colorSvc_ = new ColorMapService(d.scatterScale);
// 散点颜色归一化对齐原版 Plotlycmin/cmax 未设 → cauto=数据 min/max
// 按 vlist 有限值的 min/max 设数据范围,使整段色阶铺满数据实际范围(而非压进 colorBar 全程一小段)。
double vMin = std::numeric_limits<double>::max();
double vMax = std::numeric_limits<double>::lowest();
for (double v : d.scatter.v) {
if (!std::isfinite(v)) continue; // 跳过 NaN/±Inf脏数据否则数据范围被污染→全图 NaN 取色
if (v < vMin) vMin = v;
if (v > vMax) vMax = v;
}
if (vMin <= vMax) colorSvc_->setDataRange(vMin, vMax);
// 卸载旧散点项QwtPlot 默认 autoDelete=true析构时 delete 仍在 dict 的 item // 卸载旧散点项QwtPlot 默认 autoDelete=true析构时 delete 仍在 dict 的 item
// 必须先 detach()(从 dict 移除)再 delete否则 QwtPlot 析构时会 double-free。 // 必须先 detach()(从 dict 移除)再 delete否则 QwtPlot 析构时会 double-free。
if (scatterItem_) { if (scatterItem_) {

View File

@ -11,6 +11,7 @@ namespace geopro::app {
class ColorBarWidget; class ColorBarWidget;
class ScatterPlotItem; class ScatterPlotItem;
class ScatterHoverTip;
// 原数据图表视图:工具条 + QwtPlotx 轴顶部、Panner/Magnifier+ 独立色阶条。 // 原数据图表视图:工具条 + QwtPlotx 轴顶部、Panner/Magnifier+ 独立色阶条。
class RawDataChartView : public QWidget { class RawDataChartView : public QWidget {
@ -34,6 +35,7 @@ private:
// 使用 unique_ptr 管理生命周期attach 后 QwtPlot 接管绘制,但我们仍持有指针 // 使用 unique_ptr 管理生命周期attach 后 QwtPlot 接管绘制,但我们仍持有指针
ColorMapService* colorSvc_ = nullptr; // heap由 setData 重建 ColorMapService* colorSvc_ = nullptr; // heap由 setData 重建
ScatterPlotItem* scatterItem_ = nullptr; ScatterPlotItem* scatterItem_ = nullptr;
ScatterHoverTip* hoverTip_ = nullptr; // 散点 hover 提示QObjectthis 持有)
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -0,0 +1,72 @@
#include "panels/chart/ScatterHoverTip.hpp"
#include <QEvent>
#include <QMouseEvent>
#include <QToolTip>
#include <qwt_plot.h>
#include <qwt_plot_canvas.h>
#include <qwt_scale_map.h>
#include <algorithm>
#include <limits>
namespace geopro::app {
ScatterHoverTip::ScatterHoverTip(QwtPlot* plot, int xAxis, int yAxis, QObject* parent)
: QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) {
if (plot_ && plot_->canvas()) {
// 默认 widget 仅在按键按下时才收到 MouseMovehover无按键需开启鼠标跟踪。
plot_->canvas()->setMouseTracking(true);
plot_->canvas()->installEventFilter(this);
}
}
bool ScatterHoverTip::eventFilter(QObject* obj, QEvent* ev) {
if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev);
if (ev->type() == QEvent::Leave) {
QToolTip::hideText();
return false;
}
if (ev->type() != QEvent::MouseMove) return QObject::eventFilter(obj, ev);
auto* me = static_cast<QMouseEvent*>(ev);
// 拖动平移中(有按键)不弹提示——交给 LivePanner无数据则跳过。
if (me->buttons() != Qt::NoButton || !field_) return false;
const auto& xs = field_->x;
const auto& ys = field_->y;
const auto& vs = field_->v;
const std::size_t n = std::min(xs.size(), ys.size());
if (n == 0) {
QToolTip::hideText();
return false;
}
// 在像素空间找最近散点(与 ScatterPlotItem 同用 canvasMap
const QwtScaleMap xMap = plot_->canvasMap(xAxis_);
const QwtScaleMap yMap = plot_->canvasMap(yAxis_);
const QPointF mp = me->position();
double bestD2 = std::numeric_limits<double>::max();
std::size_t bestI = 0;
for (std::size_t i = 0; i < n; ++i) {
const double dx = xMap.transform(xs[i]) - mp.x();
const double dy = yMap.transform(ys[i]) - mp.y();
const double d2 = dx * dx + dy * dy;
if (d2 < bestD2) {
bestD2 = d2;
bestI = i;
}
}
if (bestD2 <= kHitRadiusPx * kHitRadiusPx) {
const double v = (bestI < vs.size()) ? vs[bestI] : 0.0;
QToolTip::showText(me->globalPosition().toPoint(),
scatterHoverText(xs[bestI], ys[bestI], v), plot_->canvas());
} else {
QToolTip::hideText();
}
return false; // 不消费保留其它过滤器LivePanner链路
}
} // namespace geopro::app

View File

@ -0,0 +1,43 @@
#pragma once
#include <QObject>
#include <QString>
#include "model/Field.hpp" // core::ScatterField
class QwtPlot;
namespace geopro::app {
// 格式化散点 hover 提示文本,对齐原版 Plotly hovertemplate
// <b>X:</b> {x:.3f}<br><b>Y:</b> {y:.3f}<br><b>值:</b> {v:.3f}
// inline 纯函数:无 qwt 依赖,可独立单测。
inline QString scatterHoverText(double x, double y, double v) {
return QStringLiteral("<b>X:</b> %1<br><b>Y:</b> %2<br><b>值:</b> %3")
.arg(x, 0, 'f', 3)
.arg(y, 0, 'f', 3)
.arg(v, 0, 'f', 3);
}
// 散点 hover 提示:监听画布鼠标移动(无按键时),找最近散点,
// 命中半径内用 QToolTip 显示 X/Y/值(对齐原版 Plotly 悬浮)。
// 不消费事件,与 LivePanner左键平移/滚轮缩放)共存。
class ScatterHoverTip : public QObject {
Q_OBJECT
public:
ScatterHoverTip(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr);
// 数据由 RawDataChartView 持有(其成员 data_.scatter地址稳定本类只读不拥有。
void setField(const core::ScatterField* field) { field_ = field; }
protected:
bool eventFilter(QObject* obj, QEvent* ev) override;
private:
QwtPlot* plot_;
int xAxis_;
int yAxis_;
const core::ScatterField* field_ = nullptr;
static constexpr double kHitRadiusPx = 6.0; // 命中半径(像素),对齐方块 marker 尺寸
};
} // namespace geopro::app

View File

@ -1,18 +1,24 @@
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include <QtGlobal>
#include "repo/IAsyncDatasetRepository.hpp" #include "repo/IAsyncDatasetRepository.hpp"
#include "api/DatasetLoadHandles.hpp" #include "api/DatasetLoadHandles.hpp"
namespace geopro::controller { namespace geopro::controller {
DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent) DatasetDetailController::DatasetDetailController(data::IAsyncDatasetRepository& repo,
: QObject(parent), repo_(repo) {} ChartStrategyRegistry& registry, QObject* parent)
: QObject(parent), repo_(repo), registry_(registry) {}
DatasetDetailController::~DatasetDetailController() { DatasetDetailController::~DatasetDetailController() {
if (chartLoad_) chartLoad_->abort(); // 退出契约abort 在飞句柄,不依赖外部析构顺序兜底 if (chartLoad_) chartLoad_->abort(); // 退出契约abort 在飞句柄,不依赖外部析构顺序兜底
if (gridLoad_) gridLoad_->abort(); if (gridLoad_) gridLoad_->abort();
} }
void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode) { void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode,
if (ddCode != QLatin1String("dd_inversion_data")) { // 首版仅支持 ERT 反演 const QString& dsName) {
qInfo("[detail] openDataset id=%s ddCode=%s name=%s", qUtf8Printable(dsId),
qUtf8Printable(ddCode), qUtf8Printable(dsName));
if (!registry_.supports(ddCode.toStdString())) { // 未注册策略 → 优雅降级
qWarning("[detail] 未注册策略 ddCode=%s → 降级提示", qUtf8Printable(ddCode));
emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
return; return;
} }
@ -21,12 +27,13 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd
chartLoad_ = load; chartLoad_ = load;
emit loadStarted(dsId, LoadPhase::Chart); emit loadStarted(dsId, LoadPhase::Chart);
QObject::connect(load, &data::ChartLoad::done, this, QObject::connect(load, &data::ChartLoad::done, this,
[this, load, dsId, ddCode](const data::ChartParts& parts) { [this, load, dsId, ddCode, dsName](const data::ChartParts& parts) {
if (load != chartLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号 if (load != chartLoad_) return; // §5.0 句柄身份比对:丢弃迟到信号
chartLoad_.clear(); chartLoad_.clear();
ChartData d; ChartData d;
d.dsId = dsId; d.dsId = dsId;
d.ddCode = ddCode; d.ddCode = ddCode;
d.dsName = dsName;
d.scatter = parts.scatter; d.scatter = parts.scatter;
d.scatterScale = parts.scatterScale; d.scatterScale = parts.scatterScale;
emit chartReady(d); emit chartReady(d);
@ -35,12 +42,14 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd
[this, load, dsId](const QString& msg) { [this, load, dsId](const QString& msg) {
if (load != chartLoad_) return; if (load != chartLoad_) return;
chartLoad_.clear(); chartLoad_.clear();
qWarning("[detail] 原数据加载失败 id=%s: %s", qUtf8Printable(dsId), qUtf8Printable(msg));
emit loadFailed(dsId, msg); emit loadFailed(dsId, msg);
}); });
} }
void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) { void DatasetDetailController::loadGridData(const QString& dsId, const QString& ddCode) {
if (ddCode != QLatin1String("dd_inversion_data")) return; // 仅 ERT 反演有网格数据 auto* strategy = registry_.find(ddCode.toStdString());
if (!strategy || !strategy->hasGridPhase()) return; // 仅有网格阶段的类型加载网格数据
if (gridLoad_) gridLoad_->abort(); // abort-and-replace if (gridLoad_) gridLoad_->abort(); // abort-and-replace
data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString()); data::GridLoad* load = repo_.loadGridAsync(dsId.toStdString());
gridLoad_ = load; gridLoad_ = load;
@ -60,6 +69,7 @@ void DatasetDetailController::loadGridData(const QString& dsId, const QString& d
[this, load, dsId](const QString& msg) { [this, load, dsId](const QString& msg) {
if (load != gridLoad_) return; if (load != gridLoad_) return;
gridLoad_.clear(); gridLoad_.clear();
qWarning("[detail] 网格数据加载失败 id=%s: %s", qUtf8Printable(dsId), qUtf8Printable(msg));
emit loadFailed(dsId, msg); emit loadFailed(dsId, msg);
}); });
} }

View File

@ -6,6 +6,7 @@
#include "model/Field.hpp" #include "model/Field.hpp"
#include "model/ColorScale.hpp" #include "model/ColorScale.hpp"
#include "model/Anomaly.hpp" #include "model/Anomaly.hpp"
#include "IDatasetChartStrategy.hpp"
namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; } namespace geopro::data { class IAsyncDatasetRepository; class ChartLoad; class GridLoad; }
namespace geopro::controller { namespace geopro::controller {
@ -17,7 +18,7 @@ public:
Q_ENUM(LoadPhase) Q_ENUM(LoadPhase)
struct ChartData { struct ChartData {
QString dsId, ddCode; QString dsId, ddCode, dsName; // dsName页签标题用空则回退 dsId
geopro::core::ScatterField scatter; geopro::core::ScatterField scatter;
geopro::core::ColorScale scatterScale; geopro::core::ColorScale scatterScale;
geopro::core::Grid grid{1, 1}; // Grid 无默认构造以占位值初始化openDataset 会覆盖 geopro::core::Grid grid{1, 1}; // Grid 无默认构造以占位值初始化openDataset 会覆盖
@ -32,10 +33,11 @@ public:
std::vector<geopro::core::Anomaly> anomalies; std::vector<geopro::core::Anomaly> anomalies;
}; };
explicit DatasetDetailController(data::IAsyncDatasetRepository& repo, QObject* parent = nullptr); DatasetDetailController(data::IAsyncDatasetRepository& repo,
ChartStrategyRegistry& registry, QObject* parent = nullptr);
~DatasetDetailController() override; // 退出契约(spec §7)abort 在飞句柄,避免迟到信号打到已析构 this ~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, const QString& dsName = QString());
void focusDataset(const QString& dsId); void focusDataset(const QString& dsId);
void loadGridData(const QString& dsId, const QString& ddCode); void loadGridData(const QString& dsId, const QString& ddCode);
signals: signals:
@ -46,6 +48,7 @@ signals:
void loadFailed(const QString& dsId, const QString& message); void loadFailed(const QString& dsId, const QString& message);
private: private:
data::IAsyncDatasetRepository& repo_; data::IAsyncDatasetRepository& repo_;
ChartStrategyRegistry& registry_;
QPointer<data::ChartLoad> chartLoad_; QPointer<data::ChartLoad> chartLoad_;
QPointer<data::GridLoad> gridLoad_; QPointer<data::GridLoad> gridLoad_;
}; };

View File

@ -0,0 +1,39 @@
#pragma once
#include <map>
#include <memory>
#include <string>
namespace geopro::controller {
// dd 类型驱动的图表策略:决定某 ddCode 的详情页如何加载/渲染。
struct IDatasetChartStrategy {
virtual ~IDatasetChartStrategy() = default;
virtual std::string ddCode() const = 0;
// 该类型是否有「网格数据」加载阶段ERT 反演=true纯散点/折线/图像类=false
// 控制器据此决定是否允许 loadGridData替代硬编码 ddCode 判断。
virtual bool hasGridPhase() const = 0;
};
class ChartStrategyRegistry {
public:
ChartStrategyRegistry() = default;
// 禁拷贝(含 unique_ptr本就不可拷贝显式 delete 让意图清晰)。
// 保留移动map 移动只搬节点find() 返回的裸指针指向的策略对象地址不变、仍有效;
// 且测试 makeInversionRegistry() 按值返回需要移动。
ChartStrategyRegistry(const ChartStrategyRegistry&) = delete;
ChartStrategyRegistry& operator=(const ChartStrategyRegistry&) = delete;
ChartStrategyRegistry(ChartStrategyRegistry&&) = default;
ChartStrategyRegistry& operator=(ChartStrategyRegistry&&) = default;
void add(std::unique_ptr<IDatasetChartStrategy> s) {
const std::string code = s->ddCode();
map_[code] = std::move(s);
}
IDatasetChartStrategy* find(const std::string& ddCode) const {
auto it = map_.find(ddCode);
return it == map_.end() ? nullptr : it->second.get();
}
bool supports(const std::string& ddCode) const { return map_.count(ddCode) > 0; }
private:
std::map<std::string, std::unique_ptr<IDatasetChartStrategy>> map_;
};
} // namespace geopro::controller

View File

@ -1,192 +1,445 @@
#include "WorkbenchNavController.hpp" #include "WorkbenchNavController.hpp"
#include <QMetaObject> #include <QDebug>
#include <algorithm>
#include <unordered_map>
#include <unordered_set>
#include "api/NavLoads.hpp"
#include "api/NavRequest.hpp"
#include "dto/NavDto.hpp" #include "dto/NavDto.hpp"
#include "repo/IAsyncProjectRepository.hpp"
namespace geopro::controller { namespace geopro::controller {
namespace {
// 数据页树形:一次取全的大 pageSize远超单 TM 实际 DS 数;超出会日志告警)+ 每页根节点数。
constexpr int kFetchAllPageSize = 1000;
constexpr int kDataRootPageSize = 5;
} // namespace
using data::DsPage;
using data::DsRow;
using data::DynamicForm;
using data::ExceptionRow;
using data::NavRequest;
using data::ProjectListPage;
using data::ProjectSummary; using data::ProjectSummary;
using data::StructNode;
using data::Workspace; using data::Workspace;
WorkbenchNavController::WorkbenchNavController(data::IProjectRepository& repo, QObject* parent) WorkbenchNavController::WorkbenchNavController(data::IAsyncProjectRepository& repo, QObject* parent)
: QObject(parent), repo_(repo) {} : QObject(parent), repo_(repo) {}
// RAII进入公共导航操作时置忙驱动等待光标任何返回路径都复位——保证 busyChanged 配平。 WorkbenchNavController::~WorkbenchNavController() { abortAll(); }
// 命名(非匿名)以匹配 controller 的 friend 声明,从而在析构时排空挂起的勾选请求。
struct BusyGuard {
WorkbenchNavController* self;
bool* busy;
BusyGuard(WorkbenchNavController* s, bool* b) : self(s), busy(b) {
*busy = true;
emit self->busyChanged(true);
}
~BusyGuard() {
WorkbenchNavController* ctrl = self; // 取本地副本lambda 不能捕获成员
*busy = false;
emit ctrl->busyChanged(false);
// 触发源是延迟合并发射,可能落在嵌套事件循环里:用队列调用在调用栈/嵌套循环展开后再排空,
// 那时 busy_ 已可靠为 false重放才会真正执行lambda 捕获的 ctrl 生命周期与应用一致,安全)。
if (ctrl->checkedTmsPending_)
QMetaObject::invokeMethod(
ctrl, [ctrl] { ctrl->drainPendingCheckedTms(); }, Qt::QueuedConnection);
}
};
void WorkbenchNavController::start() { bool WorkbenchNavController::anyInflight() const {
if (busy_) return; if (startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ ||
BusyGuard guard(this, &busy_); moreFilesReq_ || datasetReq_)
const auto ws = repo_.listWorkspaces(); return true;
if (!ws.ok) { for (const auto& h : checkedInflight_)
emit loadFailed(QStringLiteral("workspaces"), QString::fromStdString(ws.error)); if (h) return true;
return; return false;
}
QString cur;
for (const auto& w : ws.value)
if (w.isCurrent) cur = QString::fromStdString(w.id);
if (cur.isEmpty() && !ws.value.empty()) cur = QString::fromStdString(ws.value.front().id);
currentWorkspaceId_ = cur.toStdString();
emit workspacesLoaded(ws.value, cur);
loadProjectsAndStructure();
} }
void WorkbenchNavController::loadProjectsAndStructure() { void WorkbenchNavController::emitBusyIfChanged() {
const auto ps = repo_.pageProjects(std::string(), std::string(), 1, 10); // 下拉首页 10 const bool now = anyInflight();
if (!ps.ok) { if (now != lastBusy_) {
emit loadFailed(QStringLiteral("projects"), QString::fromStdString(ps.error)); lastBusy_ = now;
return; emit busyChanged(now);
} }
lastProjects_ = ps.value.rows; }
tmExceptionCache_.clear();
currentParentId_.clear(); // 切项目/工作空间重置选中态spec §6
currentParentConfType_ = 0;
checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放
pendingCheckedTms_.clear();
QString curP;
if (!ps.value.rows.empty()) {
const auto& first = ps.value.rows.front();
curP = QString::fromStdString(first.id);
currentProjectId_ = first.id;
currentProjectName_ = first.name;
currentCrsCode_ = first.crsCode;
} else {
currentProjectId_.clear();
currentProjectName_.clear();
currentCrsCode_.clear();
}
emit projectsLoaded(ps.value.rows, curP, ps.value.total);
if (curP.isEmpty()) { void WorkbenchNavController::abortAll() {
lastStructNodes_.clear(); if (startStepReq_) startStepReq_->abort();
emit structureLoaded(QString(), {}); // 暂无项目 → 空树 if (structReq_) structReq_->abort();
return; if (selDataReq_) selDataReq_->abort();
} if (selFileReq_) selFileReq_->abort();
const auto st = repo_.loadStructure(currentProjectId_); if (selDetailReq_) selDetailReq_->abort();
if (!st.ok) { if (moreFilesReq_) moreFilesReq_->abort();
emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error)); if (datasetReq_) datasetReq_->abort();
return; for (const auto& h : checkedInflight_)
} if (h) h->abort();
lastStructNodes_ = st.value; checkedInflight_.clear();
emit structureLoaded(QString::fromStdString(currentProjectName_), st.value); }
void WorkbenchNavController::resetSelectionState() {
tmExceptionCache_.clear();
currentParentId_.clear();
currentParentConfType_ = 0;
allDataRows_.clear();
dataRootsShown_ = 0;
dataTotal_ = 0;
}
// ── start / switchWorkspace 依赖链listWorkspaces → pageProjects → loadStructure ──
// 用 NavRequest 续延(每级 done 内用业务值构造下一级startStepReq_ 跟踪当前在飞级。
// abort-and-replacestart/switchWorkspace 入口 abort 旧 startStepReq_ → 自然丢弃旧链迟到信号。
void WorkbenchNavController::start() {
if (startStepReq_) startStepReq_->abort();
NavRequest* req = repo_.listWorkspacesAsync();
startStepReq_ = req;
emitBusyIfChanged();
QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) {
if (req != startStepReq_) return; // §5.0 身份比对
startStepReq_.clear();
const auto ws = qvariant_cast<std::vector<Workspace>>(v);
QString cur;
for (const auto& w : ws)
if (w.isCurrent) cur = QString::fromStdString(w.id);
if (cur.isEmpty() && !ws.empty()) cur = QString::fromStdString(ws.front().id);
currentWorkspaceId_ = cur.toStdString();
emit workspacesLoaded(ws, cur);
runProjectsAndStructure();
});
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
if (req != startStepReq_) return;
startStepReq_.clear();
emit loadFailed(QStringLiteral("workspaces"), msg);
emitBusyIfChanged();
});
} }
void WorkbenchNavController::switchWorkspace(const QString& tenantId) { void WorkbenchNavController::switchWorkspace(const QString& tenantId) {
if (tenantId.isEmpty() || busy_) return; if (tenantId.isEmpty()) return;
BusyGuard guard(this, &busy_); if (startStepReq_) startStepReq_->abort();
const auto r = repo_.switchWorkspace(tenantId.toStdString()); NavRequest* req = repo_.switchWorkspaceAsync(tenantId.toStdString());
if (!r.ok) { startStepReq_ = req;
emit loadFailed(QStringLiteral("switchWorkspace"), QString::fromStdString(r.error)); emitBusyIfChanged();
return; const std::string tid = tenantId.toStdString();
} QObject::connect(req, &NavRequest::done, this, [this, req, tid](const QVariant&) {
currentWorkspaceId_ = tenantId.toStdString(); if (req != startStepReq_) return;
loadProjectsAndStructure(); startStepReq_.clear();
currentWorkspaceId_ = tid;
runProjectsAndStructure();
});
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
if (req != startStepReq_) return;
startStepReq_.clear();
emit loadFailed(QStringLiteral("switchWorkspace"), msg);
emitBusyIfChanged();
});
} }
void WorkbenchNavController::runProjectsAndStructure() {
NavRequest* req = repo_.pageProjectsAsync(std::string(), std::string(), 1, 10); // 下拉首页 10
startStepReq_ = req;
emitBusyIfChanged();
QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) {
if (req != startStepReq_) return;
startStepReq_.clear();
const auto page = qvariant_cast<ProjectListPage>(v);
lastProjects_ = page.rows;
resetSelectionState();
QString curP;
if (!page.rows.empty()) {
const auto& first = page.rows.front();
curP = QString::fromStdString(first.id);
currentProjectId_ = first.id;
currentProjectName_ = first.name;
currentCrsCode_ = first.crsCode;
} else {
currentProjectId_.clear();
currentProjectName_.clear();
currentCrsCode_.clear();
}
emit projectsLoaded(page.rows, curP, page.total);
if (curP.isEmpty()) {
lastStructNodes_.clear();
emit structureLoaded(QString(), {}); // 暂无项目 → 空树
emitBusyIfChanged();
return;
}
NavRequest* st = repo_.loadStructureAsync(currentProjectId_);
startStepReq_ = st;
emitBusyIfChanged();
QObject::connect(st, &NavRequest::done, this, [this, st](const QVariant& sv) {
if (st != startStepReq_) return;
startStepReq_.clear();
const auto nodes = qvariant_cast<std::vector<StructNode>>(sv);
lastStructNodes_ = nodes;
emit structureLoaded(QString::fromStdString(currentProjectName_), nodes);
emitBusyIfChanged();
});
QObject::connect(st, &NavRequest::failed, this, [this, st](const QString& msg) {
if (st != startStepReq_) return;
startStepReq_.clear();
emit loadFailed(QStringLiteral("structure"), msg);
emitBusyIfChanged();
});
});
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
if (req != startStepReq_) return;
startStepReq_.clear();
emit loadFailed(QStringLiteral("projects"), msg);
emitBusyIfChanged();
});
}
// ── switchProject单请求 loadStructure ──
void WorkbenchNavController::switchProject(const QString& projectId) { void WorkbenchNavController::switchProject(const QString& projectId) {
if (projectId.isEmpty() || busy_) return; if (projectId.isEmpty()) return;
BusyGuard guard(this, &busy_);
currentProjectId_ = projectId.toStdString(); currentProjectId_ = projectId.toStdString();
for (const auto& p : lastProjects_) for (const auto& p : lastProjects_)
if (p.id == currentProjectId_) { if (p.id == currentProjectId_) {
currentProjectName_ = p.name; currentProjectName_ = p.name;
currentCrsCode_ = p.crsCode; currentCrsCode_ = p.crsCode;
} }
const auto st = repo_.loadStructure(currentProjectId_); if (structReq_) structReq_->abort(); // abort-and-replace
if (!st.ok) { NavRequest* req = repo_.loadStructureAsync(currentProjectId_);
emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error)); structReq_ = req;
return; emitBusyIfChanged();
} QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) {
lastStructNodes_ = st.value; if (req != structReq_) return; // §5.0 身份比对
tmExceptionCache_.clear(); structReq_.clear();
currentParentId_.clear(); // 切项目/工作空间重置选中态spec §6 const auto nodes = qvariant_cast<std::vector<StructNode>>(v);
currentParentConfType_ = 0; lastStructNodes_ = nodes;
checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放 resetSelectionState();
pendingCheckedTms_.clear(); emit structureLoaded(QString::fromStdString(currentProjectName_), nodes);
emit structureLoaded(QString::fromStdString(currentProjectName_), st.value); emitBusyIfChanged();
});
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
if (req != structReq_) return;
structReq_.clear();
emit loadFailed(QStringLiteral("structure"), msg);
emitBusyIfChanged();
});
} }
// ── selectObject三并发data 行 / file 行 / 对象详情),各自身份比对、独立 emit ──
void WorkbenchNavController::selectObject(const QString& objectId, int confType) { void WorkbenchNavController::selectObject(const QString& objectId, int confType) {
if (objectId.isEmpty() || busy_) return; if (objectId.isEmpty()) return;
BusyGuard guard(this, &busy_); if (selDataReq_) selDataReq_->abort(); // abort-and-replace 三路
if (selFileReq_) selFileReq_->abort();
if (selDetailReq_) selDetailReq_->abort();
currentParentId_ = objectId.toStdString(); currentParentId_ = objectId.toStdString();
currentParentConfType_ = confType; currentParentConfType_ = confType;
const std::string pid = currentProjectId_; const std::string pid = currentProjectId_;
dataPageNo_ = 1; dataPageNo_ = 1;
filePageNo_ = 1; filePageNo_ = 1;
const auto d = repo_.loadRows(pid, currentParentId_, confType, 3, dataPageNo_); allDataRows_.clear();
if (!d.ok) { dataRootsShown_ = 0;
emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error));
return;
}
dataTotal_ = d.value.total;
emit datasetsLoaded(objectId, d.value.rows, d.value.total, false);
const auto f = repo_.loadRows(pid, currentParentId_, confType, 1, filePageNo_);
if (!f.ok) {
emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error));
return;
}
fileTotal_ = f.value.total;
emit filesLoaded(objectId, f.value.rows, f.value.total, false);
const auto detail = repo_.loadObjectDetail(currentParentId_, confType); // 数据页:一次取全(大 pageSize再按根客户端分页——保证树完整子节点不会跨服务端分页丢失父
if (!detail.ok) { NavRequest* dReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 3, 1, kFetchAllPageSize);
emit loadFailed(QStringLiteral("objectDetail"), QString::fromStdString(detail.error)); selDataReq_ = dReq;
return; NavRequest* fReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 1, filePageNo_);
} selFileReq_ = fReq;
emit objectDetailLoaded(objectId, detail.value); NavRequest* detReq = repo_.loadObjectDetailAsync(currentParentId_, confType);
selDetailReq_ = detReq;
emitBusyIfChanged();
QObject::connect(dReq, &NavRequest::done, this, [this, dReq, objectId](const QVariant& v) {
if (dReq != selDataReq_) return;
selDataReq_.clear();
const auto page = qvariant_cast<DsPage>(v);
if (static_cast<int>(page.rows.size()) < page.total)
qWarning() << "[nav] data/page 未取全listCount=" << page.rows.size()
<< " total=" << page.total << " → 树可能不完整pageSize 不足)";
allDataRows_ = page.rows; // 全量缓存;按根分页由 emitNextDataRootPage 切
dataRootsShown_ = 0;
emitNextDataRootPage(false); // 首页append=false
emitBusyIfChanged();
});
QObject::connect(dReq, &NavRequest::failed, this, [this, dReq](const QString& msg) {
if (dReq != selDataReq_) return;
selDataReq_.clear();
emit loadFailed(QStringLiteral("datasets"), msg);
emitBusyIfChanged();
});
QObject::connect(fReq, &NavRequest::done, this, [this, fReq, objectId](const QVariant& v) {
if (fReq != selFileReq_) return;
selFileReq_.clear();
const auto page = qvariant_cast<DsPage>(v);
fileTotal_ = page.total;
emit filesLoaded(objectId, page.rows, page.total, false);
emitBusyIfChanged();
});
QObject::connect(fReq, &NavRequest::failed, this, [this, fReq](const QString& msg) {
if (fReq != selFileReq_) return;
selFileReq_.clear();
emit loadFailed(QStringLiteral("files"), msg);
emitBusyIfChanged();
});
QObject::connect(detReq, &NavRequest::done, this, [this, detReq, objectId](const QVariant& v) {
if (detReq != selDetailReq_) return;
selDetailReq_.clear();
emit objectDetailLoaded(objectId, qvariant_cast<DynamicForm>(v));
emitBusyIfChanged();
});
QObject::connect(detReq, &NavRequest::failed, this, [this, detReq](const QString& msg) {
if (detReq != selDetailReq_) return;
selDetailReq_.clear();
emit loadFailed(QStringLiteral("objectDetail"), msg);
emitBusyIfChanged();
});
} }
void WorkbenchNavController::loadMoreData() { // ── 数据页树形分页:从 allDataRows_ 按根切下一页(同步,无请求)──
if (currentParentId_.empty() || busy_) return; // 根 = parentId 为空或不在本 TM 全量集合内(其父是源文件节点,不在 data/page 返回里)。
BusyGuard guard(this, &busy_); // 每页取 kDataRootPageSize 个根 + 各自整棵子树;行序保持后端原序(便于稳定显示)。
const auto d = repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 3, ++dataPageNo_); void WorkbenchNavController::emitNextDataRootPage(bool append) {
if (!d.ok) { const QString parent = QString::fromStdString(currentParentId_);
emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error)); // 本 TM 全部行 id 集合(判定谁是根)。
return; std::unordered_set<std::string> ids;
ids.reserve(allDataRows_.size());
for (const auto& r : allDataRows_) ids.insert(r.id);
// 根索引(按原序)+ parentId→子索引表。
std::vector<std::size_t> rootIdx;
std::unordered_map<std::string, std::vector<std::size_t>> kids;
for (std::size_t i = 0; i < allDataRows_.size(); ++i) {
const std::string& p = allDataRows_[i].parentId;
if (p.empty() || ids.find(p) == ids.end())
rootIdx.push_back(i);
else
kids[p].push_back(i);
} }
dataTotal_ = d.value.total; const int rootCount = static_cast<int>(rootIdx.size());
emit datasetsLoaded(QString::fromStdString(currentParentId_), d.value.rows, d.value.total, true); dataTotal_ = rootCount;
// 取本页根 [shown, end) 的整棵子树DFS 收集索引),再按原序输出保稳定。
const int end = std::min(dataRootsShown_ + kDataRootPageSize, rootCount);
std::unordered_set<std::size_t> picked;
for (int k = dataRootsShown_; k < end; ++k) {
std::vector<std::size_t> stack{rootIdx[k]};
while (!stack.empty()) {
const std::size_t cur = stack.back();
stack.pop_back();
if (!picked.insert(cur).second) continue;
auto it = kids.find(allDataRows_[cur].id);
if (it != kids.end())
for (std::size_t c : it->second) stack.push_back(c);
}
}
std::vector<DsRow> out;
out.reserve(picked.size());
for (std::size_t i = 0; i < allDataRows_.size(); ++i)
if (picked.count(i)) out.push_back(allDataRows_[i]);
dataRootsShown_ = end;
emit datasetsLoaded(parent, out, rootCount, append);
}
// ── loadMoreData数据页树形——同步切下一页根无请求。loadMoreFiles文件页服务端分页 ──
void WorkbenchNavController::loadMoreData() {
if (currentParentId_.empty()) return;
if (dataRootsShown_ >= dataTotal_) return; // 无更多根
emitNextDataRootPage(true);
} }
void WorkbenchNavController::loadMoreFiles() { void WorkbenchNavController::loadMoreFiles() {
if (currentParentId_.empty() || busy_) return; if (currentParentId_.empty()) return;
BusyGuard guard(this, &busy_); if (moreFilesReq_) moreFilesReq_->abort();
const auto f = repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 1, ++filePageNo_); NavRequest* req =
if (!f.ok) { repo_.loadRowsAsync(currentProjectId_, currentParentId_, currentParentConfType_, 1, ++filePageNo_);
emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error)); moreFilesReq_ = req;
return; const QString parent = QString::fromStdString(currentParentId_);
} emitBusyIfChanged();
fileTotal_ = f.value.total; QObject::connect(req, &NavRequest::done, this, [this, req, parent](const QVariant& v) {
emit filesLoaded(QString::fromStdString(currentParentId_), f.value.rows, f.value.total, true); if (req != moreFilesReq_) return;
moreFilesReq_.clear();
const auto page = qvariant_cast<DsPage>(v);
fileTotal_ = page.total;
emit filesLoaded(parent, page.rows, page.total, true);
emitBusyIfChanged();
});
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
if (req != moreFilesReq_) return;
moreFilesReq_.clear();
--filePageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreFiles 跳页
emit loadFailed(QStringLiteral("files"), msg);
emitBusyIfChanged();
});
} }
// ── selectDataset单请求 ──
void WorkbenchNavController::selectDataset(const QString& dsObjectId) {
if (dsObjectId.isEmpty()) return;
if (datasetReq_) datasetReq_->abort();
NavRequest* req = repo_.loadDatasetFormAsync(dsObjectId.toStdString());
datasetReq_ = req;
emitBusyIfChanged();
QObject::connect(req, &NavRequest::done, this, [this, req](const QVariant& v) {
if (req != datasetReq_) return;
datasetReq_.clear();
emit datasetDetailLoaded(qvariant_cast<DynamicForm>(v));
emitBusyIfChanged();
});
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
if (req != datasetReq_) return;
datasetReq_.clear();
emit loadFailed(QStringLiteral("datasetDetail"), msg);
emitBusyIfChanged();
});
}
// ── setCheckedTms未命中缓存项并发拉取全到齐后组装新勾选 abort 旧批(以最后一次为准)──
void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
if (busy_) { // 触发源是延迟合并发射,可能落在别的同步操作的嵌套事件循环里: for (const auto& h : checkedInflight_) // abort-and-replace 旧批
pendingCheckedTms_ = tmObjectIds; // 不丢弃,记下最新一次请求,待空闲重放 if (h) h->abort();
checkedTmsPending_ = true; checkedInflight_.clear();
// 入口去重:保留首次出现顺序,剔除重复 id避免重复请求 + 异常树重复 group
QStringList deduped;
QSet<QString> seen;
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();
if (tmExceptionCache_.find(tm) == tmExceptionCache_.end()) missing.push_back(tmQ);
}
if (missing.isEmpty()) { // 全命中缓存同步组装busyChanged 不抖动
assembleAndEmitExceptionTree(deduped);
emitBusyIfChanged();
return; return;
} }
BusyGuard guard(this, &busy_);
// 并发拉取未命中项;每个 done 写缓存、清自身槽,全到齐后组装。计数用 shared 计数器。
auto remaining = std::make_shared<int>(missing.size());
auto failedFlag = std::make_shared<bool>(false);
for (const QString& tmQ : missing) {
const std::string tm = tmQ.toStdString();
NavRequest* req = repo_.loadExceptionsByTmAsync(tm);
checkedInflight_.push_back(req);
QObject::connect(req, &NavRequest::done, this,
[this, req, tm, remaining, failedFlag, deduped](const QVariant& v) {
// 身份比对req 仍在当前批中才接受(旧批已 abort + 从 vector 移除)。
bool inCurrent = false;
for (const auto& h : checkedInflight_)
if (h == req) inCurrent = true;
if (!inCurrent) return;
tmExceptionCache_[tm] = qvariant_cast<std::vector<ExceptionRow>>(v);
if (--(*remaining) == 0 && !*failedFlag) {
checkedInflight_.clear();
assembleAndEmitExceptionTree(deduped);
emitBusyIfChanged();
}
});
QObject::connect(req, &NavRequest::failed, this,
[this, req, remaining, failedFlag](const QString& msg) {
bool inCurrent = false;
for (const auto& h : checkedInflight_)
if (h == req) inCurrent = true;
if (!inCurrent) return;
if (*failedFlag) return; // 仅首个失败发一次
*failedFlag = true;
for (const auto& h : checkedInflight_) // abort 其余在飞
if (h && h != req) h->abort();
checkedInflight_.clear();
emit loadFailed(QStringLiteral("exceptions"), msg);
emitBusyIfChanged();
});
}
emitBusyIfChanged();
}
void WorkbenchNavController::assembleAndEmitExceptionTree(const QStringList& tmObjectIds) {
auto nameOf = [this](const std::string& id) -> std::string { auto nameOf = [this](const std::string& id) -> std::string {
for (const auto& n : lastStructNodes_) for (const auto& n : lastStructNodes_)
if (n.id == id) return n.name; if (n.id == id) return n.name;
@ -197,14 +450,7 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
for (const QString& tmQ : tmObjectIds) { for (const QString& tmQ : tmObjectIds) {
const std::string tm = tmQ.toStdString(); const std::string tm = tmQ.toStdString();
auto it = tmExceptionCache_.find(tm); auto it = tmExceptionCache_.find(tm);
if (it == tmExceptionCache_.end()) { if (it == tmExceptionCache_.end()) continue; // 防御:理论上此时全命中
const auto ex = repo_.loadExceptionsByTm(tm);
if (!ex.ok) {
emit loadFailed(QStringLiteral("exceptions"), QString::fromStdString(ex.error));
return;
}
it = tmExceptionCache_.emplace(tm, ex.value).first;
}
auto grouped = data::dto::groupExceptionsByConsortium(it->second); auto grouped = data::dto::groupExceptionsByConsortium(it->second);
data::ObjectExceptionGroup g; data::ObjectExceptionGroup g;
g.objectId = tm; g.objectId = tm;
@ -217,21 +463,4 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) {
emit exceptionTreeLoaded(groups, total); emit exceptionTreeLoaded(groups, total);
} }
void WorkbenchNavController::drainPendingCheckedTms() {
if (busy_ || !checkedTmsPending_) return;
checkedTmsPending_ = false; // 先清标志再重放,避免重入自旋
setCheckedTms(pendingCheckedTms_); // 此时 busy_=false会正常执行
}
void WorkbenchNavController::selectDataset(const QString& dsObjectId) {
if (dsObjectId.isEmpty() || busy_) return;
BusyGuard guard(this, &busy_);
const auto form = repo_.loadDatasetForm(dsObjectId.toStdString());
if (!form.ok) {
emit loadFailed(QStringLiteral("datasetDetail"), QString::fromStdString(form.error));
return;
}
emit datasetDetailLoaded(form.value);
}
} // namespace geopro::controller } // namespace geopro::controller

View File

@ -1,30 +1,39 @@
#pragma once #pragma once
#include <QObject> #include <QObject>
#include <QPointer>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <map> #include <map>
#include <string> #include <string>
#include <vector> #include <vector>
#include "repo/IProjectRepository.hpp" #include "repo/RepoTypes.hpp"
namespace geopro::data {
class IAsyncProjectRepository;
class NavRequest;
} // namespace geopro::data
namespace geopro::controller { namespace geopro::controller {
// 导航状态机:编排 IProjectRepository持有当前 空间/项目 状态,经信号驱动 UI。不持有 widget。 // 导航状态机:编排 IAsyncProjectRepository异步句柄持有当前 空间/项目 状态,经信号驱动 UI。
// 不持有 widget。abort-and-replace + 句柄身份比对保证迟到信号被丢弃spec §5.0)。
// busyChanged 语义:「是否存在任一在飞句柄」(去抖:值变才发)。
class WorkbenchNavController : public QObject { class WorkbenchNavController : public QObject {
Q_OBJECT Q_OBJECT
public: public:
explicit WorkbenchNavController(data::IProjectRepository& repo, QObject* parent = nullptr); explicit WorkbenchNavController(data::IAsyncProjectRepository& repo, QObject* parent = nullptr);
~WorkbenchNavController() override; // 退出契约abort 所有在飞句柄
void start(); // 启动:拉空间 → 项目 → 结构 void start(); // 启动:拉空间 → 项目 → 结构(依赖链)
QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); } QString currentCrsCode() const { return QString::fromStdString(currentCrsCode_); }
public slots: public slots:
void switchWorkspace(const QString& tenantId); void switchWorkspace(const QString& tenantId);
void switchProject(const QString& projectId); void switchProject(const QString& projectId);
void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情 void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情(并发)
void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树 void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树(并发,带缓存)
void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单 void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单
void loadMoreData(); void loadMoreData();
void loadMoreFiles(); void loadMoreFiles();
@ -45,24 +54,42 @@ signals:
void loadFailed(const QString& stage, const QString& message); void loadFailed(const QString& stage, const QString& message);
private: private:
friend struct BusyGuard; // 允许在 guard 析构时排空挂起的勾选请求 // start / switchWorkspace 依赖链:拉项目 → 拉结构(续延,复用)。
void loadProjectsAndStructure(); // start + switchWorkspace 共用 void runProjectsAndStructure();
void drainPendingCheckedTms(); // 空闲后重放最近一次被挂起的勾选集 void abortAll(); // 退出/重置时 abort 所有在飞句柄
void resetSelectionState(); // 切项目/工作空间重置选中态spec §6
void emitBusyIfChanged(); // 据「是否存在任一在飞句柄」去抖发 busyChanged
bool anyInflight() const; // OR 所有在飞 QPointer / 集合
void assembleAndEmitExceptionTree(const QStringList& tmObjectIds); // 缓存命中后组装异常树
// 数据页树形分页:从 allDataRows_一次取全的整棵按「第一层节点(根)」切下一页,
// 每页 kDataRootPageSize 个根 + 各自整棵子树total=根总数。append=false 首页、true 加载更多。
void emitNextDataRootPage(bool append);
data::IAsyncProjectRepository& repo_;
bool lastBusy_ = false;
// 在飞句柄QPointer 防悬垂;身份比对用):
QPointer<data::NavRequest> startStepReq_; // start / switchWorkspace 依赖链当前在飞级
QPointer<data::NavRequest> structReq_; // switchProject
QPointer<data::NavRequest> selDataReq_; // selectObjectdata 行
QPointer<data::NavRequest> selFileReq_; // selectObjectfile 行
QPointer<data::NavRequest> selDetailReq_; // selectObject对象详情
QPointer<data::NavRequest> moreFilesReq_; // loadMoreFiles数据页改客户端按根分页无在飞句柄
QPointer<data::NavRequest> datasetReq_;
std::vector<QPointer<data::NavRequest>> checkedInflight_; // setCheckedTms未命中缓存的并发批
data::IProjectRepository& repo_;
bool busy_ = false;
bool checkedTmsPending_ = false;
QStringList pendingCheckedTms_;
std::vector<data::ProjectSummary> lastProjects_; std::vector<data::ProjectSummary> lastProjects_;
std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_;
std::string currentParentId_; std::string currentParentId_;
int currentParentConfType_ = 0; int currentParentConfType_ = 0;
std::vector<data::StructNode> lastStructNodes_; // tmId→name 解析 std::vector<data::StructNode> lastStructNodes_; // tmId→name 解析
std::map<std::string, std::vector<data::ExceptionRow>> tmExceptionCache_; std::map<std::string, std::vector<data::ExceptionRow>> tmExceptionCache_;
int dataPageNo_ = 0; int dataPageNo_ = 0;
int filePageNo_ = 0; int filePageNo_ = 0;
int dataTotal_ = 0; int dataTotal_ = 0; // 数据页:根节点总数(树形分页单位)
int fileTotal_ = 0; int fileTotal_ = 0;
std::vector<data::DsRow> allDataRows_; // 当前 TM 一次取全的所有数据行(树形按根客户端分页用)
int dataRootsShown_ = 0; // 已 emit 的根节点数loadMoreData 续切)
}; };
} // namespace geopro::controller } // namespace geopro::controller

View File

@ -7,7 +7,8 @@ add_library(geopro_data STATIC
dto/DatasetChartDto.cpp dto/DatasetChartDto.cpp
api/ApiProjectRepository.cpp api/ApiProjectRepository.cpp
api/ApiDatasetRepository.cpp api/ApiDatasetRepository.cpp
api/DatasetLoadHandles.cpp) api/DatasetLoadHandles.cpp
api/NavRequest.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)

View File

@ -3,7 +3,7 @@
Repository 抽象(**异步契约**QFuture/回调 + 取消 + 分页DTO 与领域模型分离。 Repository 抽象(**异步契约**QFuture/回调 + 取消 + 分页DTO 与领域模型分离。
子目录(设计 §3、§6 子目录(设计 §3、§6
- `repo/` — IProjectRepository, IDatasetRepository - `repo/` — IAsyncProjectRepository, IDatasetRepository
- `local/` — LocalSampleRepositoryM1QtConcurrent 跑解析)+ 各格式解析器 - `local/` — LocalSampleRepositoryM1QtConcurrent 跑解析)+ 各格式解析器
- `api/` — ApiRepositoryM1 骨架,签名对齐 pop-api - `api/` — ApiRepositoryM1 骨架,签名对齐 pop-api
- `dto/` — 后端 JSON DTO + → model 映射 - `dto/` — 后端 JSON DTO + → model 映射

View File

@ -6,6 +6,8 @@
#include <QUrl> #include <QUrl>
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "api/NavLoads.hpp"
#include "api/NavRequest.hpp"
#include "dto/NavDto.hpp" #include "dto/NavDto.hpp"
namespace geopro::data { namespace geopro::data {
@ -13,13 +15,8 @@ namespace geopro::data {
namespace { namespace {
constexpr int kCodeSuccess = 200; constexpr int kCodeSuccess = 200;
bool ok(const net::ApiResponse& r) { return r.code == kCodeSuccess; } // 异步失败谓词:业务码非 200 或网络错误。
bool isFailureA(const net::ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); }
std::string errorOf(const net::ApiResponse& r, const char* fallback) {
if (!r.msg.isEmpty()) return r.msg.toStdString();
if (!r.rawError.isEmpty()) return r.rawError.toStdString();
return fallback;
}
// 后端 id 进 URL 前做百分号编码(不可信外部数据:防 ? # & / 空格 破坏路径/查询)。 // 后端 id 进 URL 前做百分号编码(不可信外部数据:防 ? # & / 空格 破坏路径/查询)。
QString enc(const std::string& s) { QString enc(const std::string& s) {
@ -29,54 +26,59 @@ QString enc(const std::string& s) {
ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {} ApiProjectRepository::ApiProjectRepository(net::ApiClient& api) : api_(api) {}
RepoResult<std::vector<Workspace>> ApiProjectRepository::listWorkspaces() { // ── 异步实现(薄封装:解析器在 try 内见 ApiNavRequest──
const net::ApiResponse r =
api_.get(QStringLiteral("/business/system/tenant/enterprise/joined/list")); NavRequest* ApiProjectRepository::listWorkspacesAsync() {
if (!ok(r)) return {false, {}, errorOf(r, "listWorkspaces failed")}; auto* call = api_.getAsync(QStringLiteral("/business/system/tenant/enterprise/joined/list"));
return {true, dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray()), {}}; return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return QVariant::fromValue(dto::parseWorkspaces(r.data.value(QStringLiteral("value")).toArray()));
}, &isFailureA);
} }
RepoResult<bool> ApiProjectRepository::switchWorkspace(const std::string& tenantId) { NavRequest* ApiProjectRepository::switchWorkspaceAsync(const std::string& tenantId) {
const QString path = const QString path =
QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId)); QStringLiteral("/business/system/tenant/enterprise/switch/%1").arg(enc(tenantId));
const net::ApiResponse r = api_.postJson(path, QJsonObject{}); auto* call = api_.postJsonAsync(path, QJsonObject{});
if (!ok(r)) return {false, false, errorOf(r, "switchWorkspace failed")}; // 切换空间返回新 accessToken必须重新注入与同步版一致后续请求才落到新空间。
// 切换空间返回新 accessToken必须重新注入后续请求才落到新空间。 return new ApiNavRequest(call, [this](const net::ApiResponse& r) {
const QString token = r.data.value(QStringLiteral("accessToken")).toString(); const QString token = r.data.value(QStringLiteral("accessToken")).toString();
if (!token.isEmpty()) api_.setToken(token); if (!token.isEmpty()) api_.setToken(token);
return {true, true, {}}; return QVariant::fromValue(true);
}, &isFailureA);
} }
RepoResult<ProjectListPage> ApiProjectRepository::pageProjects(const std::string& nameFilter, NavRequest* ApiProjectRepository::pageProjectsAsync(const std::string& nameFilter,
const std::string& typeId, int pageNo, const std::string& typeId, int pageNo,
int pageSize) { int pageSize) {
QJsonObject body{{QStringLiteral("projectName"), QString::fromStdString(nameFilter)}, QJsonObject body{{QStringLiteral("projectName"), QString::fromStdString(nameFilter)},
{QStringLiteral("pageNo"), pageNo}, {QStringLiteral("pageNo"), pageNo},
{QStringLiteral("pageSize"), pageSize}}; {QStringLiteral("pageSize"), pageSize}};
if (!typeId.empty()) body[QStringLiteral("projectTypeId")] = QString::fromStdString(typeId); if (!typeId.empty()) body[QStringLiteral("projectTypeId")] = QString::fromStdString(typeId);
const net::ApiResponse r = api_.postJson(QStringLiteral("/business/my/profile/project/page"), body); auto* call = api_.postJsonAsync(QStringLiteral("/business/my/profile/project/page"), body);
if (!ok(r)) return {false, {}, errorOf(r, "pageProjects failed")}; return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return {true, dto::parseProjectPage(r.data), {}}; return QVariant::fromValue(dto::parseProjectPage(r.data));
}, &isFailureA);
} }
RepoResult<std::vector<ProjectType>> ApiProjectRepository::listProjectTypes() { NavRequest* ApiProjectRepository::listProjectTypesAsync() {
const net::ApiResponse r = api_.get(QStringLiteral("/business/project/type/list")); auto* call = api_.getAsync(QStringLiteral("/business/project/type/list"));
if (!ok(r)) return {false, {}, errorOf(r, "listProjectTypes failed")}; return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return {true, dto::parseProjectTypes(r.data.value(QStringLiteral("value")).toArray()), {}}; return QVariant::fromValue(dto::parseProjectTypes(r.data.value(QStringLiteral("value")).toArray()));
}, &isFailureA);
} }
RepoResult<std::vector<StructNode>> ApiProjectRepository::loadStructure(const std::string& projectId) { NavRequest* ApiProjectRepository::loadStructureAsync(const std::string& projectId) {
// 项目结构(项目根 + GS + TM不含 DS。比 projectWorkbench 干净。
const QString path = const QString path =
QStringLiteral("/business/projectStruct/queryProjectStruct/%1").arg(enc(projectId)); QStringLiteral("/business/projectStruct/queryProjectStruct/%1").arg(enc(projectId));
const net::ApiResponse r = api_.get(path); auto* call = api_.getAsync(path);
if (!ok(r)) return {false, {}, errorOf(r, "loadStructure failed")}; return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return {true, dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()), {}}; return QVariant::fromValue(dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()));
}, &isFailureA);
} }
RepoResult<DsPage> ApiProjectRepository::loadRows(const std::string& projectId, NavRequest* ApiProjectRepository::loadRowsAsync(const std::string& projectId,
const std::string& parentId, int parentConfType, const std::string& parentId, int parentConfType,
int classifyType, int pageNo) { int classifyType, int pageNo, int pageSize) {
const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page")
: QStringLiteral("/business/dsObject/data/page"); : QStringLiteral("/business/dsObject/data/page");
const QJsonObject body{ const QJsonObject body{
@ -85,38 +87,39 @@ RepoResult<DsPage> ApiProjectRepository::loadRows(const std::string& projectId,
{QStringLiteral("structParentConfType"), parentConfType}, {QStringLiteral("structParentConfType"), parentConfType},
{QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}},
{QStringLiteral("pageNo"), pageNo}, {QStringLiteral("pageNo"), pageNo},
{QStringLiteral("pageSize"), 5}}; {QStringLiteral("pageSize"), pageSize}};
const net::ApiResponse r = api_.postJson(path, body); auto* call = api_.postJsonAsync(path, body);
if (!ok(r)) return {false, {}, errorOf(r, "loadRows failed")}; return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return {true, dto::parseDsPage(r.data), {}}; return QVariant::fromValue(dto::parseDsPage(r.data));
}, &isFailureA);
} }
RepoResult<DynamicForm> ApiProjectRepository::loadObjectDetail(const std::string& objectId, NavRequest* ApiProjectRepository::loadObjectDetailAsync(const std::string& objectId, int confType) {
int confType) {
const QString path = const QString path =
(confType == 1) (confType == 1)
? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId)) ? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId))
: QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId)); : QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId));
const net::ApiResponse r = api_.get(path); auto* call = api_.getAsync(path);
if (!ok(r)) return {false, {}, errorOf(r, "loadObjectDetail failed")}; return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return {true, dto::parseDynamicForm(r.data), {}}; return QVariant::fromValue(dto::parseDynamicForm(r.data));
}, &isFailureA);
} }
RepoResult<DynamicForm> ApiProjectRepository::loadDatasetForm(const std::string& dsObjectId) { NavRequest* ApiProjectRepository::loadDatasetFormAsync(const std::string& dsObjectId) {
const QString path = const QString path = QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId));
QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId)); auto* call = api_.getAsync(path);
const net::ApiResponse r = api_.get(path); return new ApiNavRequest(call, [](const net::ApiResponse& r) {
if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetForm failed")}; return QVariant::fromValue(dto::parseDynamicForm(r.data));
return {true, dto::parseDynamicForm(r.data), {}}; }, &isFailureA);
} }
RepoResult<std::vector<ExceptionRow>> ApiProjectRepository::loadExceptionsByTm( NavRequest* ApiProjectRepository::loadExceptionsByTmAsync(const std::string& tmObjectId) {
const std::string& tmObjectId) {
const QString path = const QString path =
QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId)); QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId));
const net::ApiResponse r = api_.get(path); auto* call = api_.getAsync(path);
if (!ok(r)) return {false, {}, errorOf(r, "loadExceptionsByTm failed")}; return new ApiNavRequest(call, [](const net::ApiResponse& r) {
return {true, dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()), {}}; return QVariant::fromValue(dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()));
}, &isFailureA);
} }
} // namespace geopro::data } // namespace geopro::data

View File

@ -1,26 +1,30 @@
#pragma once #pragma once
#include "repo/IProjectRepository.hpp" #include "repo/IAsyncProjectRepository.hpp"
namespace geopro::net { class ApiClient; } namespace geopro::net { class ApiClient; }
namespace geopro::data { namespace geopro::data {
// 用共享会话 ApiClient 实现导航仓储同步阻塞。token 由调用方注入 ApiClient。 class NavRequest;
class ApiProjectRepository : public IProjectRepository {
// 用共享会话 ApiClient 实现导航异步仓储。token 由调用方注入 ApiClient。
class ApiProjectRepository : public IAsyncProjectRepository {
public: public:
explicit ApiProjectRepository(net::ApiClient& api); explicit ApiProjectRepository(net::ApiClient& api);
RepoResult<std::vector<Workspace>> listWorkspaces() override; // ── 异步 ── 返回 NavRequest*(薄封装,一方法一请求)。
RepoResult<bool> switchWorkspace(const std::string& tenantId) override; NavRequest* listWorkspacesAsync() override;
RepoResult<ProjectListPage> pageProjects(const std::string& nameFilter, const std::string& typeId, NavRequest* switchWorkspaceAsync(const std::string& tenantId) override;
int pageNo, int pageSize) override; NavRequest* pageProjectsAsync(const std::string& nameFilter, const std::string& typeId,
RepoResult<std::vector<ProjectType>> listProjectTypes() override; int pageNo, int pageSize) override;
RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) override; NavRequest* listProjectTypesAsync() override;
RepoResult<DsPage> loadRows(const std::string& projectId, const std::string& parentId, NavRequest* loadStructureAsync(const std::string& projectId) override;
int parentConfType, int classifyType, int pageNo) override; NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId,
RepoResult<DynamicForm> loadObjectDetail(const std::string& objectId, int confType) override; int parentConfType, int classifyType, int pageNo,
RepoResult<DynamicForm> loadDatasetForm(const std::string& dsObjectId) override; int pageSize = 5) override;
RepoResult<std::vector<ExceptionRow>> loadExceptionsByTm(const std::string& tmObjectId) override; NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override;
NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) override;
NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override;
private: private:
net::ApiClient& api_; net::ApiClient& api_;

14
src/data/api/NavLoads.hpp Normal file
View File

@ -0,0 +1,14 @@
#pragma once
#include <vector>
#include <QMetaType>
#include "repo/RepoTypes.hpp"
// 导航异步返回类型经 QVariant 承载:同线程直连仅需 Q_DECLARE_METATYPE无需 qRegisterMetaType
Q_DECLARE_METATYPE(std::vector<geopro::data::Workspace>)
Q_DECLARE_METATYPE(geopro::data::ProjectListPage)
Q_DECLARE_METATYPE(std::vector<geopro::data::ProjectType>)
Q_DECLARE_METATYPE(std::vector<geopro::data::StructNode>)
Q_DECLARE_METATYPE(geopro::data::DsPage)
Q_DECLARE_METATYPE(geopro::data::DynamicForm)
Q_DECLARE_METATYPE(std::vector<geopro::data::ExceptionRow>)
// bool 已内置 QMetaType。

View File

@ -0,0 +1,47 @@
#include "api/NavRequest.hpp"
#include <stdexcept>
namespace geopro::data {
namespace {
QString reasonOf(const geopro::net::ApiResponse& r) {
return r.msg.isEmpty() ? r.rawError : r.msg;
}
} // namespace
ApiNavRequest::ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure,
QObject* parent)
: NavRequest(parent), call_(call), parse_(std::move(parse)), isFailure_(std::move(isFailure)) {
QObject::connect(call, &geopro::net::IApiCall::finished, this,
[this](const geopro::net::ApiResponse& resp) {
if (aborted_) return; // §5.0 入口守卫
if (isFailure_(resp)) {
emit failed(reasonOf(resp));
deleteLater();
return;
}
QVariant out;
try {
out = parse_(resp); // 仅解析在 try 内(下游 done 处理器抛出不误报)
} catch (const std::exception& e) {
emit failed(QString::fromUtf8(e.what()));
deleteLater();
return;
} catch (...) {
emit failed(QStringLiteral("解析失败:未知异常"));
deleteLater();
return;
}
emit done(out);
deleteLater();
});
}
void ApiNavRequest::abort() {
if (aborted_) return;
aborted_ = true;
if (call_) call_->abort();
deleteLater();
}
} // namespace geopro::data

View File

@ -0,0 +1,39 @@
#pragma once
#include <functional>
#include <QObject>
#include <QPointer>
#include <QString>
#include <QVariant>
#include "IApiCall.hpp"
namespace geopro::data {
// 单请求异步句柄抽象基可测试缝payload 经 QVariant 承载,控制器侧 qvariant_cast<T> 取出。
class NavRequest : public QObject {
Q_OBJECT
public:
using QObject::QObject;
~NavRequest() override = default;
virtual void abort() = 0;
signals:
void done(const QVariant& value);
void failed(const QString& message);
};
// Api 实现:包一个 IApiCall + 注入的解析器ApiResponse → QVariant+ 失败谓词。
class ApiNavRequest : public NavRequest {
Q_OBJECT
public:
using Parser = std::function<QVariant(const geopro::net::ApiResponse&)>;
using Predicate = std::function<bool(const geopro::net::ApiResponse&)>;
ApiNavRequest(geopro::net::IApiCall* call, Parser parse, Predicate isFailure,
QObject* parent = nullptr); // 持有非拥有引用QPointercall 完成(finished)或 abort 后自行 deleteLater 自管理生命周期,本类不得 delete 它
void abort() override;
private:
QPointer<geopro::net::IApiCall> call_;
Parser parse_;
Predicate isFailure_;
bool aborted_ = false;
};
} // namespace geopro::data

View File

@ -49,7 +49,9 @@ ColorScale parseColorBar(const QJsonObject& data) {
if (pair.size() < 2) continue; if (pair.size() < 2) continue;
const double val = num(pair.at(0)); const double val = num(pair.at(0));
const std::string rgba = pair.at(1).toString().toStdString(); const std::string rgba = pair.at(1).toString().toStdString();
cs.addStop(val, parseColor(rgba, AlphaScale::Bit255)); // API colorBar 颜色为混合格式hex(#RRGGBB) 与 CSS rgba(r,g,b,a),其中 a 是 01 浮点
// (实测 "rgba(0, 0, 170, 1)")。须用 Unit 标度a*255否则 a=1 被当字节 → alpha≈1 近透明。
cs.addStop(val, parseColor(rgba, AlphaScale::Unit));
} }
return cs; return cs;
} }

View File

@ -123,6 +123,9 @@ std::vector<DsRow> parseDsRows(const QJsonArray& arr) {
d.typeName = str(o, "name"); // 注意name 字段=ds类型名 d.typeName = str(o, "name"); // 注意name 字段=ds类型名
d.ddCode = str(o, "ddCode"); d.ddCode = str(o, "ddCode");
d.createTime = str(o, "createTime"); d.createTime = str(o, "createTime");
// 数据集树父节点sourceShowParentId 是“显示树”父(=派生数据挂源数据下),回退 parentId。
d.parentId = str(o, "sourceShowParentId");
if (d.parentId.empty()) d.parentId = str(o, "parentId");
const QJsonObject f = o.value(QStringLiteral("file")).toObject(); const QJsonObject f = o.value(QStringLiteral("file")).toObject();
d.fileName = str(f, "name"); d.fileName = str(f, "name");
d.fileUrl = str(f, "url"); d.fileUrl = str(f, "url");

View File

@ -0,0 +1,31 @@
#pragma once
#include <string>
namespace geopro::data {
class NavRequest;
// 导航异步仓储抽象(薄封装:一方法一请求,返回自管理句柄 emit done(QVariant)/failed(msg))。
// 汇聚/链式编排由 WorkbenchNavController 负责(它知道完整序列与状态)。
// 方法与同步 IProjectRepository 一一对应(加 Async 后缀消歧:同名不同返回类型不能同类共存);
// payload 类型见各方法注释(控制器 qvariant_cast
class IAsyncProjectRepository {
public:
virtual ~IAsyncProjectRepository() = default;
virtual NavRequest* listWorkspacesAsync() = 0; // std::vector<Workspace>
virtual NavRequest* switchWorkspaceAsync(const std::string& tenantId) = 0; // bool解析器内 setToken 副作用)
virtual NavRequest* pageProjectsAsync(const std::string& nameFilter,
const std::string& typeId, int pageNo, int pageSize) = 0; // ProjectListPage
virtual NavRequest* listProjectTypesAsync() = 0; // std::vector<ProjectType>
virtual NavRequest* loadStructureAsync(const std::string& projectId) = 0; // std::vector<StructNode>
// pageSize 默认 5文件页/数据页服务端分页);数据页树形需一次取全→控制器传大 pageSize 取整棵子树,
// 再按“第一层节点(根)”客户端分页(详见 WorkbenchNavController::emitNextDataRootPage
virtual NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId,
int parentConfType, int classifyType, int pageNo,
int pageSize = 5) = 0; // DsPage
virtual NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) = 0; // DynamicForm
virtual NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) = 0; // DynamicForm
virtual NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) = 0; // std::vector<ExceptionRow>
};
} // namespace geopro::data

View File

@ -1,41 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include "repo/RepoTypes.hpp"
namespace geopro::data {
// 仓储结果信封:网络可失败,故用显式 Result 而非抛异常,便于 UI 出错误/空状态。
template <class T>
struct RepoResult {
bool ok = false;
T value{};
std::string error;
};
// 导航仓储抽象(同步;呼应既有 IDatasetRepository 风格)。
class IProjectRepository {
public:
virtual ~IProjectRepository() = default;
virtual RepoResult<std::vector<Workspace>> listWorkspaces() = 0;
virtual RepoResult<bool> switchWorkspace(const std::string& tenantId) = 0;
// 项目分页nameFilter 名称模糊可空、typeId 类型过滤(空=不限、pageNo 从 1 起。
virtual RepoResult<ProjectListPage> pageProjects(const std::string& nameFilter,
const std::string& typeId, int pageNo,
int pageSize) = 0;
// 项目类型列表(弹窗类型过滤下拉)。
virtual RepoResult<std::vector<ProjectType>> listProjectTypes() = 0;
virtual RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) = 0;
// 按结构父节点分页拉数据/文件行parentConfType 1=GS 2=TMclassifyType 3=数据 1=文件;
// pageNo 从 1 起pageSize 固定 5。
virtual RepoResult<DsPage> loadRows(const std::string& projectId, const std::string& parentId,
int parentConfType, int classifyType, int pageNo) = 0;
// 对象详情confType 1=GS(getGsObjectDetail) 2=TM(tmObject/getDetail) → 动态表单。
virtual RepoResult<DynamicForm> loadObjectDetail(const std::string& objectId, int confType) = 0;
// 数据集详情dsObject/dynamicForm/{dsObjectId} → 动态表单。
virtual RepoResult<DynamicForm> loadDatasetForm(const std::string& dsObjectId) = 0;
// 单 TM 异常列表(含异常体归属字段)。
virtual RepoResult<std::vector<ExceptionRow>> loadExceptionsByTm(const std::string& tmObjectId) = 0;
};
} // namespace geopro::data

View File

@ -5,8 +5,11 @@ namespace geopro::data {
struct DsNode { std::string id, name, ddType; }; struct DsNode { std::string id, name, ddType; };
// data/page 或 file/page 的一条 ds。数据行只用 dsName/typeName/ddCode文件行另含 file*。 // data/page 或 file/page 的一条 ds。数据行只用 dsName/typeName/ddCode文件行另含 file*。
// parentId = 数据集树的父节点 id取 sourceShowParentId回退 parentId空或不在本批=树根。
// 原版数据列表是树:源「原始数据」为根,派生「反演/接地电阻」挂其下。
struct DsRow { struct DsRow {
std::string id, dsName, typeName, ddCode, createTime; std::string id, dsName, typeName, ddCode, createTime;
std::string parentId;
std::string fileName, fileUrl; std::string fileName, fileUrl;
long long fileSize = 0; long long fileSize = 0;
}; };

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

@ -1,6 +1,7 @@
#include "ApiCall.hpp" #include "ApiCall.hpp"
#include <QNetworkReply> #include <QNetworkReply>
#include <QtGlobal>
#include "ApiResponseParse.hpp" #include "ApiResponseParse.hpp"
namespace geopro::net { namespace geopro::net {
@ -15,7 +16,14 @@ void ApiCall::onFinished() {
QNetworkReply* reply = reply_.data(); // 快照:意图明确 + 防御 reply_ 中途被置空 QNetworkReply* reply = reply_.data(); // 快照:意图明确 + 防御 reply_ 中途被置空
if (!reply) return; if (!reply) return;
ApiResponse resp = buildResponse(reply); ApiResponse resp = buildResponse(reply);
const QString url = reply->url().toString(); // 先快照 URLreply 即将 deleteLater
reply->deleteLater(); reply->deleteLater();
// 错误判定口径同 spec §7code != 200 || !rawError.isEmpty()。
if (resp.rawError.isEmpty() && resp.code == 200)
qInfo("[net] %s http=%d code=%d", qUtf8Printable(url), resp.httpStatus, resp.code);
else
qWarning("[net] %s http=%d code=%d err=%s", qUtf8Printable(url), resp.httpStatus, resp.code,
qUtf8Printable(resp.rawError.isEmpty() ? resp.msg : resp.rawError));
emit finished(resp); emit finished(resp);
deleteLater(); deleteLater();
} }

56
src/net/ApiChain.cpp Normal file
View File

@ -0,0 +1,56 @@
#include "ApiChain.hpp"
#include <stdexcept>
namespace geopro::net {
ApiChain::ApiChain(QList<StepFactory> steps, Predicate isFailure, QObject* parent)
: QObject(parent), steps_(std::move(steps)), isFailure_(std::move(isFailure)) {
Q_ASSERT(!steps_.isEmpty()); // 契约:至少一步(空链永不发 succeeded
Q_ASSERT(isFailure_);
// 首步同步契约:此处同步执行首个 step 工厂。调用方须在 new ApiChain 之前连接
// succeeded/failed且首个工厂不得同步抛出、其 IApiCall 不得同步 emit finished。
// 详见 ApiChain.hpp 首步同步契约说明。
runNext();
}
void ApiChain::runNext() {
if (aborted_) return;
if (index_ >= steps_.size()) { // 全部完成
emit succeeded(responses_);
deleteLater();
return;
}
IApiCall* call = nullptr;
try {
call = steps_[index_](responses_); // 工厂可抛(如 RSA 失败):转 failed
} catch (const std::exception& e) {
ApiResponse r;
r.rawError = QString::fromUtf8(e.what()); // 保留原因;登录 RSA 文案在 AuthService 层包装
aborted_ = true;
emit failed(index_, r);
deleteLater();
return;
}
current_ = call;
QObject::connect(call, &IApiCall::finished, this, [this](const ApiResponse& resp) {
if (aborted_) return; // §5.0 入口守卫
if (isFailure_(resp)) {
aborted_ = true;
emit failed(index_, resp);
deleteLater();
return;
}
responses_.append(resp);
++index_;
runNext(); // 链式推进
});
}
void ApiChain::abort() {
if (aborted_) return;
aborted_ = true;
if (current_) current_->abort(); // abort 当前在飞步骤
deleteLater();
}
} // namespace geopro::net

49
src/net/ApiChain.hpp Normal file
View File

@ -0,0 +1,49 @@
#pragma once
#include <functional>
#include <QList>
#include <QPointer>
#include <QObject>
#include "IApiCall.hpp"
namespace geopro::net {
// 顺序执行 N 个步骤(依赖链):每步工厂用既往响应构造下一 IApiCall工厂可抛 std::exception
// 任一步失败 → fail-fastfailed(index,resp) + abort 当前在飞 + deleteLater。
// 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0aborted_ 闸门 + 一律 deleteLater
// 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。
// 注意Part A导航暂未接入生产调用首个生产使用见 Part B 登录AuthService 串行链);
// 已由 tests/net/test_api_chain.cpp 覆盖,请勿当死代码删除。
//
// ── 首步同步契约(调用方必读)──────────────────────────────────────────────────
// ctor 内同步调用 runNext(),立即执行首个 step 工厂。
// 因此调用方须在 new ApiChain(...) 之前连接 succeeded/failed 信号——否则若首步工厂
// 同步抛异常catch 后 emit failed信号会在连接建立前发出而丢失。
// 契约:
// 1. 首个 step 工厂不得同步抛异常生产路径verifyCodeCheck 不抛,满足)。
// 2. 首个 step 工厂返回的 IApiCall 不得同步 emit finished生产路径真实网络请求满足
// 若将来需要可同步完成的首步,应将 ctor 内 runNext() 改为
// QMetaObject::invokeMethod(this, &ApiChain::runNext, Qt::QueuedConnection)
// 以推迟首拍到事件循环,届时调用方已完成信号连接。
// ────────────────────────────────────────────────────────────────────────────────
class ApiChain : public QObject {
Q_OBJECT
public:
// 工厂:入参为已完成步骤的响应(按序),返回本步 IApiCall持有非拥有引用 QPointerIApiCall 完成或 abort 后自行 deleteLater本类不得 delete 它)。可抛 std::exception。
using StepFactory = std::function<IApiCall*(const QList<ApiResponse>& prior)>;
using Predicate = std::function<bool(const ApiResponse&)>;
ApiChain(QList<StepFactory> steps, Predicate isFailure, QObject* parent = nullptr);
void abort();
signals:
void succeeded(const QList<geopro::net::ApiResponse>& responses);
void failed(int index, const geopro::net::ApiResponse& resp);
private:
void runNext(); // 构造并连接下一步(工厂抛出 → emit failed
QList<StepFactory> steps_;
Predicate isFailure_;
QList<ApiResponse> responses_;
QPointer<IApiCall> current_;
int index_ = 0;
bool aborted_ = false;
};
} // namespace geopro::net

View File

@ -1,9 +1,7 @@
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "ApiCall.hpp" #include "ApiCall.hpp"
#include "ApiResponseParse.hpp"
#include <QEventLoop>
#include <QJsonDocument> #include <QJsonDocument>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
@ -34,14 +32,6 @@ struct ApiClient::Impl {
} }
return req; return req;
} }
// 阻塞等待 reply 完成,解析为 ApiResponse。调用方负责 reply->deleteLater()。
static ApiResponse await(QNetworkReply* reply) {
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
return buildResponse(reply);
}
}; };
ApiClient::ApiClient(QString baseUrl) : impl_(std::make_unique<Impl>(std::move(baseUrl))) {} ApiClient::ApiClient(QString baseUrl) : impl_(std::make_unique<Impl>(std::move(baseUrl))) {}
@ -50,23 +40,6 @@ ApiClient::~ApiClient() = default;
void ApiClient::setToken(const QString& token) { impl_->token = token; } void ApiClient::setToken(const QString& token) { impl_->token = token; }
ApiResponse ApiClient::get(const QString& path) {
QNetworkRequest req = impl_->buildRequest(path);
QNetworkReply* reply = impl_->nam.get(req);
ApiResponse resp = Impl::await(reply);
reply->deleteLater();
return resp;
}
ApiResponse ApiClient::postJson(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);
ApiResponse resp = Impl::await(reply);
reply->deleteLater();
return resp;
}
IApiCall* ApiClient::getAsync(const QString& path) { IApiCall* ApiClient::getAsync(const QString& path) {
QNetworkRequest req = impl_->buildRequest(path); QNetworkRequest req = impl_->buildRequest(path);
QNetworkReply* reply = impl_->nam.get(req); QNetworkReply* reply = impl_->nam.get(req);

View File

@ -21,7 +21,7 @@ struct ApiResponse {
QString rawError; QString rawError;
}; };
// QtNetwork 的步 HTTP 封装。 // QtNetwork 的步 HTTP 封装。
// 内部持有【唯一一个】QNetworkAccessManager 成员,默认共享 cookie jar // 内部持有【唯一一个】QNetworkAccessManager 成员,默认共享 cookie jar
// 因此同一 ApiClient 实例发出的多次请求处于同一会话(共享 JSESSIONID // 因此同一 ApiClient 实例发出的多次请求处于同一会话(共享 JSESSIONID
// 这是登录流程 getImageCode -> verifyCodeCheck -> login2 串联的关键。 // 这是登录流程 getImageCode -> verifyCodeCheck -> login2 串联的关键。
@ -36,10 +36,6 @@ public:
// 设置令牌;注入请求头 geomativeauthorization。token 值本身已含 "Geomative " 前缀。 // 设置令牌;注入请求头 geomativeauthorization。token 值本身已含 "Geomative " 前缀。
void setToken(const QString& token); void setToken(const QString& token);
// 同步 GET / POST(JSON)。用 QNetworkReply + QEventLoop 阻塞等待响应。
ApiResponse get(const QString& path);
ApiResponse postJson(const QString& path, const QJsonObject& body);
// 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。 // 异步 GET / POST(JSON):立即返回自管理句柄,不阻塞。调用方连 IApiCall::finished。
IApiCall* getAsync(const QString& path); IApiCall* getAsync(const QString& path);
IApiCall* postJsonAsync(const QString& path, const QJsonObject& body); IApiCall* postJsonAsync(const QString& path, const QJsonObject& body);

78
src/net/AuthLoads.cpp Normal file
View File

@ -0,0 +1,78 @@
#include "AuthLoads.hpp"
#include "ApiChain.hpp"
#include "ApiClient.hpp" // geopro::net::ApiResponse
#include "IApiCall.hpp"
namespace geopro::net {
namespace {
// 统一的失败文案:优先服务端 msg否则回退传输层 rawError最后给通用文案。
QString reasonOf(const ApiResponse& resp, const QString& fallback) {
if (!resp.msg.isEmpty()) return resp.msg;
if (!resp.rawError.isEmpty()) return resp.rawError;
return fallback;
}
} // namespace
CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), call_(call) {
QObject::connect(call_, &IApiCall::finished, this, [this](const ApiResponse& resp) {
if (aborted_) return; // §5.0 入口守卫
if (resp.code != 200 || !resp.rawError.isEmpty()) {
aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退
emit failed(reasonOf(resp, QStringLiteral("获取验证码失败")));
deleteLater();
return;
}
AuthService::Captcha cap;
cap.codeId = resp.data.value(QStringLiteral("id")).toString();
cap.code = resp.data.value(QStringLiteral("code")).toString();
aborted_ = true; // 终态置位:到达 done 终态后 abort() 早退
emit done(cap);
deleteLater();
});
}
void CaptchaLoad::abort() {
if (aborted_) return;
aborted_ = true;
if (call_) call_->abort();
deleteLater();
}
LoginLoad::LoginLoad(ApiChain* chain, QObject* parent) : QObject(parent), chain_(chain) {
QObject::connect(chain_, &ApiChain::succeeded, this,
[this](const QList<ApiResponse>& responses) {
if (aborted_) return; // §5.0 入口守卫
const QString token =
responses.isEmpty()
? QString()
: responses.last().data.value(QStringLiteral("accessToken")).toString();
if (token.isEmpty()) {
aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退
emit failed(QStringLiteral("登录成功但缺少 accessToken"));
deleteLater();
return;
}
aborted_ = true; // 终态置位:到达 done 终态后 abort() 早退
emit done(token);
deleteLater();
});
QObject::connect(chain_, &ApiChain::failed, this, [this](int, const ApiResponse& resp) {
if (aborted_) return; // §5.0 入口守卫
aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退
emit failed(reasonOf(resp, QStringLiteral("登录失败")));
deleteLater();
});
}
void LoginLoad::abort() {
if (aborted_) return;
aborted_ = true;
if (chain_) chain_->abort();
deleteLater();
}
} // namespace geopro::net

47
src/net/AuthLoads.hpp Normal file
View File

@ -0,0 +1,47 @@
#pragma once
#include <QObject>
#include <QPointer>
#include <QString>
#include "AuthService.hpp" // geopro::net::AuthService::Captcha
namespace geopro::net {
class IApiCall;
class ApiChain;
// 验证码加载句柄net 层,自管理):接管一个 getImageCode 的 IApiCall
// 完成后解析 {data.id, data.code} -> done(Captcha);失败 -> failed(msg)。
// 安全不变量见 spec §5.0aborted_ 入口守卫 + 一律 deleteLater禁同步 delete
class CaptchaLoad : public QObject {
Q_OBJECT
public:
explicit CaptchaLoad(IApiCall* call, QObject* parent = nullptr);
void abort();
signals:
void done(const geopro::net::AuthService::Captcha& captcha);
void failed(const QString& message);
private:
QPointer<IApiCall> call_;
bool aborted_ = false;
};
// 登录加载句柄net 层,自管理):接管一个 verifyCodeCheck->RSA->login2 的 ApiChain
// succeeded 取末步 data.accessToken -> done(token);缺 token 或 failed -> failed(msg)。
class LoginLoad : public QObject {
Q_OBJECT
public:
explicit LoginLoad(ApiChain* chain, QObject* parent = nullptr);
void abort();
signals:
void done(const QString& token);
void failed(const QString& message);
private:
QPointer<ApiChain> chain_;
bool aborted_ = false;
};
} // namespace geopro::net

View File

@ -2,9 +2,11 @@
#include <QJsonObject> #include <QJsonObject>
#include <QJsonValue> #include <QJsonValue>
#include <stdexcept> #include <string>
#include "ApiChain.hpp"
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "AuthLoads.hpp"
#include "crypto/RsaEncryptor.hpp" #include "crypto/RsaEncryptor.hpp"
namespace geopro::net { namespace geopro::net {
@ -17,64 +19,39 @@ const char* const kPathImageCode = "/business/system/personalUser/getImageCode";
const char* const kPathVerifyCode = "/business/system/personalUser/verifyCodeCheck"; const char* const kPathVerifyCode = "/business/system/personalUser/verifyCodeCheck";
const char* const kPathLogin = "/admin/tenant/auth/login2"; const char* const kPathLogin = "/admin/tenant/auth/login2";
// 统一的错误信息:优先用服务端 msg否则回退到传输层 rawError最后给通用文案。
QString errorFrom(const ApiResponse& resp, const QString& fallback) {
if (!resp.msg.isEmpty()) return resp.msg;
if (!resp.rawError.isEmpty()) return resp.rawError;
return fallback;
}
} // namespace } // namespace
AuthService::AuthService(ApiClient& api, std::string rsaPublicKeyPem) AuthService::AuthService(ApiClient& api, std::string rsaPublicKeyPem)
: api_(api), rsaPublicKeyPem_(std::move(rsaPublicKeyPem)) {} : api_(api), rsaPublicKeyPem_(std::move(rsaPublicKeyPem)) {}
AuthService::Captcha AuthService::fetchCaptcha() { CaptchaLoad* AuthService::fetchCaptchaAsync() {
const ApiResponse resp = api_.get(QString::fromLatin1(kPathImageCode)); IApiCall* call = api_.getAsync(QString::fromLatin1(kPathImageCode));
Captcha cap; return new CaptchaLoad(call);
if (resp.code != kCodeSuccess) {
return cap; // 失败时返回空 codeId/code调用方据此判断
}
cap.codeId = resp.data.value(QStringLiteral("id")).toString();
cap.code = resp.data.value(QStringLiteral("code")).toString();
return cap;
} }
AuthService::LoginResult AuthService::login(const QString& username, const QString& password, LoginLoad* AuthService::loginAsync(const QString& username, const QString& password,
const QString& code, const QString& codeId) { const QString& code, const QString& codeId) {
// 1) 校验验证码(与 captcha 同会话)。 // 失败判定与同步版一致:服务端 code != 200 或存在传输层 rawError。
const QJsonObject verifyBody{{QStringLiteral("code"), code}, auto isFailure = [](const ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); };
{QStringLiteral("codeId"), codeId}};
const ApiResponse verify = api_.postJson(QString::fromLatin1(kPathVerifyCode), verifyBody);
if (verify.code != kCodeSuccess) {
return {false, QString(), errorFrom(verify, QStringLiteral("verifyCodeCheck failed"))};
}
// 2) RSA 加密密码PKCS#1 v1.5 -> base64 // step1校验验证码与 captcha 同会话)。
std::string encrypted; ApiChain::StepFactory step1 = [this, code, codeId](const QList<ApiResponse>&) -> IApiCall* {
try { const QJsonObject body{{QStringLiteral("code"), code}, {QStringLiteral("codeId"), codeId}};
return api_.postJsonAsync(QString::fromLatin1(kPathVerifyCode), body);
};
// step2RSA 加密密码PKCS#1 v1.5 -> base64可抛 std::exception → ApiChain 转 failed-> login2。
ApiChain::StepFactory step2 = [this, username, password](const QList<ApiResponse>&) -> IApiCall* {
RsaEncryptor enc(rsaPublicKeyPem_); RsaEncryptor enc(rsaPublicKeyPem_);
encrypted = enc.encryptBase64(password.toStdString()); const std::string encrypted = enc.encryptBase64(password.toStdString());
} catch (const std::exception& e) { const QJsonObject body{{QStringLiteral("username"), username},
return {false, QString(), {QStringLiteral("password"), QString::fromStdString(encrypted)},
QStringLiteral("RSA encryption failed: %1").arg(QString::fromUtf8(e.what()))}; {QStringLiteral("checkCode"), QString()}};
} return api_.postJsonAsync(QString::fromLatin1(kPathLogin), body);
};
// 3) login2checkCode 传空串。 auto* chain = new ApiChain({step1, step2}, isFailure);
const QJsonObject loginBody{ return new LoginLoad(chain);
{QStringLiteral("username"), username},
{QStringLiteral("password"), QString::fromStdString(encrypted)},
{QStringLiteral("checkCode"), QString()}};
const ApiResponse login = api_.postJson(QString::fromLatin1(kPathLogin), loginBody);
if (login.code != kCodeSuccess) {
return {false, QString(), errorFrom(login, QStringLiteral("login2 failed"))};
}
const QString token = login.data.value(QStringLiteral("accessToken")).toString();
if (token.isEmpty()) {
return {false, QString(), QStringLiteral("login2 succeeded but accessToken missing")};
}
return {true, token, QString()};
} }
} // namespace geopro::net } // namespace geopro::net

View File

@ -6,9 +6,13 @@
namespace geopro::net { namespace geopro::net {
class ApiClient; class ApiClient;
class CaptchaLoad;
class LoginLoad;
// 登录编排:复刻已实测通过的 getImageCode -> verifyCodeCheck -> RSA -> login2 流程。 // 登录编排:复刻已实测通过的 getImageCode -> verifyCodeCheck -> RSA -> login2 流程。
// 依赖外部注入的 ApiClient单实例、共享会话RSA 公钥 PEM 由调用方读取后传入。 // 依赖外部注入的 ApiClient单实例、共享会话RSA 公钥 PEM 由调用方读取后传入。
// 异步fetchCaptchaAsync/loginAsync 立即返回自管理句柄net 层 CaptchaLoad/LoginLoad
// 不阻塞 UI句柄 done/failed 后自 deleteLater。AuthService 自身只创建句柄,无需是 QObject。
class AuthService { class AuthService {
public: public:
AuthService(ApiClient& api, std::string rsaPublicKeyPem); AuthService(ApiClient& api, std::string rsaPublicKeyPem);
@ -18,18 +22,14 @@ public:
QString codeId; QString codeId;
QString code; QString code;
}; };
Captcha fetchCaptcha();
struct LoginResult { // 异步拉验证码GET getImageCode返回句柄连 CaptchaLoad::done(Captcha)/failed(QString)。
bool ok = false; CaptchaLoad* fetchCaptchaAsync();
QString token;
QString error;
};
// 校验验证码 -> RSA 加密密码 -> login2。成功返回 {true, accessToken, ""} // 异步登录verifyCodeCheck -> RSA 加密密码 -> login2依赖链
// 任一步失败返回 {false, "", <服务端 msg 或本地错误>} // 返回句柄,连 LoginLoad::done(token)/failed(QString)。
LoginResult login(const QString& username, const QString& password, const QString& code, LoginLoad* loginAsync(const QString& username, const QString& password, const QString& code,
const QString& codeId); const QString& codeId);
private: private:
ApiClient& api_; ApiClient& api_;
@ -37,3 +37,6 @@ private:
}; };
} // namespace geopro::net } // namespace geopro::net
#include <QMetaType>
Q_DECLARE_METATYPE(geopro::net::AuthService::Captcha)

View File

@ -7,7 +7,9 @@ add_library(geopro_net STATIC
IApiCall.cpp IApiCall.cpp
ApiCall.cpp ApiCall.cpp
ApiBatch.cpp ApiBatch.cpp
AuthService.cpp) ApiChain.cpp
AuthService.cpp
AuthLoads.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)
target_compile_features(geopro_net PUBLIC cxx_std_17) target_compile_features(geopro_net PUBLIC cxx_std_17)

View File

@ -39,6 +39,8 @@ 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_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp)
# NavRequest 线QVariant payload: done/failed/abort
target_sources(geopro_tests PRIVATE data/test_nav_request.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
@ -50,6 +52,10 @@ target_sources(geopro_tests PRIVATE net/test_rsa.cpp)
target_sources(geopro_tests PRIVATE net/test_auth.cpp) target_sources(geopro_tests PRIVATE net/test_auth.cpp)
# ApiBatch 线QSignalSpy Qt6::Test # ApiBatch 线QSignalSpy Qt6::Test
target_sources(geopro_tests PRIVATE net/test_api_batch.cpp) target_sources(geopro_tests PRIVATE net/test_api_batch.cpp)
# ApiChain 线//abort/
target_sources(geopro_tests PRIVATE net/test_api_chain.cpp)
# AuthLoads 线CaptchaLoad/LoginLoad done/failed/abort
target_sources(geopro_tests PRIVATE net/test_auth_loads.cpp)
target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test) 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
@ -98,10 +104,13 @@ target_sources(geopro_tests PRIVATE
app/test_colormap_service.cpp app/test_colormap_service.cpp
${CMAKE_SOURCE_DIR}/src/app/panels/chart/ColorMapService.cpp ${CMAKE_SOURCE_DIR}/src/app/panels/chart/ColorMapService.cpp
) )
# hover inline qwt/ ScatterHoverTip
target_sources(geopro_tests PRIVATE app/test_scatter_hover.cpp)
# controller DatasetDetailController QSignalSpy chartReady/loadFailed # controller DatasetDetailController QSignalSpy chartReady/loadFailed
find_package(Qt6 COMPONENTS Test REQUIRED) find_package(Qt6 COMPONENTS Test REQUIRED)
target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp) target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp)
target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp)
target_link_libraries(geopro_tests PRIVATE geopro_controller Qt6::Test) target_link_libraries(geopro_tests PRIVATE geopro_controller Qt6::Test)
add_subdirectory(spike) # spike S3: banded contour add_subdirectory(spike) # spike S3: banded contour

View File

@ -1,8 +1,11 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include "panels/chart/IDatasetChartStrategy.hpp" #include "IDatasetChartStrategy.hpp" // geopro::controller控制器层
using namespace geopro::app; using namespace geopro::controller;
namespace { namespace {
struct Fake : IDatasetChartStrategy { std::string ddCode() const override { return "dd_inversion_data"; } }; struct Fake : IDatasetChartStrategy {
std::string ddCode() const override { return "dd_inversion_data"; }
bool hasGridPhase() const override { return true; }
};
} }
TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) { TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) {
ChartStrategyRegistry reg; ChartStrategyRegistry reg;
@ -12,3 +15,10 @@ TEST(ChartStrategyRegistry, FindsRegisteredAndDegradesUnknown) {
EXPECT_FALSE(reg.supports("dd_unknown")); EXPECT_FALSE(reg.supports("dd_unknown"));
EXPECT_EQ(reg.find("dd_unknown"), nullptr); EXPECT_EQ(reg.find("dd_unknown"), nullptr);
} }
TEST(ChartStrategyRegistry, ReportsHasGridPhase) {
ChartStrategyRegistry reg;
reg.add(std::make_unique<Fake>());
auto* s = reg.find("dd_inversion_data");
ASSERT_NE(s, nullptr);
EXPECT_TRUE(s->hasGridPhase());
}

View File

@ -1,4 +1,5 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <cmath>
#include "panels/chart/ColorMapService.hpp" #include "panels/chart/ColorMapService.hpp"
using namespace geopro; using namespace geopro;
@ -36,6 +37,43 @@ TEST(ColorMapService, ColorAtContinuousAtExtremes) {
EXPECT_EQ(cMax.b, 220); EXPECT_EQ(cMax.b, 220);
} }
// 复刻原版 Plotly散点颜色按**数据 min/maxcauto**归一化,而色阶形状(断点位置)仍按
// colorBar 断点值范围。数据范围窄于色阶范围时,应把整段光谱铺满数据范围(而非压进一小段)。
TEST(ColorMapService, DataRangeDecouplesFromColorscaleShape) {
core::ColorScale cs;
cs.addStop(0.0, core::Rgba{0, 0, 255, 255}); // blue pos 0.0
cs.addStop(500.0, core::Rgba{0, 255, 0, 255}); // green pos 0.5
cs.addStop(1000.0, core::Rgba{255, 0, 0, 255}); // red pos 1.0
app::ColorMapService svc(cs);
// 默认数据范围 = 断点范围 [0,1000]:值 250 → 归一化 0.25 → 蓝绿之间(非纯绿)。
EXPECT_NEAR(svc.normalized(250.0), 0.25, 1e-9);
// 收窄数据范围到 [0,500](模拟 cauto 取数据 min/max
svc.setDataRange(0.0, 500.0);
EXPECT_NEAR(svc.normalized(250.0), 0.5, 1e-9);
// 值 250 → 归一化 0.5 → 落在色阶位置 0.5 = 纯绿。
auto mid = svc.colorAtContinuous(250.0);
EXPECT_EQ(mid.r, 0); EXPECT_EQ(mid.g, 255); EXPECT_EQ(mid.b, 0);
// 数据范围两端铺到色阶两端0→蓝、500→红。
auto lo = svc.colorAtContinuous(0.0);
EXPECT_EQ(lo.b, 255); EXPECT_EQ(lo.r, 0);
auto hi = svc.colorAtContinuous(500.0);
EXPECT_EQ(hi.r, 255); EXPECT_EQ(hi.b, 0);
}
// 脏数据健壮性NaN 值取色不得崩溃(曾因 upper_bound 用 NaN 比较返回 end() 后解引用越界)。
TEST(ColorMapService, ColorAtContinuousNaNSafe) {
core::ColorScale cs;
cs.addStop(0.0, core::Rgba{11, 22, 33, 255});
cs.addStop(100.0, core::Rgba{200, 210, 220, 255});
app::ColorMapService svc(cs);
auto c = svc.colorAtContinuous(std::nan("")); // 不崩 → 回退首断点色
EXPECT_EQ(c.r, 11);
EXPECT_EQ(c.g, 22);
EXPECT_EQ(c.b, 33);
}
TEST(ColorMapService, ScaleRefReturnsOriginal) { TEST(ColorMapService, ScaleRefReturnsOriginal) {
core::ColorScale cs; core::ColorScale cs;
cs.addStop(0.0, core::Rgba{0, 0, 0, 255}); cs.addStop(0.0, core::Rgba{0, 0, 0, 255});

View File

@ -0,0 +1,17 @@
#include <gtest/gtest.h>
#include "panels/chart/ScatterHoverTip.hpp"
using geopro::app::scatterHoverText;
// 对齐原版 Plotly hovertemplate
// <b>X:</b> %{x:.3f}<br><b>Y:</b> %{y:.3f}<br><b>值:</b> %{marker.color:.3f}
TEST(ScatterHoverTip, FormatsXYValueWith3Decimals) {
const QString t = scatterHoverText(12.3456, 24.0, 60.77);
EXPECT_EQ(t, QStringLiteral("<b>X:</b> 12.346<br><b>Y:</b> 24.000<br><b>值:</b> 60.770"));
}
TEST(ScatterHoverTip, RoundsAndPadsToFixed3) {
// 负值、整数、需补零各覆盖一次
EXPECT_EQ(scatterHoverText(-1.0, 0.5, 1.0),
QStringLiteral("<b>X:</b> -1.000<br><b>Y:</b> 0.500<br><b>值:</b> 1.000"));
}

View File

@ -1,11 +1,29 @@
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <QSignalSpy> #include <QSignalSpy>
#include "DatasetDetailController.hpp" #include "DatasetDetailController.hpp"
#include "IDatasetChartStrategy.hpp"
#include "repo/IAsyncDatasetRepository.hpp" #include "repo/IAsyncDatasetRepository.hpp"
#include "api/DatasetLoadHandles.hpp" #include "api/DatasetLoadHandles.hpp"
using namespace geopro; using namespace geopro;
namespace { namespace {
// 反演策略桩:散点 + 网格两阶段。
struct InversionStrategy : controller::IDatasetChartStrategy {
std::string ddCode() const override { return "dd_inversion_data"; }
bool hasGridPhase() const override { return true; }
};
// 无网格阶段策略桩:仅散点(如纯散点类型)。
struct NoGridStrategy : controller::IDatasetChartStrategy {
std::string ddCode() const override { return "dd_scatter_only"; }
bool hasGridPhase() const override { return false; }
};
// 注册了反演策略的注册表(多数用例复用)。
controller::ChartStrategyRegistry makeInversionRegistry() {
controller::ChartStrategyRegistry reg;
reg.add(std::make_unique<InversionStrategy>());
return reg;
}
// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。 // 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::ChartLoad/GridLoad 的信号、override abort。
struct StubChartLoad : data::ChartLoad { struct StubChartLoad : data::ChartLoad {
bool aborted = false; bool aborted = false;
@ -33,7 +51,8 @@ struct StubAsyncRepo : data::IAsyncDatasetRepository {
TEST(DatasetDetailController, EmitsChartReadyOnDone) { TEST(DatasetDetailController, EmitsChartReadyOnDone) {
StubAsyncRepo repo; StubAsyncRepo repo;
controller::DatasetDetailController c(repo); auto reg = makeInversionRegistry();
controller::DatasetDetailController c(repo, reg);
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(); repo.lastChart->fireDone();
@ -42,7 +61,8 @@ TEST(DatasetDetailController, EmitsChartReadyOnDone) {
TEST(DatasetDetailController, EmitsLoadFailedOnFailed) { TEST(DatasetDetailController, EmitsLoadFailedOnFailed) {
StubAsyncRepo repo; StubAsyncRepo repo;
controller::DatasetDetailController c(repo); auto reg = makeInversionRegistry();
controller::DatasetDetailController c(repo, reg);
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(); repo.lastChart->fireFailed();
@ -51,16 +71,39 @@ TEST(DatasetDetailController, EmitsLoadFailedOnFailed) {
TEST(DatasetDetailController, UnsupportedTypeFailsImmediately) { TEST(DatasetDetailController, UnsupportedTypeFailsImmediately) {
StubAsyncRepo repo; StubAsyncRepo repo;
controller::DatasetDetailController c(repo); auto reg = makeInversionRegistry(); // 注册了反演,但未注册 dd_other
controller::DatasetDetailController c(repo, reg);
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
c.openDataset("ds1", "dd_other"); c.openDataset("ds1", "dd_other");
EXPECT_EQ(spy.count(), 1); EXPECT_EQ(spy.count(), 1);
EXPECT_EQ(repo.lastChart, nullptr); // 未发起加载 EXPECT_EQ(repo.lastChart, nullptr); // 未发起加载
} }
// 空注册表 → 任意 ddCode 都不支持 → loadFailed不发起加载。
TEST(DatasetDetailController, EmptyRegistryFailsAnyType) {
StubAsyncRepo repo;
controller::ChartStrategyRegistry reg; // 空
controller::DatasetDetailController c(repo, reg);
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
c.openDataset("ds1", "dd_inversion_data");
EXPECT_EQ(spy.count(), 1);
EXPECT_EQ(repo.lastChart, nullptr);
}
// 无网格阶段的策略 → loadGridData 不发起网格加载。
TEST(DatasetDetailController, NoGridPhaseStrategySkipsGridLoad) {
StubAsyncRepo repo;
controller::ChartStrategyRegistry reg;
reg.add(std::make_unique<NoGridStrategy>());
controller::DatasetDetailController c(repo, reg);
c.loadGridData("ds1", "dd_scatter_only");
EXPECT_EQ(repo.lastGrid, nullptr); // hasGridPhase()==false → 未发起
}
TEST(DatasetDetailController, AbortsPreviousOnReopen) { TEST(DatasetDetailController, AbortsPreviousOnReopen) {
StubAsyncRepo repo; StubAsyncRepo repo;
controller::DatasetDetailController c(repo); auto reg = makeInversionRegistry();
controller::DatasetDetailController c(repo, reg);
c.openDataset("dsA", "dd_inversion_data"); c.openDataset("dsA", "dd_inversion_data");
StubChartLoad* a = repo.lastChart; StubChartLoad* a = repo.lastChart;
c.openDataset("dsB", "dd_inversion_data"); // 替换 c.openDataset("dsB", "dd_inversion_data"); // 替换
@ -69,7 +112,8 @@ TEST(DatasetDetailController, AbortsPreviousOnReopen) {
TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) { TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) {
StubAsyncRepo repo; StubAsyncRepo repo;
controller::DatasetDetailController c(repo); auto reg = makeInversionRegistry();
controller::DatasetDetailController c(repo, reg);
QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady); QSignalSpy spy(&c, &controller::DatasetDetailController::chartReady);
c.openDataset("dsA", "dd_inversion_data"); c.openDataset("dsA", "dd_inversion_data");
StubChartLoad* a = repo.lastChart; StubChartLoad* a = repo.lastChart;
@ -83,7 +127,8 @@ TEST(DatasetDetailController, DropsLateSignalFromAbortedLoad) {
TEST(DatasetDetailController, EmitsGridReadyOnDone) { TEST(DatasetDetailController, EmitsGridReadyOnDone) {
StubAsyncRepo repo; StubAsyncRepo repo;
controller::DatasetDetailController c(repo); auto reg = makeInversionRegistry();
controller::DatasetDetailController c(repo, reg);
QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady); QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady);
c.loadGridData("ds1", "dd_inversion_data"); c.loadGridData("ds1", "dd_inversion_data");
repo.lastGrid->fireDone(); repo.lastGrid->fireDone();
@ -92,7 +137,8 @@ TEST(DatasetDetailController, EmitsGridReadyOnDone) {
TEST(DatasetDetailController, EmitsLoadFailedOnGridFailed) { TEST(DatasetDetailController, EmitsLoadFailedOnGridFailed) {
StubAsyncRepo repo; StubAsyncRepo repo;
controller::DatasetDetailController c(repo); auto reg = makeInversionRegistry();
controller::DatasetDetailController c(repo, reg);
QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed); QSignalSpy spy(&c, &controller::DatasetDetailController::loadFailed);
c.loadGridData("ds1", "dd_inversion_data"); c.loadGridData("ds1", "dd_inversion_data");
repo.lastGrid->fireFailed(); repo.lastGrid->fireFailed();
@ -101,7 +147,8 @@ TEST(DatasetDetailController, EmitsLoadFailedOnGridFailed) {
TEST(DatasetDetailController, DropsLateGridSignalFromAbortedLoad) { TEST(DatasetDetailController, DropsLateGridSignalFromAbortedLoad) {
StubAsyncRepo repo; StubAsyncRepo repo;
controller::DatasetDetailController c(repo); auto reg = makeInversionRegistry();
controller::DatasetDetailController c(repo, reg);
QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady); QSignalSpy spy(&c, &controller::DatasetDetailController::gridReady);
c.loadGridData("dsA", "dd_inversion_data"); c.loadGridData("dsA", "dd_inversion_data");
StubGridLoad* a = repo.lastGrid; StubGridLoad* a = repo.lastGrid;

View File

@ -0,0 +1,263 @@
#include <gtest/gtest.h>
#include <QSignalSpy>
#include <QVariant>
#include <vector>
#include "WorkbenchNavController.hpp"
#include "api/NavLoads.hpp"
#include "api/NavRequest.hpp"
#include "repo/IAsyncProjectRepository.hpp"
using namespace geopro;
namespace {
// 桩句柄:不声明 Q_OBJECT —— 发射继承自 data::NavRequest 的 done/failed、override abort 记录。
struct StubNavRequest : data::NavRequest {
bool aborted = false;
void abort() override { aborted = true; }
void fireDone(const QVariant& v) { emit done(v); }
void fireFailed() { emit failed(QStringLiteral("x")); }
};
struct StubAsyncRepo : data::IAsyncProjectRepository {
StubNavRequest* lastWorkspaces = nullptr;
StubNavRequest* lastSwitchWs = nullptr;
StubNavRequest* lastProjects = nullptr;
StubNavRequest* lastStructure = nullptr;
StubNavRequest* lastData = nullptr;
StubNavRequest* lastFile = nullptr;
StubNavRequest* lastDetail = nullptr;
StubNavRequest* lastDataset = nullptr;
std::vector<StubNavRequest*> exceptions; // setCheckedTms 并发批
data::NavRequest* listWorkspacesAsync() override { return lastWorkspaces = new StubNavRequest; }
data::NavRequest* switchWorkspaceAsync(const std::string&) override {
return lastSwitchWs = new StubNavRequest;
}
data::NavRequest* pageProjectsAsync(const std::string&, const std::string&, int, int) override {
return lastProjects = new StubNavRequest;
}
data::NavRequest* listProjectTypesAsync() override { return new StubNavRequest; }
data::NavRequest* loadStructureAsync(const std::string&) override {
return lastStructure = new StubNavRequest;
}
data::NavRequest* loadRowsAsync(const std::string&, const std::string&, int, int classifyType,
int, int) override {
auto* r = new StubNavRequest;
if (classifyType == 1)
lastFile = r;
else
lastData = r;
return r;
}
data::NavRequest* loadObjectDetailAsync(const std::string&, int) override {
return lastDetail = new StubNavRequest;
}
data::NavRequest* loadDatasetFormAsync(const std::string&) override {
return lastDataset = new StubNavRequest;
}
data::NavRequest* loadExceptionsByTmAsync(const std::string&) override {
auto* r = new StubNavRequest;
exceptions.push_back(r);
return r;
}
};
QVariant wsVar() {
return QVariant::fromValue(std::vector<data::Workspace>{{"w1", "WS", 2, true}});
}
QVariant pageVar() {
data::ProjectSummary p;
p.id = "p1";
p.name = "P1";
return QVariant::fromValue(data::ProjectListPage{{p}, 1});
}
QVariant emptyPageVar() { return QVariant::fromValue(data::ProjectListPage{{}, 0}); }
QVariant nodesVar() { return QVariant::fromValue(std::vector<data::StructNode>{}); }
QVariant dsPageVar() { return QVariant::fromValue(data::DsPage{{}, 0}); }
QVariant formVar() { return QVariant::fromValue(data::DynamicForm{}); }
QVariant exVar() { return QVariant::fromValue(std::vector<data::ExceptionRow>{}); }
} // namespace
// start() 依赖链workspaces → projects → structure逐级 emit 既有信号。
TEST(WorkbenchNavController, StartChainEmitsWorkspacesThenProjectsThenStructure) {
StubAsyncRepo repo;
controller::WorkbenchNavController c(repo);
QSignalSpy wsSpy(&c, &controller::WorkbenchNavController::workspacesLoaded);
QSignalSpy psSpy(&c, &controller::WorkbenchNavController::projectsLoaded);
QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded);
c.start();
repo.lastWorkspaces->fireDone(wsVar());
EXPECT_EQ(wsSpy.count(), 1);
repo.lastProjects->fireDone(pageVar());
EXPECT_EQ(psSpy.count(), 1);
repo.lastStructure->fireDone(nodesVar());
EXPECT_EQ(stSpy.count(), 1);
}
// busyChanged 反映在飞发起→true最后完成→false。
TEST(WorkbenchNavController, BusyChangedReflectsInflight) {
StubAsyncRepo repo;
controller::WorkbenchNavController c(repo);
QSignalSpy busySpy(&c, &controller::WorkbenchNavController::busyChanged);
c.start();
ASSERT_GE(busySpy.count(), 1);
EXPECT_TRUE(busySpy.takeFirst().at(0).toBool()); // 首次 true
repo.lastWorkspaces->fireDone(wsVar());
repo.lastProjects->fireDone(pageVar());
repo.lastStructure->fireDone(nodesVar());
EXPECT_FALSE(busySpy.last().at(0).toBool()); // 末尾 false
}
// 空项目链projects 空 → structure 发空树 → busy 复位(不发结构请求)。
TEST(WorkbenchNavController, StartWithNoProjectsEmitsEmptyStructureAndClearsBusy) {
StubAsyncRepo repo;
controller::WorkbenchNavController c(repo);
QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded);
QSignalSpy busySpy(&c, &controller::WorkbenchNavController::busyChanged);
c.start();
repo.lastWorkspaces->fireDone(wsVar());
repo.lastProjects->fireDone(emptyPageVar());
EXPECT_EQ(stSpy.count(), 1);
EXPECT_FALSE(busySpy.last().at(0).toBool());
}
// setCheckedTms新勾选 abort 旧异常批(以最后一次为准)。
TEST(WorkbenchNavController, SetCheckedTmsAbortsPreviousBatch) {
StubAsyncRepo repo;
controller::WorkbenchNavController c(repo);
c.setCheckedTms({"tmA"});
StubNavRequest* a = repo.exceptions.back();
c.setCheckedTms({"tmB"}); // 覆盖
EXPECT_TRUE(a->aborted);
}
// setCheckedTms全命中缓存 → 不发新请求、直接组装 emit。
TEST(WorkbenchNavController, SetCheckedTmsUsesCacheWithoutRequest) {
StubAsyncRepo repo;
controller::WorkbenchNavController c(repo);
QSignalSpy exSpy(&c, &controller::WorkbenchNavController::exceptionTreeLoaded);
c.setCheckedTms({"tmA"}); // 首次未命中 → 发请求
ASSERT_EQ(repo.exceptions.size(), 1u);
repo.exceptions.back()->fireDone(exVar()); // 写缓存 + emit
EXPECT_EQ(exSpy.count(), 1);
c.setCheckedTms({"tmA"}); // 第二次命中缓存 → 不发新请求
EXPECT_EQ(repo.exceptions.size(), 1u);
EXPECT_EQ(exSpy.count(), 2); // 仍 emit
}
// 回灌防护abort 后旧句柄迟到 done 被身份比对丢弃。
TEST(WorkbenchNavController, DropsLateStructureAfterProjectSwitch) {
StubAsyncRepo repo;
controller::WorkbenchNavController c(repo);
QSignalSpy stSpy(&c, &controller::WorkbenchNavController::structureLoaded);
c.switchProject("pA");
StubNavRequest* a = repo.lastStructure;
c.switchProject("pB");
StubNavRequest* b = repo.lastStructure;
EXPECT_TRUE(a->aborted); // 旧句柄被 abort
a->fireDone(nodesVar()); // 旧 → 丢弃
EXPECT_EQ(stSpy.count(), 0);
b->fireDone(nodesVar()); // 新 → 正常
EXPECT_EQ(stSpy.count(), 1);
}
// selectObject 三并发data/file/detail 各自完成 → 各发对应信号。
TEST(WorkbenchNavController, SelectObjectConcurrentEmitsAllThree) {
StubAsyncRepo repo;
controller::WorkbenchNavController c(repo);
QSignalSpy dsSpy(&c, &controller::WorkbenchNavController::datasetsLoaded);
QSignalSpy flSpy(&c, &controller::WorkbenchNavController::filesLoaded);
QSignalSpy dtSpy(&c, &controller::WorkbenchNavController::objectDetailLoaded);
c.selectObject("obj1", 1);
repo.lastData->fireDone(dsPageVar());
repo.lastFile->fireDone(dsPageVar());
repo.lastDetail->fireDone(formVar());
EXPECT_EQ(dsSpy.count(), 1);
EXPECT_EQ(flSpy.count(), 1);
EXPECT_EQ(dtSpy.count(), 1);
}
// selectDataset 单请求 → datasetDetailLoaded。
TEST(WorkbenchNavController, SelectDatasetEmitsDetail) {
StubAsyncRepo repo;
controller::WorkbenchNavController c(repo);
QSignalSpy spy(&c, &controller::WorkbenchNavController::datasetDetailLoaded);
c.selectDataset("ds1");
repo.lastDataset->fireDone(formVar());
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"));
}
namespace {
// 构造一棵树6 个根(parentId="src" 不在集合内) + r1 两个子(c1a/c1b)。扁平 8 行。
QVariant treePageVar() {
auto mk = [](const std::string& id, const std::string& parent) {
data::DsRow d; d.id = id; d.dsName = id; d.ddCode = "dd"; d.parentId = parent; return d;
};
std::vector<data::DsRow> rows;
for (int i = 1; i <= 6; ++i) rows.push_back(mk("r" + std::to_string(i), "src"));
rows.push_back(mk("c1a", "r1"));
rows.push_back(mk("c1b", "r1"));
return QVariant::fromValue(data::DsPage{rows, static_cast<int>(rows.size())});
}
} // namespace
// 数据页树形分页:按「第一层节点(根)」分页(每页 5 根)total=根总数,子树随根整棵带出;
// loadMoreData 同步续切下一页根。
TEST(WorkbenchNavController, DataPaginatesByRootNodeNotFlatCount) {
qRegisterMetaType<std::vector<geopro::data::DsRow>>();
StubAsyncRepo repo;
controller::WorkbenchNavController c(repo);
QSignalSpy dsSpy(&c, &controller::WorkbenchNavController::datasetsLoaded);
c.selectObject("tm1", 2);
repo.lastData->fireDone(treePageVar()); // 一次取全 8 行
ASSERT_EQ(dsSpy.count(), 1);
const auto rows0 = qvariant_cast<std::vector<data::DsRow>>(dsSpy.at(0).at(1));
EXPECT_EQ(rows0.size(), 7u); // 首页 5 根(r1..r5) + r1 两子 = 7 行
EXPECT_EQ(dsSpy.at(0).at(2).toInt(), 6); // total = 根总数 6非扁平 8
EXPECT_FALSE(dsSpy.at(0).at(3).toBool()); // append=false
c.loadMoreData(); // 同步切下一页(无新请求)
ASSERT_EQ(dsSpy.count(), 2);
const auto rows1 = qvariant_cast<std::vector<data::DsRow>>(dsSpy.at(1).at(1));
EXPECT_EQ(rows1.size(), 1u); // 第二页第 6 个根 r6
EXPECT_EQ(rows1[0].id, "r6");
EXPECT_EQ(dsSpy.at(1).at(2).toInt(), 6);
EXPECT_TRUE(dsSpy.at(1).at(3).toBool()); // append=true
c.loadMoreData(); // 已无更多根 → 不再 emit
EXPECT_EQ(dsSpy.count(), 2);
}
// 失败路径start 首级失败 → loadFailed + busy 复位。
TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) {
StubAsyncRepo repo;
controller::WorkbenchNavController c(repo);
QSignalSpy failSpy(&c, &controller::WorkbenchNavController::loadFailed);
QSignalSpy busySpy(&c, &controller::WorkbenchNavController::busyChanged);
c.start();
repo.lastWorkspaces->fireFailed();
EXPECT_EQ(failSpy.count(), 1);
EXPECT_FALSE(busySpy.last().at(0).toBool());
}

View File

@ -17,15 +17,31 @@ TEST(DatasetChartDto, ParsesInversionGrid) {
EXPECT_DOUBLE_EQ(g.vmax, 6.0); EXPECT_DOUBLE_EQ(g.vmax, 6.0);
} }
TEST(DatasetChartDto, ParsesColorBar) { TEST(DatasetChartDto, ParsesColorBar) {
// Use "json" delimiter to avoid raw-string termination by ")" inside rgba() // Real API colorBar format: MIXED hex (#RRGGBB) and CSS rgba() with alpha in 01 scale
const char* colorBarJson = "{\"properties\":{\"colorBar\":[[\"10\",\"rgba(0,0,255,255)\"],[\"20\",\"rgba(255,0,0,255)\"]]}}"; // (verified live: lvl/colorGradation/getDetail returns e.g. ["1.51","rgba(0, 0, 170, 1)"]).
// Every stop must render OPAQUE (alpha=255); the rgba a=1 must map to 255, not 1.
const char* colorBarJson =
"{\"properties\":{\"colorBar\":["
"[\"0.00\",\"#00008B\"],"
"[\"1.51\",\"rgba(0, 0, 170, 1)\"],"
"[\"60.77\",\"#FFFF00\"],"
"[\"138.20\",\"rgba(255, 128, 0, 1)\"]]}}";
auto d = obj(colorBarJson); auto d = obj(colorBarJson);
auto cs = parseColorBar(d); auto cs = parseColorBar(d);
auto stops = cs.stopValues(); auto stops = cs.stops();
ASSERT_EQ(stops.size(), 2u); ASSERT_EQ(stops.size(), 4u);
EXPECT_DOUBLE_EQ(stops[0], 10.0); EXPECT_DOUBLE_EQ(stops[0].first, 0.0);
auto c = cs.colorAt(12.0); // [10,20) -> blue EXPECT_DOUBLE_EQ(stops[1].first, 1.51);
EXPECT_GT(c.b, c.r); // hex stop → exact rgb, opaque
EXPECT_EQ(stops[0].second.r, 0); EXPECT_EQ(stops[0].second.g, 0);
EXPECT_EQ(stops[0].second.b, 0x8B); EXPECT_EQ(stops[0].second.a, 255);
// rgba(a=1) stop → exact rgb, OPAQUE (regression: was alpha=1 under Bit255 → near-transparent)
EXPECT_EQ(stops[1].second.r, 0); EXPECT_EQ(stops[1].second.g, 0);
EXPECT_EQ(stops[1].second.b, 170); EXPECT_EQ(stops[1].second.a, 255);
EXPECT_EQ(stops[3].second.r, 255); EXPECT_EQ(stops[3].second.g, 128);
EXPECT_EQ(stops[3].second.b, 0); EXPECT_EQ(stops[3].second.a, 255);
// every stop opaque
for (const auto& s : stops) EXPECT_EQ(s.second.a, 255) << "value=" << s.first;
} }
TEST(DatasetChartDto, ParsesAnomalyPolyline) { TEST(DatasetChartDto, ParsesAnomalyPolyline) {
auto arr = QJsonDocument::fromJson( auto arr = QJsonDocument::fromJson(

View File

@ -176,6 +176,24 @@ TEST(NavDto, ParseDsRowsDataAndFile) {
EXPECT_EQ(page.rows[0].dsName, "a"); EXPECT_EQ(page.rows[0].dsName, "a");
} }
// 数据集树父节点sourceShowParentId 优先(=显示树父),缺失回退 parentId皆无则空=树根)。
TEST(NavDto, ParseDsRowsParentIdForTree) {
const auto d = dto::parseDsRows(arrOf(R"([
{"id":"raw","dsName":"E3原始","name":"ERT原始数据","ddCode":"dd_ert_measurement_data",
"parentId":"srcFile","sourceShowParentId":"srcFile"},
{"id":"inv","dsName":"E3反演","name":"电阻率数据","ddCode":"dd_inversion_data",
"parentId":"raw","sourceShowParentId":"raw"},
{"id":"fallback","dsName":"仅parentId","name":"t","ddCode":"dd",
"parentId":"raw"},
{"id":"root","dsName":"无父","name":"t","ddCode":"dd"}
])"));
ASSERT_EQ(d.size(), 4u);
EXPECT_EQ(d[0].parentId, "srcFile"); // 原始数据→源文件节点(不在本批→树根)
EXPECT_EQ(d[1].parentId, "raw"); // 派生反演→挂原始数据下
EXPECT_EQ(d[2].parentId, "raw"); // 无 sourceShowParentId → 回退 parentId
EXPECT_TRUE(d[3].parentId.empty()); // 二者皆无 → 空(树根)
}
TEST(NavDto, ParseProjectItemFullFields) { TEST(NavDto, ParseProjectItemFullFields) {
const auto v = dto::parseProjectList(arrOf(R"([ const auto v = dto::parseProjectList(arrOf(R"([
{"id":"p1","projectName":"演示","projectCode":"001","status":2, {"id":"p1","projectName":"演示","projectCode":"001","status":2,

View File

@ -0,0 +1,45 @@
#include <gtest/gtest.h>
#include <QSignalSpy>
#include <QVariant>
#include "api/NavRequest.hpp"
#include "api/NavLoads.hpp"
#include "net/FakeApiCall.hpp"
using namespace geopro::data;
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(); };
} // namespace
TEST(NavRequest, EmitsDoneWithParsedPayload) {
auto* call = new FakeApiCall;
auto* req = new ApiNavRequest(call,
[](const ApiResponse&) { return QVariant::fromValue(DsPage{{}, 42}); }, isFailure);
QSignalSpy doneSpy(req, &NavRequest::done);
call->fire(ok());
ASSERT_EQ(doneSpy.count(), 1);
const auto page = qvariant_cast<DsPage>(doneSpy.takeFirst().at(0));
EXPECT_EQ(page.total, 42);
}
TEST(NavRequest, EmitsFailedOnBusinessError) {
auto* call = new FakeApiCall;
auto* req = new ApiNavRequest(call, [](const ApiResponse&) { return QVariant(); }, isFailure);
QSignalSpy failSpy(req, &NavRequest::failed);
call->fire(bad());
EXPECT_EQ(failSpy.count(), 1);
}
TEST(NavRequest, AbortSuppressesLateDone) {
auto* call = new FakeApiCall;
auto* req = new ApiNavRequest(call, [](const ApiResponse&) { return QVariant(); }, isFailure);
QSignalSpy doneSpy(req, &NavRequest::done);
req->abort();
EXPECT_TRUE(call->aborted);
call->fire(ok());
EXPECT_EQ(doneSpy.count(), 0);
}

View File

@ -0,0 +1,70 @@
#include <gtest/gtest.h>
#include <stdexcept>
#include <QSignalSpy>
#include "ApiChain.hpp"
#include "net/FakeApiCall.hpp"
using namespace geopro::net;
using geopro::net::test::FakeApiCall;
namespace {
ApiResponse ok(int v = 0) { ApiResponse r; r.code = 200; r.httpStatus = 200; r.data = QJsonObject{{"v", v}}; 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(); };
} // namespace
TEST(ApiChain, RunsStepsInOrderAndPassesPriorResponses) {
auto* s1 = new FakeApiCall;
auto* s2 = new FakeApiCall;
int seenPrior = -1;
QList<ApiChain::StepFactory> steps{
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; },
[&](const QList<ApiResponse>& prior) -> IApiCall* {
seenPrior = prior.size(); // 第二步能看到第一步响应
return s2;
}};
auto* chain = new ApiChain(steps, isFailure);
QSignalSpy okSpy(chain, &ApiChain::succeeded);
s1->fire(ok(11)); // 第一步完成 → 触发第二步工厂
EXPECT_EQ(seenPrior, 1);
EXPECT_EQ(okSpy.count(), 0); // 还差第二步
s2->fire(ok(22));
EXPECT_EQ(okSpy.count(), 1);
const auto resps = okSpy.takeFirst().at(0).value<QList<ApiResponse>>();
EXPECT_EQ(resps.size(), 2);
}
TEST(ApiChain, FailFastShortCircuitsRemainingSteps) {
auto* s1 = new FakeApiCall;
bool secondBuilt = false;
QList<ApiChain::StepFactory> steps{
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; },
[&](const QList<ApiResponse>&) -> IApiCall* { secondBuilt = true; return new FakeApiCall; }};
auto* chain = new ApiChain(steps, isFailure);
QSignalSpy failSpy(chain, &ApiChain::failed);
s1->fire(bad()); // 第一步失败
EXPECT_EQ(failSpy.count(), 1);
EXPECT_FALSE(secondBuilt); // 后续步骤不再构造
}
TEST(ApiChain, AbortGateSuppressesLateSignals) {
auto* s1 = new FakeApiCall;
QList<ApiChain::StepFactory> steps{[&](const QList<ApiResponse>&) -> IApiCall* { return s1; }};
auto* chain = new ApiChain(steps, isFailure);
QSignalSpy okSpy(chain, &ApiChain::succeeded);
chain->abort();
EXPECT_TRUE(s1->aborted); // 在飞步骤被 abort
s1->fire(ok()); // 迟到
EXPECT_EQ(okSpy.count(), 0); // aborted_ 闸门
}
TEST(ApiChain, StepFactoryThrowBecomesFailed) {
auto* s1 = new FakeApiCall;
QList<ApiChain::StepFactory> steps{
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; },
[&](const QList<ApiResponse>&) -> IApiCall* { throw std::runtime_error("rsa fail"); }};
auto* chain = new ApiChain(steps, isFailure);
QSignalSpy failSpy(chain, &ApiChain::failed);
s1->fire(ok()); // 触发第二步工厂 → 抛 → failed
EXPECT_EQ(failSpy.count(), 1);
}

View File

@ -1,17 +1,19 @@
// net 层端到端登录连通测试(真实站点)。 // net 层端到端登录连通测试(真实站点)。
// 复刻已实测通过的流程getImageCode -> verifyCodeCheck -> RSA -> login2 // 复刻已实测通过的流程getImageCode -> verifyCodeCheck -> RSA -> login2
// 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。 // 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。
// 需要 QCoreApplication 提供事件循环ApiClient 用 QEventLoop 同步等待) // 异步化fetchCaptchaAsync/loginAsync 返回自管理句柄QSignalSpy::wait 驱动事件循环等待
// 网络不可达时本用例会失败(属环境问题,非逻辑问题)。 // 需要 QCoreApplication 提供事件循环。网络不可达时本用例会失败(属环境问题,非逻辑问题)。
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <QCoreApplication> #include <QCoreApplication>
#include <QSignalSpy>
#include <fstream> #include <fstream>
#include <sstream> #include <sstream>
#include <string> #include <string>
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "AuthLoads.hpp"
#include "AuthService.hpp" #include "AuthService.hpp"
namespace { namespace {
@ -35,11 +37,24 @@ TEST(AuthLiveTest, FullLoginFlowReturnsToken) {
geopro::net::AuthService auth( geopro::net::AuthService auth(
api, slurp("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem")); api, slurp("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem"));
auto cap = auth.fetchCaptcha(); auto* cl = auth.fetchCaptchaAsync();
QSignalSpy capDone(cl, &geopro::net::CaptchaLoad::done);
QSignalSpy capFail(cl, &geopro::net::CaptchaLoad::failed);
ASSERT_TRUE(capDone.wait(10000) || capFail.count() > 0);
ASSERT_EQ(capDone.count(), 1)
<< (capFail.count() ? capFail.takeFirst().at(0).toString().toStdString() : "captcha failed");
auto cap = capDone.takeFirst().at(0).value<geopro::net::AuthService::Captcha>();
ASSERT_FALSE(cap.codeId.isEmpty()); ASSERT_FALSE(cap.codeId.isEmpty());
ASSERT_FALSE(cap.code.isEmpty()); ASSERT_FALSE(cap.code.isEmpty());
auto r = auth.login("sydk", "123456", cap.code, cap.codeId); auto* ll = auth.loginAsync("sydk", "123456", cap.code, cap.codeId);
EXPECT_TRUE(r.ok) << r.error.toStdString(); QSignalSpy loginDone(ll, &geopro::net::LoginLoad::done);
EXPECT_TRUE(r.token.startsWith("Geomative ")) << r.token.toStdString(); QSignalSpy loginFail(ll, &geopro::net::LoginLoad::failed);
ASSERT_TRUE(loginDone.wait(10000) || loginFail.count() > 0);
EXPECT_EQ(loginDone.count(), 1)
<< (loginFail.count() ? loginFail.takeFirst().at(0).toString().toStdString() : "");
if (loginDone.count()) {
auto token = loginDone.takeFirst().at(0).toString();
EXPECT_TRUE(token.startsWith("Geomative ")) << token.toStdString();
}
} }

View File

@ -0,0 +1,153 @@
// AuthLoads 离线单测CaptchaLoad/LoginLoad 句柄行为done/failed/abort 闸门),
// 用 FakeApiCall + 真 ApiChain 离线驱动不联网。QSignalSpy 需 Qt6::Test。
#include <gtest/gtest.h>
#include <stdexcept>
#include <QSignalSpy>
#include "ApiChain.hpp"
#include "ApiClient.hpp"
#include "AuthLoads.hpp"
#include "AuthService.hpp"
#include "net/FakeApiCall.hpp"
using namespace geopro::net;
using geopro::net::test::FakeApiCall;
namespace {
ApiResponse captchaOk() {
ApiResponse r;
r.code = 200;
r.httpStatus = 200;
r.data = QJsonObject{{"id", "cid-1"}, {"code", "AB12"}};
return r;
}
ApiResponse tokenOk() {
ApiResponse r;
r.code = 200;
r.httpStatus = 200;
r.data = QJsonObject{{"accessToken", "Geomative deadbeef"}};
return r;
}
ApiResponse plainOk() {
ApiResponse r;
r.code = 200;
r.httpStatus = 200;
return r;
}
ApiResponse failResp() {
ApiResponse r;
r.code = 500;
r.httpStatus = 200;
r.msg = QStringLiteral("验证码错误");
return r;
}
auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); };
} // namespace
TEST(CaptchaLoad, ParsesCaptchaOnSuccess) {
auto* call = new FakeApiCall;
auto* load = new CaptchaLoad(call);
QSignalSpy doneSpy(load, &CaptchaLoad::done);
QSignalSpy failSpy(load, &CaptchaLoad::failed);
call->fire(captchaOk());
ASSERT_EQ(doneSpy.count(), 1);
EXPECT_EQ(failSpy.count(), 0);
auto cap = doneSpy.takeFirst().at(0).value<AuthService::Captcha>();
EXPECT_EQ(cap.codeId, QStringLiteral("cid-1"));
EXPECT_EQ(cap.code, QStringLiteral("AB12"));
}
TEST(CaptchaLoad, EmitsFailedOnErrorResponse) {
auto* call = new FakeApiCall;
auto* load = new CaptchaLoad(call);
QSignalSpy doneSpy(load, &CaptchaLoad::done);
QSignalSpy failSpy(load, &CaptchaLoad::failed);
call->fire(failResp());
EXPECT_EQ(doneSpy.count(), 0);
ASSERT_EQ(failSpy.count(), 1);
EXPECT_EQ(failSpy.takeFirst().at(0).toString(), QStringLiteral("验证码错误"));
}
TEST(CaptchaLoad, AbortGateSuppressesLateSignal) {
auto* call = new FakeApiCall;
auto* load = new CaptchaLoad(call);
QSignalSpy doneSpy(load, &CaptchaLoad::done);
load->abort();
EXPECT_TRUE(call->aborted);
call->fire(captchaOk()); // 迟到
EXPECT_EQ(doneSpy.count(), 0);
}
TEST(LoginLoad, EmitsTokenOnChainSuccess) {
auto* s1 = new FakeApiCall;
auto* s2 = new FakeApiCall;
QList<ApiChain::StepFactory> steps{
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; },
[&](const QList<ApiResponse>&) -> IApiCall* { return s2; }};
auto* chain = new ApiChain(steps, isFailure);
auto* load = new LoginLoad(chain);
QSignalSpy doneSpy(load, &LoginLoad::done);
QSignalSpy failSpy(load, &LoginLoad::failed);
s1->fire(plainOk()); // verifyCodeCheck 通过 → 触发 step2
s2->fire(tokenOk()); // login2 返回 token
ASSERT_EQ(doneSpy.count(), 1);
EXPECT_EQ(failSpy.count(), 0);
EXPECT_EQ(doneSpy.takeFirst().at(0).toString(), QStringLiteral("Geomative deadbeef"));
}
TEST(LoginLoad, EmitsFailedWhenChainFails) {
auto* s1 = new FakeApiCall;
QList<ApiChain::StepFactory> steps{
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; }};
auto* chain = new ApiChain(steps, isFailure);
auto* load = new LoginLoad(chain);
QSignalSpy doneSpy(load, &LoginLoad::done);
QSignalSpy failSpy(load, &LoginLoad::failed);
s1->fire(failResp());
EXPECT_EQ(doneSpy.count(), 0);
ASSERT_EQ(failSpy.count(), 1);
EXPECT_EQ(failSpy.takeFirst().at(0).toString(), QStringLiteral("验证码错误"));
}
TEST(LoginLoad, EmitsFailedWhenTokenMissing) {
auto* s1 = new FakeApiCall;
QList<ApiChain::StepFactory> steps{
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; }};
auto* chain = new ApiChain(steps, isFailure);
auto* load = new LoginLoad(chain);
QSignalSpy doneSpy(load, &LoginLoad::done);
QSignalSpy failSpy(load, &LoginLoad::failed);
s1->fire(plainOk()); // 成功但无 accessToken
EXPECT_EQ(doneSpy.count(), 0);
ASSERT_EQ(failSpy.count(), 1);
}
// M-2step 工厂抛异常(模拟 RSA 失败)时 LoginLoad 应 emit failed。
// 第一步用 FakeApiCall不同步 fire由测试手动触发第二步工厂抛异常。
// 构造顺序new ApiChain同步执行 step1 工厂s1 已建立但未 fire
// → new LoginLoad连接 chain 的 succeeded/failed
// → s1->fire(plainOk())step1 成功 → step2 工厂抛 → ApiChain emit failed
// → LoginLoad 已连接,收到 failed
TEST(LoginLoad, EmitsFailedWhenStepFactoryThrows) {
auto* s1 = new FakeApiCall;
QList<ApiChain::StepFactory> steps{
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; },
[&](const QList<ApiResponse>&) -> IApiCall* {
throw std::runtime_error("rsa fail");
}};
auto* chain = new ApiChain(steps, isFailure);
auto* load = new LoginLoad(chain);
QSignalSpy doneSpy(load, &LoginLoad::done);
QSignalSpy failSpy(load, &LoginLoad::failed);
s1->fire(plainOk()); // step1 成功 → step2 工厂抛异常 → ApiChain emit failed
ASSERT_EQ(failSpy.count(), 1);
EXPECT_EQ(doneSpy.count(), 0);
}