Compare commits
17 Commits
8b3bc50f58
...
10072eb4b3
| Author | SHA1 | Date |
|---|---|---|
|
|
10072eb4b3 | |
|
|
66869a1e2e | |
|
|
067852e08b | |
|
|
5f00cdce7a | |
|
|
93462d78ef | |
|
|
6b4267d78a | |
|
|
d1c1bf96b1 | |
|
|
4ca5893800 | |
|
|
0cb0ed8aa0 | |
|
|
62352395ba | |
|
|
b097fa6e56 | |
|
|
05f0bf3d4f | |
|
|
2ee1ccdb0f | |
|
|
4beb7a9523 | |
|
|
22a7f2339e | |
|
|
751b486254 | |
|
|
6d0ec909ec |
|
|
@ -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()
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
|
||||||
|
|
@ -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.1–0.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. 一句话现状
|
||||||
|
|
||||||
|
geopro(Qt6/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 是 0–1 浮点**),共 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.93–197.79)**,把整段色阶铺满数据范围;而客户端 `ColorMapService` 错用 colorBar 全程 0–1323 归一化数据 → 压进色阶 0.03–0.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`),不变。
|
||||||
|
- **修复 B(hover 仍无效)**:根因 `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 + 各 loadFailed;App 启动版本/路径。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 渲染。
|
||||||
数据详情含两个页签,均已完成:
|
- CMake(VS 自带)+ Ninja + MSVC 14.51;GoogleTest/CTest;vcpkg(仅非 Qt 依赖)。
|
||||||
- **原数据**:Plotly scattergl 风格散点(方形点/白描边/连续色阶/x 轴顶部)。
|
|
||||||
- **网格数据**:填充等值面 + 黑色等值线 + 沿线数值标注 + 不规则白边(NaN 裁剪)+ 底部异常表/描述。
|
|
||||||
|
|
||||||
## 3. 技术栈与构建
|
|
||||||
|
|
||||||
- Qt6 Widgets;**Qwt 6.2**(源码在 `external/qwt-src/`,**用 `cmake/qwt.cmake` 以 CMake 构建**静态库,不要用 qmake——本机 VS2026 缺 vswhere,qmake 不可用)。
|
|
||||||
- 关键: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 渲染。
|
|
||||||
- CMake(VS 自带 4.2.3)+ Ninja + MSVC 14.51;GoogleTest/CTest;vcpkg(仅非 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` 环境项;**ctest(dev-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 服务端网格化**波动 1–4s** |
|
|
||||||
|
|
||||||
- `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}`(服务端网格化,**波动 1–4s**) + `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/yLeft,7px 方块白描边,连续配色,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 1–4s 最痛)。
|
||||||
- **填充用栅格而非多边形**: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`(内用 ApiChain:verifyCodeCheck→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.1–0.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 2(ERT 测量散点)/ Phase 3(TEM 折线,新 LineChartView)**:仅当 Phase 0 确认有样本才解锁。
|
||||||
|
- **Phase 4:BLOCKED**——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
|
|
@ -0,0 +1,235 @@
|
||||||
|
# 数据集详情图:扩展到其余 dd 类型 实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: 用 `superpowers:subagent-driven-development` 执行(每个 bite-sized 任务派一个 subagent,TDD,频繁提交)。步骤用 `- [ ]`。
|
||||||
|
> **铁律(项目记忆,务必遵守):** 任何对原版的不确定,**必须用 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**,脚本需重试(4–8 次)。
|
||||||
|
|
||||||
|
**铁律纠正(重要):详情渲染由数据集的"真实数据类型"决定,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.4(main.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 2:ERT 测量类(散点形态,仅当 Task 0.2 确认有样本才进行)
|
||||||
|
|
||||||
|
> 🔓 解锁条件:Phase 0 确认 ERT 测量(`measurement/scatter/graph` 或 `measurement/rows`)有活样本且抓到真实响应。**未解锁则本 Phase 全部 BLOCKED,跳过。**
|
||||||
|
> 渲染归类:散点类 → **复用** `ScatterPlotItem`/`RawDataChartView`,不新建 View(DRY)。
|
||||||
|
> 接口(已核对):`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.1(DTO 解析 + 单测)** 用 `tests/fixtures/dd/ert-measurement-scatter.json` 真实响应做夹具,先写 `tests/data/test_dataset_chart_dto.cpp` 新用例断言字段映射(RED)→ 在 `DatasetChartDto.cpp`(或新拆 `MeasurementDto.cpp`,若主文件超 ~400 行)实现 `parseMeasurementScatter(QJsonObject)`(GREEN)。映射须严格按真实响应字段,**不臆造字段名**。→ verify:DTO 测试绿。
|
||||||
|
- [ ] **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.6(rows 形态)** 若 Phase 0 显示 `measurement/rows` 是另一种展示(如伪剖面/列表),据规格归类(等值面→复用 `ContourPlotItem`;表格→另议),重复 2.1–2.5 节奏。**形态不明则 BLOCKED 待 Phase 0 规格。**
|
||||||
|
- [ ] **Task 2.7(提交)** 每 1–2 个 Task 一次原子提交,保持构建绿。`feat(detail): ERT 测量散点详情图 (DTO+仓储+策略+视图)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3:TEM / 设备时序(折线形态,仅当 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.1(DTO 解析 + 单测)** 用真实响应夹具 `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.3(LineChartView + 组件测)** 新建 `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 4(BLOCKED:待样本)—— dd_grid / 轨迹 / 测井 / GPR
|
||||||
|
|
||||||
|
> 以下类型当前租户**无活样本**(spec §2.4 + Phase 0 复核)。**绝不规划凭想象的渲染任务**(违反 1:1 复刻原则)。本 Phase 只列「解锁前置条件 + 调研占位」,待样本到位后各自展开为类似 Phase 2/3 的 TDD 任务序列。
|
||||||
|
|
||||||
|
- [ ] **Task 4.1(dd_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.4(GPR 雷达剖面)** 🚫 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. **每 1–2 个 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 同样 BLOCKED,plan 仍成立(Phase 1 独立有价值)。(2) `IDatasetChartStrategy` 接口扩展刻意保守(只加 `hasGridPhase()`),避免为未解锁类型过度抽象;待新 View 真接入时再演进。
|
||||||
|
|
@ -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_/drain,busyChanged=在飞存在性)。
|
||||||
|
- **登录路径 ✅**(同计划 Part B:B1/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 现网络栈三层全同步阻塞:
|
||||||
|
|
|
||||||
|
|
@ -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 为 0–1 浮点**;原 `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`「客户端」页签(R051–R096)、「测井参数表」「DD类型」
|
- 客户端菜单:`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`「客户端」页签(R051–R096)、「测井参数表」「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.5(2026-06-12 全量实测编目)** 与实现计划 `plans/2026-06-11-dataset-detail-other-dd-types.md`。客户端按真实数据类型走 `ChartStrategyRegistry` 分派。
|
||||||
> 现实约束:当前可访问租户只有 ERT/TEM/GPR 三类,且 GPR 对象无数据、无测井数据。其它 dd 类型详情页**无活样本可参照**,须待拿到数据样本后再精确复刻。
|
|
||||||
|
### 2.5 数据类型全景:设计 taxonomy(Excel)vs 实测运行 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=项目/GS,type=2=TM,parentId 链表层级;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 复刻。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
||||||
|
# 崩溃 minidump:DbgHelp(Windows 自带)提供 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
|
||||||
|
|
|
||||||
|
|
@ -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) { // 打不开(权限等)则仅写 stderr(writeLine 已容错)
|
||||||
|
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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
104
src/app/main.cpp
104
src/app/main.cpp
|
|
@ -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 承载工作台。
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 + 4(ddCode,双击详情选策略用)
|
constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4(ddCode,双击详情选策略用)
|
||||||
|
constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5(dsName,详情页签标题用)
|
||||||
|
|
||||||
// 数据页签:每条 = 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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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/max(cauto)
|
||||||
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
#include "panels/chart/IDatasetChartStrategy.hpp"
|
#include "IDatasetChartStrategy.hpp" // geopro::controller(geopro_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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
// 散点颜色归一化对齐原版 Plotly(cmin/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_) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ namespace geopro::app {
|
||||||
|
|
||||||
class ColorBarWidget;
|
class ColorBarWidget;
|
||||||
class ScatterPlotItem;
|
class ScatterPlotItem;
|
||||||
|
class ScatterHoverTip;
|
||||||
|
|
||||||
// 原数据图表视图:工具条 + QwtPlot(x 轴顶部、Panner/Magnifier)+ 独立色阶条。
|
// 原数据图表视图:工具条 + QwtPlot(x 轴顶部、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 提示(QObject,this 持有)
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace geopro::app
|
} // namespace geopro::app
|
||||||
|
|
|
||||||
|
|
@ -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 仅在按键按下时才收到 MouseMove;hover(无按键)需开启鼠标跟踪。
|
||||||
|
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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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-replace:start/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
|
||||||
|
|
|
||||||
|
|
@ -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_; // selectObject:data 行
|
||||||
|
QPointer<data::NavRequest> selFileReq_; // selectObject:file 行
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Repository 抽象(**异步契约**:QFuture/回调 + 取消 + 分页),DTO 与领域模型分离。
|
Repository 抽象(**异步契约**:QFuture/回调 + 取消 + 分页),DTO 与领域模型分离。
|
||||||
|
|
||||||
子目录(设计 §3、§6):
|
子目录(设计 §3、§6):
|
||||||
- `repo/` — IProjectRepository, IDatasetRepository
|
- `repo/` — IAsyncProjectRepository, IDatasetRepository
|
||||||
- `local/` — LocalSampleRepository(M1,QtConcurrent 跑解析)+ 各格式解析器
|
- `local/` — LocalSampleRepository(M1,QtConcurrent 跑解析)+ 各格式解析器
|
||||||
- `api/` — ApiRepository(M1 骨架,签名对齐 pop-api)
|
- `api/` — ApiRepository(M1 骨架,签名对齐 pop-api)
|
||||||
- `dto/` — 后端 JSON DTO + → model 映射
|
- `dto/` — 后端 JSON DTO + → model 映射
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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_;
|
||||||
|
|
|
||||||
|
|
@ -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。
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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); // 持有非拥有引用(QPointer);call 完成(finished)或 abort 后自行 deleteLater 自管理生命周期,本类不得 delete 它
|
||||||
|
void abort() override;
|
||||||
|
private:
|
||||||
|
QPointer<geopro::net::IApiCall> call_;
|
||||||
|
Parser parse_;
|
||||||
|
Predicate isFailure_;
|
||||||
|
bool aborted_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::data
|
||||||
|
|
@ -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 是 0–1 浮点
|
||||||
|
// (实测 "rgba(0, 0, 170, 1)")。须用 Unit 标度(a*255),否则 a=1 被当字节 → alpha≈1 近透明。
|
||||||
|
cs.addStop(val, parseColor(rgba, AlphaScale::Unit));
|
||||||
}
|
}
|
||||||
return cs;
|
return cs;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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=TM;classifyType 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
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(); // 先快照 URL(reply 即将 deleteLater)
|
||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
|
// 错误判定口径同 spec §7:code != 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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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-fast:failed(index,resp) + abort 当前在飞 + deleteLater。
|
||||||
|
// 全部成功 → succeeded(按序响应)。安全不变量见 spec §5.0(aborted_ 闸门 + 一律 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(持有非拥有引用 QPointer;IApiCall 完成或 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
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.0:aborted_ 入口守卫 + 一律 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
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
||||||
|
// step2:RSA 加密密码(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) login2:checkCode 传空串。
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 渲染验证
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/max(cauto)**归一化,而色阶形状(断点位置)仍按
|
||||||
|
// 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});
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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×1,filesLoaded×1,objectDetailLoaded×1,datasetsLoaded×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());
|
||||||
|
}
|
||||||
|
|
@ -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 0–1 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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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-2:step 工厂抛异常(模拟 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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue