feat/dataset-detail-chart #5
|
|
@ -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,7 +1,8 @@
|
||||||
# 交接文档:数据集详情图表 + 全 App 网络层异步化
|
# 交接文档:数据集详情图表 + 全 App 网络层异步化
|
||||||
|
|
||||||
> 给下一个会话:读完本文件即可无缝接手。最后更新 2026-06-12。
|
> 给下一个会话:读完本文件即可无缝接手。最后更新 2026-06-12。
|
||||||
> 分支 **`feat/dataset-detail-chart`**,领先 main **68 commits**,工作区干净,**测试 116/116 全绿**。
|
> 分支 **`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` 临时抓图/抓取产物(非源码,可忽略/清理)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -13,6 +14,80 @@ geopro(Qt6/C++ 离线桌面客户端,1:1 复刻赛盈地空 web)已完成
|
||||||
|
|
||||||
均**未合并入 main**,分支挂起等收尾。
|
均**未合并入 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. 背景
|
||||||
|
|
@ -105,7 +180,7 @@ geopro(Qt6/C++ 离线桌面客户端,1:1 复刻赛盈地空 web)已完成
|
||||||
## 6. 尚未完成 / 下一步(按优先级)
|
## 6. 尚未完成 / 下一步(按优先级)
|
||||||
|
|
||||||
### A. 收尾本分支(最先)
|
### A. 收尾本分支(最先)
|
||||||
分支领先 main 68 commits、116/116 绿、工作区干净。用户多次选「保持现状」未合并。下一步可问用户:①合并回 main(本地) ②推送建 PR(origin=gitea `https://gitea.geomative.cn/gaozheng/geopro.git`) ③保持现状。用 `superpowers:finishing-a-development-branch`。
|
分支领先 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)
|
### B. 其余 dd 类型详情图(计划已写,多数 BLOCKED)
|
||||||
计划:`plans/2026-06-11-dataset-detail-other-dd-types.md`。
|
计划:`plans/2026-06-11-dataset-detail-other-dd-types.md`。
|
||||||
|
|
|
||||||
|
|
@ -45,23 +45,56 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 各 dd 类型:样本可得性 + 渲染归类 + 可推进/BLOCKED 矩阵
|
## 1. 各 dd 类型:实测编目(2026-06-12 全量遍历,已坐实)
|
||||||
|
|
||||||
> 现实约束(spec §2.4):当前可访问租户**仅 ERT / TEM / GPR 三类**;**GPR 对象无数据、无测井数据样本**。多数 dd 类型无活样本可参照。
|
> **本节已由 2026-06-12 全量实测替换原"推断矩阵"。** 用脚本直连 API 遍历**两个账号**(威立雅租户 + 赛盈地空"数据多"账号 20 项目 / 108 TM / 752 DS),逐个 ds 核对 `ddCode` + `dsTypeCode` + 真实渲染。Phase 0 探查目标基本达成。
|
||||||
> ddCode 列为推断(源码无常量),**Phase 0 须用 Playwright 抓数据集列表项的真实 `ddCode` 字段核对后回填本表**,禁止写死未经核对的 ddCode。
|
|
||||||
|
|
||||||
| 数据类型(菜单/spec) | 推断 ddCode(待 Phase 0 核对) | 取数接口(已核对 OpenAPI 路径/参数) | 渲染归类 / 复用 | 样本可得性 | 状态 |
|
### 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 | 数据类型(中) | 渲染视图(实测) | 客户端 | 样本 |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| ERT 反演(已完成基线) | `dd_inversion_data` | `getErtRawDataScatterGraph/{id}`、`inversion/rows/{id}`、色阶 type1/2、`queryException/{id}` | 散点 + 等值面(已实现) | 有 | ✅ 已完成 |
|
| `dd_inversion_data` | ERT inversion data | 电阻率(反演)数据;TEM反演剖面;视电阻率数据 | **① 原数据(Plotly散点,cauto) + 网格数据(等值面+等值线)** 2 页签 | ✅ 已做 | 多 |
|
||||||
| ERT 测量原始 | 待核对 | `GET dd/ert/measurement/scatter/graph?dsObjectId&vFieldCode`、`GET dd/ert/measurement/rows?dsObjectId` | 散点类 → 复用 `ScatterPlotItem`/`RawDataChartView`;rows 形态待 Phase 0 定 | ERT 租户在,**须 Phase 0 找到有数据对象** | 候选可推进(待 Phase 0 确认有样本) |
|
| `dd_ert_measurement_data` | ERT raw data | ERT原始数据 | **③ 散点伪剖面**(x=斜距/y=伪深度, 视电阻率着色, A/B/M/N 悬浮, 含反演运算/生成视电阻率/色阶工具) + 数据列表 | 可做 | 多 |
|
||||||
| ERT 测量/高密度(gr) | 待核对 | `GET dd/ert/measurement/gr/rows?dsObjectId` | 待 Phase 0 看形态(散点/伪剖面) | 同上 | 候选可推进(待 Phase 0 确认) |
|
| `dd_ert_measurement_gr_data` | earth resistance | ERT接地电阻 | **② 柱状图**(Y=电阻/欧姆, X=电极点) + 列表 | 可做 | 多 |
|
||||||
| TEM 时序(设备时序) | 待核对 | `POST dd/ert/timeSensor/rows`(body `DDTimeSensorDataQueryReqVO`)、`GET dd/ert/timeSensor/page` | 时序折线类 → 新建 `LineChartView`(x=时间,y=数值) | **TEM 租户在**,须 Phase 0 找到有数据对象 + 抓响应 | 候选可推进(待 Phase 0 确认有样本) |
|
| `dd_trajectory_data` | Platform trajectory | ERT电极坐标;TEM坐标 | **④ 轨迹**:地图(测线折线) + 列表 + 高程 3 页签 | 可做 | 多 |
|
||||||
| dd_grid(网格) | 待核对 | `GET dd/ert/grid/rows?dsObjectId&pageNo&pageSize` | 等值面类 → 复用 `ContourPlotItem`/`GridDataChartView` | 当前租户**无样本** | 🚫 BLOCKED:待样本 |
|
| `dd_grid` | WhitenedData | 白化数据 | **⑤ 列表**(序号/x/y 地表点) | 可做 | 有 |
|
||||||
| 轨迹 | 待核对 | `GET dd/ert/trajectory/rows?dsObjectId`、`GET dd/ert/trajectory/line?dsObjectId&frontCrsCode` | 折线/路径类 → 待样本定(可能复用散点连线或新 `LineChartView`) | 当前租户**无样本** | 🚫 BLOCKED:待样本 |
|
| `dd_gpr_channel_detail` | RADAR_SINGLE_CHANNEL_PROFILE | 雷达单通道剖面数据 | **⑥ 雷达剖面灰度图像(B-scan radargram)** 左 + **单道波形(A-scan wiggle)** 右;工具:对比度滑块/灰度色阶/显示频谱图 | 待需求确认 | 雷达项目 |
|
||||||
| 测井(well logging) | 待核对(菜单「测井参数表」) | **OpenAPI 未见明确专用 rows 接口** —— Phase 0 须从原版抓真实请求确认接口 | 折线类 → 新建 `LineChartView`(y=深度向下 / x=数值;或 x=时间 y=数值曲线) | **无测井数据样本** | 🚫 BLOCKED:待样本 + 待接口确认 |
|
| `dd_gpr_channel_image` | RADAR_SINGLE_CHANNEL_IMAGE_LIST | 雷达单通道图片列表 | **⑥ 同上**(B-scan 灰度图像 + A-scan 波形,与 channel_detail 同视图) | 待需求确认 | 雷达项目 |
|
||||||
| GPR(雷达剖面图像) | 待核对 | `GET dd/gpr/channel/image/{dsObjectId}`、`GET dd/gpr/channel/trace/spectrogram`、`GET dd/gpr/channel/querySegmentation` | 图像类 → 新建 `ImageChartView`(位图剖面 + 坐标轴) | **GPR 对象无数据** | 🚫 BLOCKED:待样本 |
|
| `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 容器)——疑数据中间态、无独立可视详情 | 待需求确认 | 雷达项目 |
|
||||||
|
|
||||||
**矩阵小结:** 唯一确定有数据的是 `dd_inversion_data`(已完成)。其余全部需 Phase 0 实地探查;ERT 测量类与 TEM 是**最可能**找到样本的(租户在),但「是否有具体对象带数据」必须 Phase 0 验证后才解锁实现任务。
|
> 渲染视图编号①②③④⑤⑥⑦。**全 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`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
|
|
||||||
**架构偏离(重要):** spec 原定渲染器为 **QGraphicsView**,实际落地改用 **QwtPlot(轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**(见返工方案 `plans/2026-06-11-dataset-detail-chart-v2-qwt.md`,权威)。展示结果视觉等价,下文 §5.2/§8 的 QGraphicsView 细节已被 QwtPlot 方案取代,保留作背景参考。
|
**架构偏离(重要):** 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 轴顶部)+ 网格等值面(填充栅格 + 黑色等值线 + 沿线数值标注 + NaN 白边裁剪)+ 色阶图例 + 异常叠加 + 底部异常表/描述 + 多 Tab + 网格数据懒加载 + 页签内滚动/分割条 + 实时平移/滚轮缩放。数据加载已异步化(见 `specs/2026-06-11-apiclient-async-design.md`)。
|
**已完成(仅 `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`(如已生成)。
|
- **其余 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`(如已生成)。
|
||||||
|
|
@ -54,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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -415,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();
|
||||||
|
|
@ -483,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);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 控制器信号 → 详情面板:数据就绪开页 / 聚焦请求 ──
|
// ── 控制器信号 → 详情面板:数据就绪开页 / 聚焦请求 ──
|
||||||
|
|
@ -541,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 左上(工具条下方)并置顶。
|
||||||
|
|
@ -635,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,
|
||||||
|
|
@ -711,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)
|
||||||
|
|
@ -789,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% 等分数缩放下字体/图标按真实比例渲染,更清晰。
|
||||||
|
|
@ -798,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)。
|
||||||
|
|
@ -808,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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,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,4 +1,5 @@
|
||||||
#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 {
|
||||||
|
|
@ -12,8 +13,12 @@ DatasetDetailController::~DatasetDetailController() {
|
||||||
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,
|
||||||
|
const QString& dsName) {
|
||||||
|
qInfo("[detail] openDataset id=%s ddCode=%s name=%s", qUtf8Printable(dsId),
|
||||||
|
qUtf8Printable(ddCode), qUtf8Printable(dsName));
|
||||||
if (!registry_.supports(ddCode.toStdString())) { // 未注册策略 → 优雅降级
|
if (!registry_.supports(ddCode.toStdString())) { // 未注册策略 → 优雅降级
|
||||||
|
qWarning("[detail] 未注册策略 ddCode=%s → 降级提示", qUtf8Printable(ddCode));
|
||||||
emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
|
emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -22,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);
|
||||||
|
|
@ -36,6 +42,7 @@ 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -62,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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,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 会覆盖
|
||||||
|
|
@ -37,7 +37,7 @@ public:
|
||||||
ChartStrategyRegistry& registry, QObject* parent = nullptr);
|
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:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
#include "WorkbenchNavController.hpp"
|
#include "WorkbenchNavController.hpp"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
#include "api/NavLoads.hpp"
|
#include "api/NavLoads.hpp"
|
||||||
#include "api/NavRequest.hpp"
|
#include "api/NavRequest.hpp"
|
||||||
#include "dto/NavDto.hpp"
|
#include "dto/NavDto.hpp"
|
||||||
|
|
@ -7,7 +12,14 @@
|
||||||
|
|
||||||
namespace geopro::controller {
|
namespace geopro::controller {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// 数据页树形:一次取全的大 pageSize(远超单 TM 实际 DS 数;超出会日志告警)+ 每页根节点数。
|
||||||
|
constexpr int kFetchAllPageSize = 1000;
|
||||||
|
constexpr int kDataRootPageSize = 5;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
using data::DsPage;
|
using data::DsPage;
|
||||||
|
using data::DsRow;
|
||||||
using data::DynamicForm;
|
using data::DynamicForm;
|
||||||
using data::ExceptionRow;
|
using data::ExceptionRow;
|
||||||
using data::NavRequest;
|
using data::NavRequest;
|
||||||
|
|
@ -23,7 +35,7 @@ WorkbenchNavController::~WorkbenchNavController() { abortAll(); }
|
||||||
|
|
||||||
bool WorkbenchNavController::anyInflight() const {
|
bool WorkbenchNavController::anyInflight() const {
|
||||||
if (startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ ||
|
if (startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ ||
|
||||||
moreDataReq_ || moreFilesReq_ || datasetReq_)
|
moreFilesReq_ || datasetReq_)
|
||||||
return true;
|
return true;
|
||||||
for (const auto& h : checkedInflight_)
|
for (const auto& h : checkedInflight_)
|
||||||
if (h) return true;
|
if (h) return true;
|
||||||
|
|
@ -44,7 +56,6 @@ void WorkbenchNavController::abortAll() {
|
||||||
if (selDataReq_) selDataReq_->abort();
|
if (selDataReq_) selDataReq_->abort();
|
||||||
if (selFileReq_) selFileReq_->abort();
|
if (selFileReq_) selFileReq_->abort();
|
||||||
if (selDetailReq_) selDetailReq_->abort();
|
if (selDetailReq_) selDetailReq_->abort();
|
||||||
if (moreDataReq_) moreDataReq_->abort();
|
|
||||||
if (moreFilesReq_) moreFilesReq_->abort();
|
if (moreFilesReq_) moreFilesReq_->abort();
|
||||||
if (datasetReq_) datasetReq_->abort();
|
if (datasetReq_) datasetReq_->abort();
|
||||||
for (const auto& h : checkedInflight_)
|
for (const auto& h : checkedInflight_)
|
||||||
|
|
@ -56,6 +67,9 @@ void WorkbenchNavController::resetSelectionState() {
|
||||||
tmExceptionCache_.clear();
|
tmExceptionCache_.clear();
|
||||||
currentParentId_.clear();
|
currentParentId_.clear();
|
||||||
currentParentConfType_ = 0;
|
currentParentConfType_ = 0;
|
||||||
|
allDataRows_.clear();
|
||||||
|
dataRootsShown_ = 0;
|
||||||
|
dataTotal_ = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── start / switchWorkspace 依赖链:listWorkspaces → pageProjects → loadStructure ──
|
// ── start / switchWorkspace 依赖链:listWorkspaces → pageProjects → loadStructure ──
|
||||||
|
|
@ -204,8 +218,11 @@ void WorkbenchNavController::selectObject(const QString& objectId, int confType)
|
||||||
const std::string pid = currentProjectId_;
|
const std::string pid = currentProjectId_;
|
||||||
dataPageNo_ = 1;
|
dataPageNo_ = 1;
|
||||||
filePageNo_ = 1;
|
filePageNo_ = 1;
|
||||||
|
allDataRows_.clear();
|
||||||
|
dataRootsShown_ = 0;
|
||||||
|
|
||||||
NavRequest* dReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 3, dataPageNo_);
|
// 数据页:一次取全(大 pageSize),再按根客户端分页——保证树完整(子节点不会跨服务端分页丢失父)。
|
||||||
|
NavRequest* dReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 3, 1, kFetchAllPageSize);
|
||||||
selDataReq_ = dReq;
|
selDataReq_ = dReq;
|
||||||
NavRequest* fReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 1, filePageNo_);
|
NavRequest* fReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 1, filePageNo_);
|
||||||
selFileReq_ = fReq;
|
selFileReq_ = fReq;
|
||||||
|
|
@ -217,8 +234,12 @@ void WorkbenchNavController::selectObject(const QString& objectId, int confType)
|
||||||
if (dReq != selDataReq_) return;
|
if (dReq != selDataReq_) return;
|
||||||
selDataReq_.clear();
|
selDataReq_.clear();
|
||||||
const auto page = qvariant_cast<DsPage>(v);
|
const auto page = qvariant_cast<DsPage>(v);
|
||||||
dataTotal_ = page.total;
|
if (static_cast<int>(page.rows.size()) < page.total)
|
||||||
emit datasetsLoaded(objectId, page.rows, page.total, false);
|
qWarning() << "[nav] data/page 未取全:listCount=" << page.rows.size()
|
||||||
|
<< " total=" << page.total << " → 树可能不完整(pageSize 不足)";
|
||||||
|
allDataRows_ = page.rows; // 全量缓存;按根分页由 emitNextDataRootPage 切
|
||||||
|
dataRootsShown_ = 0;
|
||||||
|
emitNextDataRootPage(false); // 首页(append=false)
|
||||||
emitBusyIfChanged();
|
emitBusyIfChanged();
|
||||||
});
|
});
|
||||||
QObject::connect(dReq, &NavRequest::failed, this, [this, dReq](const QString& msg) {
|
QObject::connect(dReq, &NavRequest::failed, this, [this, dReq](const QString& msg) {
|
||||||
|
|
@ -255,30 +276,56 @@ void WorkbenchNavController::selectObject(const QString& objectId, int confType)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── loadMoreData / loadMoreFiles:单请求,append=true ──
|
// ── 数据页树形分页:从 allDataRows_ 按根切下一页(同步,无请求)──
|
||||||
|
// 根 = parentId 为空或不在本 TM 全量集合内(其父是源文件节点,不在 data/page 返回里)。
|
||||||
|
// 每页取 kDataRootPageSize 个根 + 各自整棵子树;行序保持后端原序(便于稳定显示)。
|
||||||
|
void WorkbenchNavController::emitNextDataRootPage(bool append) {
|
||||||
|
const QString parent = QString::fromStdString(currentParentId_);
|
||||||
|
// 本 TM 全部行 id 集合(判定谁是根)。
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
const int rootCount = static_cast<int>(rootIdx.size());
|
||||||
|
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() {
|
void WorkbenchNavController::loadMoreData() {
|
||||||
if (currentParentId_.empty()) return;
|
if (currentParentId_.empty()) return;
|
||||||
if (moreDataReq_) moreDataReq_->abort();
|
if (dataRootsShown_ >= dataTotal_) return; // 无更多根
|
||||||
NavRequest* req =
|
emitNextDataRootPage(true);
|
||||||
repo_.loadRowsAsync(currentProjectId_, currentParentId_, currentParentConfType_, 3, ++dataPageNo_);
|
|
||||||
moreDataReq_ = req;
|
|
||||||
const QString parent = QString::fromStdString(currentParentId_);
|
|
||||||
emitBusyIfChanged();
|
|
||||||
QObject::connect(req, &NavRequest::done, this, [this, req, parent](const QVariant& v) {
|
|
||||||
if (req != moreDataReq_) return;
|
|
||||||
moreDataReq_.clear();
|
|
||||||
const auto page = qvariant_cast<DsPage>(v);
|
|
||||||
dataTotal_ = page.total;
|
|
||||||
emit datasetsLoaded(parent, page.rows, page.total, true);
|
|
||||||
emitBusyIfChanged();
|
|
||||||
});
|
|
||||||
QObject::connect(req, &NavRequest::failed, this, [this, req](const QString& msg) {
|
|
||||||
if (req != moreDataReq_) return;
|
|
||||||
moreDataReq_.clear();
|
|
||||||
--dataPageNo_; // 回滚:本请求失败,页号复位,避免下次 loadMoreData 跳页
|
|
||||||
emit loadFailed(QStringLiteral("datasets"), msg);
|
|
||||||
emitBusyIfChanged();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void WorkbenchNavController::loadMoreFiles() {
|
void WorkbenchNavController::loadMoreFiles() {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ private:
|
||||||
void emitBusyIfChanged(); // 据「是否存在任一在飞句柄」去抖发 busyChanged
|
void emitBusyIfChanged(); // 据「是否存在任一在飞句柄」去抖发 busyChanged
|
||||||
bool anyInflight() const; // OR 所有在飞 QPointer / 集合
|
bool anyInflight() const; // OR 所有在飞 QPointer / 集合
|
||||||
void assembleAndEmitExceptionTree(const QStringList& tmObjectIds); // 缓存命中后组装异常树
|
void assembleAndEmitExceptionTree(const QStringList& tmObjectIds); // 缓存命中后组装异常树
|
||||||
|
// 数据页树形分页:从 allDataRows_(一次取全的整棵)按「第一层节点(根)」切下一页,
|
||||||
|
// 每页 kDataRootPageSize 个根 + 各自整棵子树;total=根总数。append=false 首页、true 加载更多。
|
||||||
|
void emitNextDataRootPage(bool append);
|
||||||
|
|
||||||
data::IAsyncProjectRepository& repo_;
|
data::IAsyncProjectRepository& repo_;
|
||||||
bool lastBusy_ = false;
|
bool lastBusy_ = false;
|
||||||
|
|
@ -71,8 +74,7 @@ private:
|
||||||
QPointer<data::NavRequest> selDataReq_; // selectObject:data 行
|
QPointer<data::NavRequest> selDataReq_; // selectObject:data 行
|
||||||
QPointer<data::NavRequest> selFileReq_; // selectObject:file 行
|
QPointer<data::NavRequest> selFileReq_; // selectObject:file 行
|
||||||
QPointer<data::NavRequest> selDetailReq_; // selectObject:对象详情
|
QPointer<data::NavRequest> selDetailReq_; // selectObject:对象详情
|
||||||
QPointer<data::NavRequest> moreDataReq_;
|
QPointer<data::NavRequest> moreFilesReq_; // loadMoreFiles(数据页改客户端按根分页,无在飞句柄)
|
||||||
QPointer<data::NavRequest> moreFilesReq_;
|
|
||||||
QPointer<data::NavRequest> datasetReq_;
|
QPointer<data::NavRequest> datasetReq_;
|
||||||
std::vector<QPointer<data::NavRequest>> checkedInflight_; // setCheckedTms:未命中缓存的并发批
|
std::vector<QPointer<data::NavRequest>> checkedInflight_; // setCheckedTms:未命中缓存的并发批
|
||||||
|
|
||||||
|
|
@ -84,8 +86,10 @@ private:
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ NavRequest* ApiProjectRepository::loadStructureAsync(const std::string& projectI
|
||||||
|
|
||||||
NavRequest* ApiProjectRepository::loadRowsAsync(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{
|
||||||
|
|
@ -87,7 +87,7 @@ NavRequest* ApiProjectRepository::loadRowsAsync(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}};
|
||||||
auto* call = api_.postJsonAsync(path, body);
|
auto* call = api_.postJsonAsync(path, body);
|
||||||
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
return new ApiNavRequest(call, [](const net::ApiResponse& r) {
|
||||||
return QVariant::fromValue(dto::parseDsPage(r.data));
|
return QVariant::fromValue(dto::parseDsPage(r.data));
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ public:
|
||||||
NavRequest* listProjectTypesAsync() override;
|
NavRequest* listProjectTypesAsync() override;
|
||||||
NavRequest* loadStructureAsync(const std::string& projectId) override;
|
NavRequest* loadStructureAsync(const std::string& projectId) override;
|
||||||
NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId,
|
NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId,
|
||||||
int parentConfType, int classifyType, int pageNo) override;
|
int parentConfType, int classifyType, int pageNo,
|
||||||
|
int pageSize = 5) override;
|
||||||
NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override;
|
NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) override;
|
||||||
NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) override;
|
NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) override;
|
||||||
NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override;
|
NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,11 @@ public:
|
||||||
const std::string& typeId, int pageNo, int pageSize) = 0; // ProjectListPage
|
const std::string& typeId, int pageNo, int pageSize) = 0; // ProjectListPage
|
||||||
virtual NavRequest* listProjectTypesAsync() = 0; // std::vector<ProjectType>
|
virtual NavRequest* listProjectTypesAsync() = 0; // std::vector<ProjectType>
|
||||||
virtual NavRequest* loadStructureAsync(const std::string& projectId) = 0; // std::vector<StructNode>
|
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,
|
virtual NavRequest* loadRowsAsync(const std::string& projectId, const std::string& parentId,
|
||||||
int parentConfType, int classifyType, int pageNo) = 0; // DsPage
|
int parentConfType, int classifyType, int pageNo,
|
||||||
|
int pageSize = 5) = 0; // DsPage
|
||||||
virtual NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) = 0; // DynamicForm
|
virtual NavRequest* loadObjectDetailAsync(const std::string& objectId, int confType) = 0; // DynamicForm
|
||||||
virtual NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) = 0; // DynamicForm
|
virtual NavRequest* loadDatasetFormAsync(const std::string& dsObjectId) = 0; // DynamicForm
|
||||||
virtual NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) = 0; // std::vector<ExceptionRow>
|
virtual NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) = 0; // std::vector<ExceptionRow>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,8 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
|
@ -42,7 +42,7 @@ struct StubAsyncRepo : data::IAsyncProjectRepository {
|
||||||
return lastStructure = new StubNavRequest;
|
return lastStructure = new StubNavRequest;
|
||||||
}
|
}
|
||||||
data::NavRequest* loadRowsAsync(const std::string&, const std::string&, int, int classifyType,
|
data::NavRequest* loadRowsAsync(const std::string&, const std::string&, int, int classifyType,
|
||||||
int) override {
|
int, int) override {
|
||||||
auto* r = new StubNavRequest;
|
auto* r = new StubNavRequest;
|
||||||
if (classifyType == 1)
|
if (classifyType == 1)
|
||||||
lastFile = r;
|
lastFile = r;
|
||||||
|
|
@ -208,6 +208,48 @@ TEST(WorkbenchNavController, SelectObjectOneFailureEmitsPartialResults) {
|
||||||
EXPECT_EQ(failSpy.first().at(0).toString(), QStringLiteral("datasets"));
|
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 复位。
|
// 失败路径:start 首级失败 → loadFailed + busy 复位。
|
||||||
TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) {
|
TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) {
|
||||||
StubAsyncRepo repo;
|
StubAsyncRepo repo;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue