feat(dataset-detail+app): 数据集树/按根分页 + 暗色主题保真 + 详情图保真 + 桌面日志崩溃捕获

本分支累积的数据集详情与桌面端健壮性工作(多轮迭代,已逐项实测/用户验收),一次性提交。

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

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

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

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

测试 116→122 全绿(+ParseDsRowsParentIdForTree / DataPaginatesByRootNodeNotFlatCount / 散点/colormap 回归)。
This commit is contained in:
gaozheng 2026-06-12 19:00:32 +08:00
parent 66869a1e2e
commit 10072eb4b3
39 changed files with 1126 additions and 139 deletions

View File

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

View File

@ -1,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.10.5 的代码(散点 hover/日志崩溃捕获/colormap/数据集树/按根分页/暗色主题)**全部未提交**(用户多次选「保持现状」不提交不合并)。`git status` 一大堆 M/??**别假设干净树**;接手前先 `git status` 看清。新增未跟踪文件:`src/app/Logging.*`、`src/app/panels/chart/{ScatterHoverTip,ChartTheme}.*`、`tests/app/test_scatter_hover.cpp`,以及若干 `*.jpeg/*.yml` 临时抓图/抓取产物(非源码,可忽略/清理)。
--- ---
@ -13,6 +14,80 @@ geoproQt6/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 是 01 浮点**),共 18 段。`DatasetChartDto.cpp` 用 `AlphaScale::Bit255` 解析 → rgba 的 `a=1` 被当字节 → alpha≈1/255 近透明12 个 rgba 段全成白缝6 个 hex 段因 hex 分支强制 255 才可见)。**修复:`Bit255`→`Unit`**(一行)。散点连续插值 `ColorMapService` 的归一化位置 `val/maxVal` 已证**精确等于原版 Plotly colorscale 位置**,无需改。
- **散点 hover**:新增 `ScatterHoverTip`canvas 事件过滤器,与 LivePanner 共存),最近点命中显示 `X/Y/值`(各 3 位小数,对齐原版 Plotly hovertemplate `<b>X:</b> %{x:.3f}…`)。
- 文件:改 `src/data/dto/DatasetChartDto.cpp`;新增 `src/app/panels/chart/ScatterHoverTip.{hpp,cpp}` + 接线 `RawDataChartView.{hpp,cpp}`;测试 `tests/data/test_dataset_chart_dto.cpp`(改用真实混合格式 + 断言 alpha=255 回归)、新增 `tests/app/test_scatter_hover.cpp`。**测试 116→118 全绿**cpp-reviewer APPROVE。
- ⚠️ **像素级视觉 1:1 待用户在运行的 app 内登录核对**(原生 Qt 窗口无法用 Playwright 驱动;以下各层已机器验证:抓包/源码/RED-GREEN/插值位置匹配)。
- 观察(未改,待定):图例 `ColorBarWidget``stops-1=17` 段,丢弃最后一段最深色;原版疑为 18 段。非本次报障,留待用户决定是否补齐。
### 0.2 2026-06-12 第二轮修复(散点 cauto 归一化 + hover mouseTracking + 页签名)
第一轮 alpha 修复后颜色不再透明,但散点仍与原版不一致(挤在暖色中段、无蓝无紫)。复查 Plotly `_fullData`:散点 **`cmin/cmax` 未设 → `cauto` 自动取 vlist 实际 min/max本例 45.93197.79**,把整段色阶铺满数据范围;而客户端 `ColorMapService` 错用 colorBar 全程 01323 归一化数据 → 压进色阶 0.030.15 段。
- **修复 A散点 cauto**`ColorMapService.setDataRange(dataMin,dataMax)` 解耦「色阶形状位置(按断点值)」与「数据值归一化(按数据 min/max`RawDataChartView::setData` 按 `d.scatter.v` 有限值 min/max 调用。**数值验证 1:1**:手算修复后客户端对样本值的 RGB 与原版 Plotly 实测逐字节相同(如中位 92.01→[168,0,35]、62.34→[255,119,0])。网格仍用绝对阈值(`colorAtDiscrete`),不变。
- **修复 Bhover 仍无效)**:根因 `QwtPlotCanvas` 默认不开 mouseTracking → 无按键时收不到 MouseMove。`ScatterHoverTip` 构造里加 `canvas->setMouseTracking(true)`
- **修复 C页签用数据名**`ChartData` 加 `dsName``openDataset` 加可选第 3 参 `dsName`(默认空,向后兼容测试);`kDsNameRole` 存 dsName`main.cpp` 双击传入,`DatasetDetailPanel` `addTab``dsName`(空回退 dsId+ tooltip。
- 文件:`ColorMapService.{hpp,cpp}`、`RawDataChartView.cpp`、`ScatterHoverTip.cpp`、`DatasetDetailController.{hpp,cpp}`、`DatasetListPanel.{hpp,cpp}`、`DatasetDetailPanel.cpp`、`main.cpp`;测试 `test_colormap_service.cpp`(+DataRangeDecouples)。**119/119 全绿**。
- ⚠️ hover/页签名/视觉仍待用户运行核对GUI 无法自动驱动)。
### 0.3 2026-06-12 桌面端日志 + 崩溃捕获(新基础设施)
用户反馈:双击 ds 后状态栏报「内部系统错误」然后客户端崩溃;后端错误可接受,但客户端不该崩。复测发现 scatter/color 接口当时其实 200/干净数据 → 无日志根本无从定位。遂加生产级日志 + 崩溃捕获(已实测打通)。
- **`src/app/Logging.{hpp,cpp}`**`initLogging()`main.cpp 在 setApplicationName 后调用)。
- `qInstallMessageHandler` 接管全 App `qDebug/qInfo/qWarning/qCritical/qFatal` → 写带时间戳/级别的滚动日志:`%LOCALAPPDATA%/Geomative/Geopro3/logs/geopro_YYYYMMDD.log`按天UTF-8+BOM启动清理 14 天前旧文件)。
- **崩溃捕获**`SetUnhandledExceptionFilter`SEH含未捕获 C++ 异常 0xE06D7363+ `std::set_terminate`;崩溃时 `MiniDumpWriteDump``crash_*.dmp`(链 `Dbghelp`+ 记一行 `[FATAL] 崩溃 code=… addr=… dump=…`。`SetErrorMode` 抑制系统弹窗。**已用临时 env 自检实测**:空指针崩溃 → 生成 6MB dmp + 日志记录 code=0xc0000005自检块已删
- dmp 可 VS/WinDbg 加载看完整调用栈。
- **埋点**`ApiCall::onFinished` 记录每个 API 响应URL/http/code/err成功 INFO/失败 WARN`DatasetDetailController` openDataset + 各 loadFailedApp 启动版本/路径。net 层 `qWarning` 自动收录。
- **顺带堵崩溃根因**(评审 H-2 + 防脏数据):`ColorMapService::colorAtContinuous` 对 NaN/Inf 的 t 回退首断点色(原会 `upper_bound` 返回 end() 后解引用越界崩溃);`RawDataChartView` vlist min/max 用 `isfinite` 跳过 NaN/±Inf。+ 单测 `ColorAtContinuousNaNSafe`
- **测试 119→120 全绿**
### 0.4 2026-06-12 崩溃定位 + 顶层异常护栏
用户提供 dump + log。日志序列`openDataset(ERT2-WS_result.dat)` → **`dynamicForm` 后端返回 `code=500 sys.internalServerError`**(即"内部系统错误")→ scatter 200 → color 200 → `Qt has caught an exception thrown from an event handler` → 崩溃。
**dump 分析winget 装 Microsoft.WinDbg`!analyze -v`**:异常 `e06d7363`C++ EH`VCRUNTIME140!CxxThrowException ← msvcp140!std::_Xlength_error ← geopro_desktop+0x9ad0 ← +0xa94ee ← +0x24b71 ← +0x235c0`。即 **`std::length_error`**STL 容器/字符串用非法 size resize/reserve/构造)。栈帧都在 geopro_desktop含静态链接的 Qwt但**该 dump 是旧构建、当前 PDB 已不匹配 → 符号化不出函数名**。静态排查 dynamicForm 解析 / DynamicFormView / selectDataset 失败路径 / 散点解析(已 try/catch) / 渲染 / ColorMapService 均未见明显抛点 → 确切行**待运行期护栏日志点名**。
**修复(主,根治"不该崩"**`main.cpp` 加 `GuardedApplication : QApplication`override `notify()` try/catch任何 slot/事件处理器抛出的异常被拦截 + `qCritical` 记录 `[guard] 拦截... what | type | receiver=<对象类名> | event=<事件类型>`,然后吞掉(不终止)。后端故障等异常**不再使整个客户端退出**。
**附带**:修 `appendCrashLine` 文件共享 bug崩溃行原写不进日志——g_logFile 独占,第二句柄 open 失败;改为复用已打开句柄);`colorAtContinuous` NaN/Inf 守卫。
**护栏实测**:用户复现,护栏拦到 → 日志 `[guard] 拦截未捕获异常: vector too long | type=std::length_error | receiver=QNetworkReplyHttpImpl | event=43(MetaCall)`。即异常在**网络响应 finished 同步链**里(最后一个 chart 请求 color 返回 → `chartReady→openOrUpdate→渲染`)抛出 `std::length_error`"vector too long" = std::vector 用了非法尺寸,典型负数转 size_t。进程**未崩**(护栏吞掉),但图表没渲染出来。
**诊断基础设施(关键补强)**
1. **Release 构建之前不生成 PDB** → dump/崩溃栈对自家代码全部符号化失败(只有系统 DLL 导出表能解析)。已在根 `CMakeLists.txt` MSVC 块加 `/Zi`(编译) + `/DEBUG /OPT:REF /OPT:ICF`(链接)Release 保持优化的同时产出匹配 PDB。**生产桌面端排障必需。**
2. **VEH 抛点堆栈符号化**`Logging.cpp``AddVectoredExceptionHandler` 在 C++ 异常0xE06D7363**抛出瞬间**栈未展开、PDB 匹配)用 DbgHelp`SymInitialize`+`SymLoadModuleExW`+`SymFromAddrW`+`SymGetLineFromAddrW64`**宽字符**——UNICODE 下必须)打印 `模块+RVA 函数名+偏移 (文件:行)`。自测验证:`reserve(-1)` → 正确打印 `geopro::app::initLogging (Logging.cpp:245)`。即使异常被护栏吞掉也留下抛点栈。
**下一步定位 length_error 确切行**:用当前构建复现一次 → 日志 `[THROW]` 段直接给出 `geopro::app::<类>::<方法> (文件:行)`。**测试 120/120 全绿。**
### 0.5 2026-06-12 数据集列表树化 + 按根分页 + 暗色主题保真(本次会话)
用户三组报障,均已修复、构建链接通过、应用真实流程跑通无崩溃、**测试 120→122 全绿**
**(1) 数据集列表应是树(原为平铺)**
- **实地确认(铁律 Playwright**:原版「数据管理」选 TM 后的数据列表是 **el-table tree-data**(有展开箭头)。脚本直连 API 验证 TM E3项目 1458977804960256 垃圾掩埋場10 个 DS → **4 个根**(默认折叠)= 3×源「ERT原始数据」+ 1×「ERT电极坐标」派生「反演/接地电阻」按 `parentId`(==`sourceShowParentId`)挂源「原始数据」下。根 = parentId 为空或指向不在本批的「源文件节点」。
- **改**`DsRow` 加 `parentId``NavDto::parseDsRows` 解析 `sourceShowParentId`(回退 `parentId`)`DatasetListPanel::populateDatasetList` 由 `QListWidget` 改建 **`QTreeWidget`**(两遍法按 parentId 嵌套,卡片委托 `applyDatasetCardDelegate` 泛化到 `QAbstractItemView``main.cpp` `datasetList``QTreeWidget``setExpandsOnDoubleClick(false)`:双击=开详情、展开靠箭头),点击/双击/反向高亮/加载更多 4 处改 `QTreeWidget` API。文件页签仍平铺 `QListWidget`。默认折叠(对齐原版)。
- 测试:`test_nav_dto.cpp::ParseDsRowsParentIdForTree`。
**(2) 分页应按「第一层节点(根)」算(原按扁平 DS**
- **根因**:后端 `dsObject/data/page` 按**扁平 DS** 分页脚本验证total=10、pageSize=5 返回 5 条扁平行)——子节点的父常落在下一页 → 按页建树出孤儿根、首层数错乱。
- **改**`IAsyncProjectRepository::loadRowsAsync` 加 `int pageSize=5` 参数(接口/`ApiProjectRepository`/测试 stub 同步);`WorkbenchNavController::selectObject` 数据页改用大 pageSize(`kFetchAllPageSize=1000`)**一次取全**整棵,缓存 `allDataRows_`;新私有 `emitNextDataRootPage(bool append)` **客户端按根切页**(每页 `kDataRootPageSize=5` 个根 + 各自整棵子树DFS 收集后按原序输出,`total`=根总数);`loadMoreData` 改同步切下一页(无请求);`dataRootsShown_` 游标,`resetSelectionState` 清空。删因此空置的 `moreDataReq_`。`main.cpp` `addTreeLoadMore` 计数改按顶层根。若 `listCount<total`pageSize 不足取全)`qWarning` 告警。
- 测试:`test_workbench_nav_controller.cpp::DataPaginatesByRootNodeNotFlatCount`6 根→首页 5 根+子=7 行/total=6续页第 6 根;用 `qRegisterMetaType<std::vector<DsRow>>` 读 spy 行参)。
**(3) 暗色主题图表样式未跟随**
- 原详情图 `QwtPlot`、`ColorBarWidget`、`LoadingOverlay` 全硬编码白底/浅色 → 暗色主题下刺眼白底/白蒙板。**原版 web 无暗色,故暗色为客户端自定****浅色分支保持原硬编码值=与原版 1:1 不动,仅暗色改 token**。
- 新增 **`src/app/panels/chart/ChartTheme.{hpp,cpp}`** `applyChartPlotTheme(QwtPlot*)`:按 `isDarkTheme()` 设画布底色(`bg/panel`)/轴字(`text/secondary`)/网格(`border/default`)/零线(`border/strong`),遍历 itemList 重着色 grid/marker。`Raw/GridDataChartView` 删硬编码白块、ctor 末尾调用 + 连 `ThemeManager::changed` 热切换。
- `ColorBarWidget::paintEvent` 底色/边框/刻度字按 `isDarkTheme()`(暗色 `bg/panel`/`border/strong`/`text/secondary`;色带格=数据色不变)+ ctor 连 changed→update。
- `LoadingOverlay` 遮罩纱色由 `rgba(255,255,255,160)` 改按主题(暗色 `bg/app` 深纱)+ label 文字 `text/primary`,连 changed 热切换。
- token 表见 `src/app/Theme.cpp``bg/panel` 白/`#161A20`、`text/secondary`、`border/*`)。
**新文件**`src/app/panels/chart/ChartTheme.{hpp,cpp}`(已加 `src/app/CMakeLists.txt`)。
**改动文件**`RepoTypes.hpp`、`NavDto.cpp`、`IAsyncProjectRepository.hpp`、`ApiProjectRepository.{hpp,cpp}`、`WorkbenchNavController.{hpp,cpp}`、`DatasetListPanel.{hpp,cpp}`、`main.cpp`、`RawDataChartView.cpp`、`GridDataChartView.cpp`、`ColorBarWidget.cpp`、`LoadingOverlay.cpp`、`src/app/CMakeLists.txt`、`tests/{data/test_nav_dto,controller/test_workbench_nav_controller}.cpp`。
**记忆新增**`dataset-list-is-tree`(树结构 + 扁平分页坑 + 按根分页解法 + 暗色图表方案)。
- ⚠️ **待用户运行核对GUI 无法自动驱动)**:① 暗色下网格详情的「加载中…」蒙板是深纱、色阶条深底浅字;② 数据列表按 5 根分页(根多的 TM 才出「加载更多」)+ 树嵌套正确。机器侧已验证:脚本抓原版结构 + 构建 + 122 测试 + 真实登录流程日志跑通(项目 1458977804960256→E3→data/page→getDetail→dynamicForm无崩溃
--- ---
## 1. 背景 ## 1. 背景
@ -105,7 +180,7 @@ geoproQt6/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.10.5 代码全未提交,见顶部 ⚠️)。用户多次选「保持现状」未提交未合并。**先决**:本次会话两组改动(数据集树/按根分页、暗色主题)**待用户在运行的 app 内目视核对**(见 0.5 末尾清单)——核对通过再谈提交。下一步可问用户:①目视核对本次改动 ②提交这批未提交工作(建议按主题分多个 commit详情图保真 / 日志崩溃捕获 / 数据集树+分页 / 暗色主题)③合并回 main(本地) ④推送建 PR(origin=gitea `https://gitea.geomative.cn/gaozheng/geopro.git`) ⑤保持现状。用 `superpowers:finishing-a-development-branch`
### B. 其余 dd 类型详情图(计划已写,多数 BLOCKED ### B. 其余 dd 类型详情图(计划已写,多数 BLOCKED
计划:`plans/2026-06-11-dataset-detail-other-dd-types.md`。 计划:`plans/2026-06-11-dataset-detail-other-dd-types.md`。

View File

@ -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**脚本需重试48 次)。
**铁律纠正(重要):详情渲染由数据集的"真实数据类型"决定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`)。
--- ---

View File

@ -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 为 01 浮点**;原 `DatasetChartDto``AlphaScale::Bit255` 解析致 12/18 段近透明(散点/网格/图例全发白)。修为 `AlphaScale::Unit`(一行根因修复,通用生效)。散点 `ColorMapService` 连续插值的归一化位置已证 1:1 等于原版 Plotly colorscale。新增 `ScatterHoverTip` 复刻原版 hovertemplate。详见 `HANDOFF-dataset-detail-chart.md §0.1`
**未完成:** **未完成:**
- **其余 dd 类型的详情图渲染**§2.4`dd_ert_measurement_data`、`dd_ert_measurement_gr_data`、`dd_grid`、`dd_trajectory_data`、测井(深度/时序折线、GPR`dd/gpr/channel/image`、TEM 等。控制器目前对非 `dd_inversion_data` 直接「暂不支持该类型预览」。**现实约束:当前租户仅 ERT/TEM/GPR 三类GPR 对象无数据、无测井数据 → 多数类型无活样本,须先取样本。** 实现计划见 `plans/2026-06-11-dataset-detail-other-dd-types.md`(如已生成)。 - **其余 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.52026-06-12 全量实测编目)** 与实现计划 `plans/2026-06-11-dataset-detail-other-dd-types.md`。客户端按真实数据类型走 `ChartStrategyRegistry` 分派。
> 现实约束:当前可访问租户只有 ERT/TEM/GPR 三类,且 GPR 对象无数据、无测井数据。其它 dd 类型详情页**无活样本可参照**,须待拿到数据样本后再精确复刻。
### 2.5 数据类型全景:设计 taxonomyExcelvs 实测运行 ddCode + 渲染映射2026-06-12
> 用脚本直连 API 全量遍历两个账号(威立雅 + 赛盈地空"数据多"账号20 项目/108 TM/752 DS+ 逐个打开详情看截图确认渲染。**关键纠正:详情渲染由数据集"真实数据类型"决定URL 详情链接的 `ddCode` 经常标错,客户端分派须用 ds 元数据的真实类型。**
#### 2.5.1 实测 10 种 ddCode → 7 种渲染视图(均有活样本、看截图确认)
| 渲染视图 | ddCode | 数据类型 | 客户端 |
|---|---|---|---|
| **① 原数据(Plotly散点,cauto) + 网格数据(等值面+等值线)** | `dd_inversion_data` | ERT/TEM 反演剖面、视电阻率数据 | ✅ 已做 |
| **② 柱状图(Y=电阻欧姆,X=电极点) + 列表** | `dd_ert_measurement_gr_data` | ERT接地电阻 | ❌ |
| **③ 散点伪剖面(斜距/伪深度,视电阻率着色,反演运算工具) + 数据列表** | `dd_ert_measurement_data` | ERT原始数据 | ❌ |
| **④ 轨迹:地图 + 列表 + 高程 3页签** | `dd_trajectory_data` | ERT电极坐标、TEM坐标 | ❌ |
| **⑤ 列表(序号/x/y)** | `dd_grid` | 白化数据 | ❌ |
| **⑥ 雷达剖面灰度图像(B-scan) + 单道波形(A-scan) + 对比度/灰度色阶/频谱** | `dd_gpr_channel_detail`、`dd_gpr_channel_image` | 雷达单通道剖面/图片列表 | ❌ |
| **⑦ 地图轨迹(真实GIS底图 + GPS路径 + 起点/终点)**RTK 另带坐标点设置/解状态过滤面板) | `dd_radar_channel_trajectory`、`dd_radar_rtk_trajectory` | 雷达单通道轨迹/RTK轨迹 | ❌ |
| (无独立可视详情,疑数据中间态) | `dd_radar_preprocess_data` | 雷达预处理数据 | — |
**共 7 种渲染视图,客户端已做 1 种(①);待做 6 种:② 柱状图、③ 散点伪剖面、④ 轨迹、⑤ 列表、⑥ 雷达剖面图像、⑦ 地图轨迹。**
#### 2.5.2 与 `Geopro3.0 菜单.xlsx`「DD类型」设计 taxonomy 的对应
Excel「DD类型」页签是**设计期格式蓝图**,用的是另一套命名(`dd_Section`/`dd_Track`/`dd_ERTRawData`/`dd_GPRSection`/`dd_Images`/`dd_TimeVarious`…)。与实际运行 ddCode **命名对不上、语义对得上**
| Excel 设计格式 | 描述(Excel) | 对应实测运行 ddCode | 样本 |
|---|---|---|---|
| `dd_Section`"最重要":水平/垂直剖面) | 反演剖面 | `dd_inversion_data`(①)、`dd_gpr_channel_detail`(⑥) | ✅ |
| `dd_ERTRawData` | ERT原始数据 | `dd_ert_measurement_data`(③)、`dd_ert_measurement_gr_data`(②) | ✅ |
| `dd_GPRSection`(设计注:讨论是否并入 dd_Section | 雷达剖面 | `dd_gpr_channel_detail`/`image`(⑥) | ✅ |
| `dd_Track`(坐标+轨迹合并) | 电极坐标/采集轨迹 | `dd_trajectory_data`(④)、`dd_radar_*_trajectory`(⑦) | ✅ |
| `dd_Images` | 图片类 | `dd_gpr_channel_image`(⑥) | ✅ |
| `dd_Files` | 文件打包(如三维雷达原始包) | `dd_radar_preprocess_data`(疑) | ✅部分 |
| `dd_TimeVarious` | 时间连续变量 | =设计中的 TEM时序/timeSensor 折线 | ❌ 无样本 |
| `dd_Stratigraphy` | 地层编录/**测井** | =设计中的测井折线/深度 | ❌ 无样本 |
| `dd_Segy` | 地震 SEGY(只展示) | — | ❌ 无地震数据 |
| `dd_Sampling` | 采样/化学(XRF/VOCs) | — | ❌ |
| `dd_LinearVarious` / `dd_Structual3D` / `dd_Property3D` / `dd_Video` | 线性变量/三维结构/三维属性/视频 | — | ❌ |
| `dd_KML`/`dd_GeoJson`/`dd_shp`/`dd_dem`/`dd_bim`/`dd_czml`… | 背景资料**图层**(导入/预览/坐标对齐) | — | 非"数据详情图",是地图图层 |
**结论:** Excel 是"应有哪些数据格式"的设计蓝图;实测 10 种是"现在真有数据、详情页真能出图"的子集(电法 ERT + 地质雷达 GPR 两大类的落地。两者语义吻合Excel 里 时序(dd_TimeVarious)/测井(dd_Stratigraphy)/地震(dd_Segy)/采样/三维模型/视频 等当前**无活样本BLOCKED**(与 plan §1.2 一致KML/shp/dem/bim 等是地图图层,不属本 spec 的详情图范畴。
#### 2.5.3 枚举 DS 的 API实测可用踩坑记录
`POST my/profile/project/page`{pageNo,pageSize} → `GET projectStruct/queryProjectStruct/{pid}`扁平节点type=1=项目/GStype=2=TMparentId 链表层级TM 可挂项目或 GS 下)→ `POST dsObject/data/page` body **`{projectId, structParentId:<tmId>, structParentConfType:2, classifyTypeList:[3], pageNo, pageSize}`**(字段是 `structParentId` 不是 `tmObjectId`;必须带 `classifyTypeList:[3]` 否则后端 `code:500``pageNo` 不是 `pageNum`;时序类另走 `dsObject/timeSensor/data/page`;后端间歇 500 需重试。DS 字段:`ddCode`+`dsTypeCode`(英文)+`name`(中文类型名)+`dsName`(文件)。
> 现实约束:当前可访问租户实测有样本的方法类为 ERT/TEM/GPR测井/地震/采样/三维/时序无活样本。无样本类详情页**无可参照**,须待数据样本到位后再精确 1:1 复刻。
--- ---

View File

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

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

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

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

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

View File

@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,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);
}); });
} }

View File

@ -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:

View File

@ -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() {

View File

@ -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_; // selectObjectdata 行 QPointer<data::NavRequest> selDataReq_; // selectObjectdata 行
QPointer<data::NavRequest> selFileReq_; // selectObjectfile 行 QPointer<data::NavRequest> selFileReq_; // selectObjectfile 行
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

View File

@ -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));

View File

@ -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;

View File

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

View File

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

View File

@ -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>

View File

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

View File

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

View File

@ -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)

View File

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

View File

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

View File

@ -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;

View File

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

View File

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