From 10072eb4b3cf3d16178c748b4945259ad7ba96c3 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Fri, 12 Jun 2026 19:00:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(dataset-detail+app):=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=9B=86=E6=A0=91/=E6=8C=89=E6=A0=B9=E5=88=86=E9=A1=B5=20+=20?= =?UTF-8?q?=E6=9A=97=E8=89=B2=E4=B8=BB=E9=A2=98=E4=BF=9D=E7=9C=9F=20+=20?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=9B=BE=E4=BF=9D=E7=9C=9F=20+=20=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E6=97=A5=E5=BF=97=E5=B4=A9=E6=BA=83=E6=8D=95=E8=8E=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本分支累积的数据集详情与桌面端健壮性工作(多轮迭代,已逐项实测/用户验收),一次性提交。 数据集列表树化 + 按根分页: - 原版数据列表为 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 回归)。 --- CMakeLists.txt | 7 + .../HANDOFF-dataset-detail-chart.md | 79 +++++- ...026-06-11-dataset-detail-other-dd-types.md | 59 ++++- .../2026-06-11-dataset-detail-view-design.md | 53 +++- src/app/CMakeLists.txt | 10 +- src/app/Logging.cpp | 243 ++++++++++++++++++ src/app/Logging.hpp | 18 ++ src/app/main.cpp | 96 +++++-- src/app/panels/DatasetDetailPanel.cpp | 4 +- src/app/panels/DatasetListPanel.cpp | 78 ++++-- src/app/panels/DatasetListPanel.hpp | 12 +- src/app/panels/LoadingOverlay.cpp | 15 +- src/app/panels/chart/ChartTheme.cpp | 45 ++++ src/app/panels/chart/ChartTheme.hpp | 14 + src/app/panels/chart/ColorBarWidget.cpp | 18 +- src/app/panels/chart/ColorMapService.cpp | 16 +- src/app/panels/chart/ColorMapService.hpp | 13 +- src/app/panels/chart/GridDataChartView.cpp | 19 +- src/app/panels/chart/RawDataChartView.cpp | 43 +++- src/app/panels/chart/RawDataChartView.hpp | 2 + src/app/panels/chart/ScatterHoverTip.cpp | 72 ++++++ src/app/panels/chart/ScatterHoverTip.hpp | 43 ++++ src/controller/DatasetDetailController.cpp | 12 +- src/controller/DatasetDetailController.hpp | 4 +- src/controller/WorkbenchNavController.cpp | 101 ++++++-- src/controller/WorkbenchNavController.hpp | 10 +- src/data/api/ApiProjectRepository.cpp | 4 +- src/data/api/ApiProjectRepository.hpp | 3 +- src/data/dto/DatasetChartDto.cpp | 4 +- src/data/dto/NavDto.cpp | 3 + src/data/repo/IAsyncProjectRepository.hpp | 5 +- src/data/repo/RepoTypes.hpp | 3 + src/net/ApiCall.cpp | 8 + tests/CMakeLists.txt | 2 + tests/app/test_colormap_service.cpp | 38 +++ tests/app/test_scatter_hover.cpp | 17 ++ .../test_workbench_nav_controller.cpp | 44 +++- tests/data/test_dataset_chart_dto.cpp | 30 ++- tests/data/test_nav_dto.cpp | 18 ++ 39 files changed, 1126 insertions(+), 139 deletions(-) create mode 100644 src/app/Logging.cpp create mode 100644 src/app/Logging.hpp create mode 100644 src/app/panels/chart/ChartTheme.cpp create mode 100644 src/app/panels/chart/ChartTheme.hpp create mode 100644 src/app/panels/chart/ScatterHoverTip.cpp create mode 100644 src/app/panels/chart/ScatterHoverTip.hpp create mode 100644 tests/app/test_scatter_hover.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 82853d8..7d766ba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,13 @@ set(CMAKE_AUTOUIC ON) if(MSVC) add_compile_options(/utf-8 /MP /W4 /permissive-) + # 生成 PDB——即使 Release 优化构建也产出调试符号,使 minidump / 运行期崩溃栈可符号化分析 + # (生产桌面端排障必需)。/Zi 编译期调试信息;/DEBUG 链接产 PDB;/OPT:REF,ICF 抵消 /DEBUG + # 默认关掉的优化,保持二进制优化+精简。仅非 Debug 配置启用。 + add_compile_options($<$>:/Zi>) + add_link_options($<$>:/DEBUG> + $<$>:/OPT:REF> + $<$>:/OPT:ICF>) endif() # ===================================================================== diff --git a/docs/superpowers/HANDOFF-dataset-detail-chart.md b/docs/superpowers/HANDOFF-dataset-detail-chart.md index da71414..f5ab629 100644 --- a/docs/superpowers/HANDOFF-dataset-detail-chart.md +++ b/docs/superpowers/HANDOFF-dataset-detail-chart.md @@ -1,7 +1,8 @@ # 交接文档:数据集详情图表 + 全 App 网络层异步化 > 给下一个会话:读完本文件即可无缝接手。最后更新 2026-06-12。 -> 分支 **`feat/dataset-detail-chart`**,领先 main **68 commits**,工作区干净,**测试 116/116 全绿**。 +> 分支 **`feat/dataset-detail-chart`**,领先 main 68 commits。**测试 122/122 全绿**。 +> ⚠️ **工作区脏**:sessions 0.1–0.5 的代码(散点 hover/日志崩溃捕获/colormap/数据集树/按根分页/暗色主题)**全部未提交**(用户多次选「保持现状」不提交不合并)。`git status` 一大堆 M/??,**别假设干净树**;接手前先 `git status` 看清。新增未跟踪文件:`src/app/Logging.*`、`src/app/panels/chart/{ScatterHoverTip,ChartTheme}.*`、`tests/app/test_scatter_hover.cpp`,以及若干 `*.jpeg/*.yml` 临时抓图/抓取产物(非源码,可忽略/清理)。 --- @@ -13,6 +14,80 @@ geopro(Qt6/C++ 离线桌面客户端,1:1 复刻赛盈地空 web)已完成 均**未合并入 main**,分支挂起等收尾。 +### 0.1 2026-06-12 渲染保真修复(本次会话) + +用户报客户端散点/网格颜色与原版差异大(大量点透明发白、图例只有 6 色 + 白缝)、散点缺 hover。经 Playwright 抓原版真实 API + 读源码定位为**通用根因**: + +- **colorBar alpha 标度 bug**(核心):`lvl/colorGradation/getDetail` 返回的 colorBar 是**混合格式**——hex `#00008B` 与 CSS `rgba(0, 0, 170, 1)`(**alpha 是 0–1 浮点**),共 18 段。`DatasetChartDto.cpp` 用 `AlphaScale::Bit255` 解析 → rgba 的 `a=1` 被当字节 → alpha≈1/255 近透明(12 个 rgba 段全成白缝;6 个 hex 段因 hex 分支强制 255 才可见)。**修复:`Bit255`→`Unit`**(一行)。散点连续插值 `ColorMapService` 的归一化位置 `val/maxVal` 已证**精确等于原版 Plotly colorscale 位置**,无需改。 +- **散点 hover**:新增 `ScatterHoverTip`(canvas 事件过滤器,与 LivePanner 共存),最近点命中显示 `X/Y/值`(各 3 位小数,对齐原版 Plotly hovertemplate `X: %{x:.3f}…`)。 +- 文件:改 `src/data/dto/DatasetChartDto.cpp`;新增 `src/app/panels/chart/ScatterHoverTip.{hpp,cpp}` + 接线 `RawDataChartView.{hpp,cpp}`;测试 `tests/data/test_dataset_chart_dto.cpp`(改用真实混合格式 + 断言 alpha=255 回归)、新增 `tests/app/test_scatter_hover.cpp`。**测试 116→118 全绿**,cpp-reviewer APPROVE。 +- ⚠️ **像素级视觉 1:1 待用户在运行的 app 内登录核对**(原生 Qt 窗口无法用 Playwright 驱动;以下各层已机器验证:抓包/源码/RED-GREEN/插值位置匹配)。 +- 观察(未改,待定):图例 `ColorBarWidget` 画 `stops-1=17` 段,丢弃最后一段最深色;原版疑为 18 段。非本次报障,留待用户决定是否补齐。 + +### 0.2 2026-06-12 第二轮修复(散点 cauto 归一化 + hover mouseTracking + 页签名) + +第一轮 alpha 修复后颜色不再透明,但散点仍与原版不一致(挤在暖色中段、无蓝无紫)。复查 Plotly `_fullData`:散点 **`cmin/cmax` 未设 → `cauto` 自动取 vlist 实际 min/max(本例 45.93–197.79)**,把整段色阶铺满数据范围;而客户端 `ColorMapService` 错用 colorBar 全程 0–1323 归一化数据 → 压进色阶 0.03–0.15 段。 + +- **修复 A(散点 cauto)**:`ColorMapService.setDataRange(dataMin,dataMax)` 解耦「色阶形状位置(按断点值)」与「数据值归一化(按数据 min/max)」;`RawDataChartView::setData` 按 `d.scatter.v` 有限值 min/max 调用。**数值验证 1:1**:手算修复后客户端对样本值的 RGB 与原版 Plotly 实测逐字节相同(如中位 92.01→[168,0,35]、62.34→[255,119,0])。网格仍用绝对阈值(`colorAtDiscrete`),不变。 +- **修复 B(hover 仍无效)**:根因 `QwtPlotCanvas` 默认不开 mouseTracking → 无按键时收不到 MouseMove。`ScatterHoverTip` 构造里加 `canvas->setMouseTracking(true)`。 +- **修复 C(页签用数据名)**:`ChartData` 加 `dsName`;`openDataset` 加可选第 3 参 `dsName`(默认空,向后兼容测试);`kDsNameRole` 存 dsName,`main.cpp` 双击传入,`DatasetDetailPanel` `addTab` 用 `dsName`(空回退 dsId)+ tooltip。 +- 文件:`ColorMapService.{hpp,cpp}`、`RawDataChartView.cpp`、`ScatterHoverTip.cpp`、`DatasetDetailController.{hpp,cpp}`、`DatasetListPanel.{hpp,cpp}`、`DatasetDetailPanel.cpp`、`main.cpp`;测试 `test_colormap_service.cpp`(+DataRangeDecouples)。**119/119 全绿**。 +- ⚠️ hover/页签名/视觉仍待用户运行核对(GUI 无法自动驱动)。 + +### 0.3 2026-06-12 桌面端日志 + 崩溃捕获(新基础设施) + +用户反馈:双击 ds 后状态栏报「内部系统错误」然后客户端崩溃;后端错误可接受,但客户端不该崩。复测发现 scatter/color 接口当时其实 200/干净数据 → 无日志根本无从定位。遂加生产级日志 + 崩溃捕获(已实测打通)。 + +- **`src/app/Logging.{hpp,cpp}`**:`initLogging()`(main.cpp 在 setApplicationName 后调用)。 + - `qInstallMessageHandler` 接管全 App `qDebug/qInfo/qWarning/qCritical/qFatal` → 写带时间戳/级别的滚动日志:`%LOCALAPPDATA%/Geomative/Geopro3/logs/geopro_YYYYMMDD.log`(按天,UTF-8+BOM,启动清理 14 天前旧文件)。 + - **崩溃捕获**:`SetUnhandledExceptionFilter`(SEH,含未捕获 C++ 异常 0xE06D7363)+ `std::set_terminate`;崩溃时 `MiniDumpWriteDump` 写 `crash_*.dmp`(链 `Dbghelp`)+ 记一行 `[FATAL] 崩溃 code=… addr=… dump=…`。`SetErrorMode` 抑制系统弹窗。**已用临时 env 自检实测**:空指针崩溃 → 生成 6MB dmp + 日志记录 code=0xc0000005(自检块已删)。 + - dmp 可 VS/WinDbg 加载看完整调用栈。 +- **埋点**:`ApiCall::onFinished` 记录每个 API 响应(URL/http/code/err,成功 INFO/失败 WARN);`DatasetDetailController` openDataset + 各 loadFailed;App 启动版本/路径。net 层 `qWarning` 自动收录。 +- **顺带堵崩溃根因**(评审 H-2 + 防脏数据):`ColorMapService::colorAtContinuous` 对 NaN/Inf 的 t 回退首断点色(原会 `upper_bound` 返回 end() 后解引用越界崩溃);`RawDataChartView` vlist min/max 用 `isfinite` 跳过 NaN/±Inf。+ 单测 `ColorAtContinuousNaNSafe`。 +- **测试 119→120 全绿**。 +### 0.4 2026-06-12 崩溃定位 + 顶层异常护栏 + +用户提供 dump + log。日志序列:`openDataset(ERT2-WS_result.dat)` → **`dynamicForm` 后端返回 `code=500 sys.internalServerError`**(即"内部系统错误")→ scatter 200 → color 200 → `Qt has caught an exception thrown from an event handler` → 崩溃。 + +**dump 分析(winget 装 Microsoft.WinDbg,`!analyze -v`)**:异常 `e06d7363`(C++ EH),栈 `VCRUNTIME140!CxxThrowException ← msvcp140!std::_Xlength_error ← geopro_desktop+0x9ad0 ← +0xa94ee ← +0x24b71 ← +0x235c0`。即 **`std::length_error`**(STL 容器/字符串用非法 size resize/reserve/构造)。栈帧都在 geopro_desktop(含静态链接的 Qwt),但**该 dump 是旧构建、当前 PDB 已不匹配 → 符号化不出函数名**。静态排查 dynamicForm 解析 / DynamicFormView / selectDataset 失败路径 / 散点解析(已 try/catch) / 渲染 / ColorMapService 均未见明显抛点 → 确切行**待运行期护栏日志点名**。 + +**修复(主,根治"不该崩")**:`main.cpp` 加 `GuardedApplication : QApplication`,override `notify()` try/catch:任何 slot/事件处理器抛出的异常被拦截 + `qCritical` 记录 `[guard] 拦截... what | type | receiver=<对象类名> | event=<事件类型>`,然后吞掉(不终止)。后端故障等异常**不再使整个客户端退出**。 +**附带**:修 `appendCrashLine` 文件共享 bug(崩溃行原写不进日志——g_logFile 独占,第二句柄 open 失败;改为复用已打开句柄);`colorAtContinuous` NaN/Inf 守卫。 + +**护栏实测**:用户复现,护栏拦到 → 日志 `[guard] 拦截未捕获异常: vector too long | type=std::length_error | receiver=QNetworkReplyHttpImpl | event=43(MetaCall)`。即异常在**网络响应 finished 同步链**里(最后一个 chart 请求 color 返回 → `chartReady→openOrUpdate→渲染`)抛出 `std::length_error`("vector too long" = std::vector 用了非法尺寸,典型负数转 size_t)。进程**未崩**(护栏吞掉),但图表没渲染出来。 + +**诊断基础设施(关键补强)**: +1. **Release 构建之前不生成 PDB** → dump/崩溃栈对自家代码全部符号化失败(只有系统 DLL 导出表能解析)。已在根 `CMakeLists.txt` MSVC 块加 `/Zi`(编译) + `/DEBUG /OPT:REF /OPT:ICF`(链接):Release 保持优化的同时产出匹配 PDB。**生产桌面端排障必需。** +2. **VEH 抛点堆栈符号化**(`Logging.cpp`):`AddVectoredExceptionHandler` 在 C++ 异常(0xE06D7363)**抛出瞬间**(栈未展开、PDB 匹配)用 DbgHelp(`SymInitialize`+`SymLoadModuleExW`+`SymFromAddrW`+`SymGetLineFromAddrW64`,**宽字符**——UNICODE 下必须)打印 `模块+RVA 函数名+偏移 (文件:行)`。自测验证:`reserve(-1)` → 正确打印 `geopro::app::initLogging (Logging.cpp:245)`。即使异常被护栏吞掉也留下抛点栈。 + +**下一步定位 length_error 确切行**:用当前构建复现一次 → 日志 `[THROW]` 段直接给出 `geopro::app::<类>::<方法> (文件:行)`。**测试 120/120 全绿。** + +### 0.5 2026-06-12 数据集列表树化 + 按根分页 + 暗色主题保真(本次会话) + +用户三组报障,均已修复、构建链接通过、应用真实流程跑通无崩溃、**测试 120→122 全绿**: + +**(1) 数据集列表应是树(原为平铺)** +- **实地确认(铁律 Playwright)**:原版「数据管理」选 TM 后的数据列表是 **el-table tree-data**(有展开箭头)。脚本直连 API 验证 TM E3(项目 1458977804960256 垃圾掩埋場):10 个 DS → **4 个根**(默认折叠)= 3×源「ERT原始数据」+ 1×「ERT电极坐标」;派生「反演/接地电阻」按 `parentId`(==`sourceShowParentId`)挂源「原始数据」下。根 = parentId 为空或指向不在本批的「源文件节点」。 +- **改**:`DsRow` 加 `parentId`;`NavDto::parseDsRows` 解析 `sourceShowParentId`(回退 `parentId`);`DatasetListPanel::populateDatasetList` 由 `QListWidget` 改建 **`QTreeWidget`**(两遍法按 parentId 嵌套,卡片委托 `applyDatasetCardDelegate` 泛化到 `QAbstractItemView`);`main.cpp` `datasetList` 改 `QTreeWidget`(`setExpandsOnDoubleClick(false)`:双击=开详情、展开靠箭头),点击/双击/反向高亮/加载更多 4 处改 `QTreeWidget` API。文件页签仍平铺 `QListWidget`。默认折叠(对齐原版)。 +- 测试:`test_nav_dto.cpp::ParseDsRowsParentIdForTree`。 + +**(2) 分页应按「第一层节点(根)」算(原按扁平 DS)** +- **根因**:后端 `dsObject/data/page` 按**扁平 DS** 分页(脚本验证:total=10、pageSize=5 返回 5 条扁平行)——子节点的父常落在下一页 → 按页建树出孤儿根、首层数错乱。 +- **改**:`IAsyncProjectRepository::loadRowsAsync` 加 `int pageSize=5` 参数(接口/`ApiProjectRepository`/测试 stub 同步);`WorkbenchNavController::selectObject` 数据页改用大 pageSize(`kFetchAllPageSize=1000`)**一次取全**整棵,缓存 `allDataRows_`;新私有 `emitNextDataRootPage(bool append)` **客户端按根切页**(每页 `kDataRootPageSize=5` 个根 + 各自整棵子树,DFS 收集后按原序输出,`total`=根总数);`loadMoreData` 改同步切下一页(无请求);`dataRootsShown_` 游标,`resetSelectionState` 清空。删因此空置的 `moreDataReq_`。`main.cpp` `addTreeLoadMore` 计数改按顶层根。若 `listCount>` 读 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. 背景 @@ -105,7 +180,7 @@ geopro(Qt6/C++ 离线桌面客户端,1:1 复刻赛盈地空 web)已完成 ## 6. 尚未完成 / 下一步(按优先级) ### A. 收尾本分支(最先) -分支领先 main 68 commits、116/116 绿、工作区干净。用户多次选「保持现状」未合并。下一步可问用户:①合并回 main(本地) ②推送建 PR(origin=gitea `https://gitea.geomative.cn/gaozheng/geopro.git`) ③保持现状。用 `superpowers:finishing-a-development-branch`。 +分支领先 main 68 commits、**122/122 绿**,但**工作区脏**(sessions 0.1–0.5 代码全未提交,见顶部 ⚠️)。用户多次选「保持现状」未提交未合并。**先决**:本次会话两组改动(数据集树/按根分页、暗色主题)**待用户在运行的 app 内目视核对**(见 0.5 末尾清单)——核对通过再谈提交。下一步可问用户:①目视核对本次改动 ②提交这批未提交工作(建议按主题分多个 commit:详情图保真 / 日志崩溃捕获 / 数据集树+分页 / 暗色主题)③合并回 main(本地) ④推送建 PR(origin=gitea `https://gitea.geomative.cn/gaozheng/geopro.git`) ⑤保持现状。用 `superpowers:finishing-a-development-branch`。 ### B. 其余 dd 类型详情图(计划已写,多数 BLOCKED) 计划:`plans/2026-06-11-dataset-detail-other-dd-types.md`。 diff --git a/docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md b/docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md index e9f332f..9b7552f 100644 --- a/docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md +++ b/docs/superpowers/plans/2026-06-11-dataset-detail-other-dd-types.md @@ -45,23 +45,56 @@ --- -## 1. 各 dd 类型:样本可得性 + 渲染归类 + 可推进/BLOCKED 矩阵 +## 1. 各 dd 类型:实测编目(2026-06-12 全量遍历,已坐实) -> 现实约束(spec §2.4):当前可访问租户**仅 ERT / TEM / GPR 三类**;**GPR 对象无数据、无测井数据样本**。多数 dd 类型无活样本可参照。 -> ddCode 列为推断(源码无常量),**Phase 0 须用 Playwright 抓数据集列表项的真实 `ddCode` 字段核对后回填本表**,禁止写死未经核对的 ddCode。 +> **本节已由 2026-06-12 全量实测替换原"推断矩阵"。** 用脚本直连 API 遍历**两个账号**(威立雅租户 + 赛盈地空"数据多"账号 20 项目 / 108 TM / 752 DS),逐个 ds 核对 `ddCode` + `dsTypeCode` + 真实渲染。Phase 0 探查目标基本达成。 -| 数据类型(菜单/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:, structParentConfType:2, classifyTypeList:[3], pageNo, pageSize}`**。 + - ⚠️ **字段是 `structParentId`(=tmId)+ `structParentConfType:2`,不是 `tmObjectId`**; + - ⚠️ **必须带 `classifyTypeList:[3]`**(=数据,缺了/换值后端直接 `code:500 sys.internalServerError`); + - ⚠️ `pageNo` 不是 `pageNum`。 + - 时序类另走 `POST /business/dsObject/timeSensor/data/page`(同 body)。 +4. DS 对象关键字段:`ddCode`(如 `dd_grid`)、`dsTypeCode`(英文,如 `WhitenedData`)、`name`(中文数据类型名)、`dsName`(文件名)、`id`(dsId)。 +5. 后端对该接口**间歇 500**,脚本需重试(4–8 次)。 + +**铁律纠正(重要):详情渲染由数据集的"真实数据类型"决定,URL 详情链接里的 `ddCode` 经常标错。** 实测:「视电阻率数据」「接地电阻」的详情链接都带 `ddCode=dd_inversion_data`,但渲染分别是反演剖面 / 柱状图,完全不同。**客户端策略分派必须用真实数据类型(ds 元数据的 `dsTypeCode`/`ddCode`),绝不能用详情链接的 ddCode。** + +### 1.1 已确认数据类型(10 种 ddCode,均有活样本) + +| ddCode | dsTypeCode | 数据类型(中) | 渲染视图(实测) | 客户端 | 样本 | |---|---|---|---|---|---| -| ERT 反演(已完成基线) | `dd_inversion_data` | `getErtRawDataScatterGraph/{id}`、`inversion/rows/{id}`、色阶 type1/2、`queryException/{id}` | 散点 + 等值面(已实现) | 有 | ✅ 已完成 | -| 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 确认有样本) | -| ERT 测量/高密度(gr) | 待核对 | `GET dd/ert/measurement/gr/rows?dsObjectId` | 待 Phase 0 看形态(散点/伪剖面) | 同上 | 候选可推进(待 Phase 0 确认) | -| TEM 时序(设备时序) | 待核对 | `POST dd/ert/timeSensor/rows`(body `DDTimeSensorDataQueryReqVO`)、`GET dd/ert/timeSensor/page` | 时序折线类 → 新建 `LineChartView`(x=时间,y=数值) | **TEM 租户在**,须 Phase 0 找到有数据对象 + 抓响应 | 候选可推进(待 Phase 0 确认有样本) | -| dd_grid(网格) | 待核对 | `GET dd/ert/grid/rows?dsObjectId&pageNo&pageSize` | 等值面类 → 复用 `ContourPlotItem`/`GridDataChartView` | 当前租户**无样本** | 🚫 BLOCKED:待样本 | -| 轨迹 | 待核对 | `GET dd/ert/trajectory/rows?dsObjectId`、`GET dd/ert/trajectory/line?dsObjectId&frontCrsCode` | 折线/路径类 → 待样本定(可能复用散点连线或新 `LineChartView`) | 当前租户**无样本** | 🚫 BLOCKED:待样本 | -| 测井(well logging) | 待核对(菜单「测井参数表」) | **OpenAPI 未见明确专用 rows 接口** —— Phase 0 须从原版抓真实请求确认接口 | 折线类 → 新建 `LineChartView`(y=深度向下 / x=数值;或 x=时间 y=数值曲线) | **无测井数据样本** | 🚫 BLOCKED:待样本 + 待接口确认 | -| GPR(雷达剖面图像) | 待核对 | `GET dd/gpr/channel/image/{dsObjectId}`、`GET dd/gpr/channel/trace/spectrogram`、`GET dd/gpr/channel/querySegmentation` | 图像类 → 新建 `ImageChartView`(位图剖面 + 坐标轴) | **GPR 对象无数据** | 🚫 BLOCKED:待样本 | +| `dd_inversion_data` | ERT inversion data | 电阻率(反演)数据;TEM反演剖面;视电阻率数据 | **① 原数据(Plotly散点,cauto) + 网格数据(等值面+等值线)** 2 页签 | ✅ 已做 | 多 | +| `dd_ert_measurement_data` | ERT raw data | ERT原始数据 | **③ 散点伪剖面**(x=斜距/y=伪深度, 视电阻率着色, A/B/M/N 悬浮, 含反演运算/生成视电阻率/色阶工具) + 数据列表 | 可做 | 多 | +| `dd_ert_measurement_gr_data` | earth resistance | ERT接地电阻 | **② 柱状图**(Y=电阻/欧姆, X=电极点) + 列表 | 可做 | 多 | +| `dd_trajectory_data` | Platform trajectory | ERT电极坐标;TEM坐标 | **④ 轨迹**:地图(测线折线) + 列表 + 高程 3 页签 | 可做 | 多 | +| `dd_grid` | WhitenedData | 白化数据 | **⑤ 列表**(序号/x/y 地表点) | 可做 | 有 | +| `dd_gpr_channel_detail` | RADAR_SINGLE_CHANNEL_PROFILE | 雷达单通道剖面数据 | **⑥ 雷达剖面灰度图像(B-scan radargram)** 左 + **单道波形(A-scan wiggle)** 右;工具:对比度滑块/灰度色阶/显示频谱图 | 待需求确认 | 雷达项目 | +| `dd_gpr_channel_image` | RADAR_SINGLE_CHANNEL_IMAGE_LIST | 雷达单通道图片列表 | **⑥ 同上**(B-scan 灰度图像 + A-scan 波形,与 channel_detail 同视图) | 待需求确认 | 雷达项目 | +| `dd_radar_channel_trajectory` | RADAR_SINGLE_CHANNEL_TRAJECTORY | 雷达单通道轨迹坐标 | **⑦ 地图轨迹**:真实地理底图(街道/河流) + GPS 测线路径(黄/绿线) + 起点/终点/轨迹点标记 | 待需求确认 | 雷达项目 | +| `dd_radar_rtk_trajectory` | rtk_trajectory | RTK 轨迹坐标 | **⑦ 地图轨迹 + RTK 坐标点设置面板**(GPS定位/解状态过滤:固定解/浮点解/单点解/差分、最大距离过滤、数据还原) | 待需求确认 | 雷达项目 | +| `dd_radar_preprocess_data` | RADAR_PREPROCESS_DATA | 雷达预处理数据 | **空白**(无标题/内容,仅空 map 容器)——疑数据中间态、无独立可视详情 | 待需求确认 | 雷达项目 | -**矩阵小结:** 唯一确定有数据的是 `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`)。 --- diff --git a/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md b/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md index a4749b3..148748c 100644 --- a/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md +++ b/docs/superpowers/specs/2026-06-11-dataset-detail-view-design.md @@ -8,7 +8,9 @@ **架构偏离(重要):** spec 原定渲染器为 **QGraphicsView**,实际落地改用 **QwtPlot(轴/交互/图例)+ VTK 算法层(等值线几何)+ 连续/离散色阶**(见返工方案 `plans/2026-06-11-dataset-detail-chart-v2-qwt.md`,权威)。展示结果视觉等价,下文 §5.2/§8 的 QGraphicsView 细节已被 QwtPlot 方案取代,保留作背景参考。 -**已完成(仅 `dd_inversion_data` ERT 反演,§2.2 展示范围内):** 原数据散点(方形点/白描边/连续色阶/x 轴顶部)+ 网格等值面(填充栅格 + 黑色等值线 + 沿线数值标注 + NaN 白边裁剪)+ 色阶图例 + 异常叠加 + 底部异常表/描述 + 多 Tab + 网格数据懒加载 + 页签内滚动/分割条 + 实时平移/滚轮缩放。数据加载已异步化(见 `specs/2026-06-11-apiclient-async-design.md`)。 +**已完成(仅 `dd_inversion_data` ERT 反演,§2.2 展示范围内):** 原数据散点(方形点/白描边/连续色阶/x 轴顶部 + **hover 显 X/Y/值**)+ 网格等值面(填充栅格 + 黑色等值线 + 沿线数值标注 + NaN 白边裁剪)+ 色阶图例 + 异常叠加 + 底部异常表/描述 + 多 Tab + 网格数据懒加载 + 页签内滚动/分割条 + 实时平移/滚轮缩放。数据加载已异步化(见 `specs/2026-06-11-apiclient-async-design.md`)。 + +**2026-06-12 渲染保真修复:** colorBar 是混合 hex+CSS-rgba 格式且 rgba 的 **alpha 为 0–1 浮点**;原 `DatasetChartDto` 用 `AlphaScale::Bit255` 解析致 12/18 段近透明(散点/网格/图例全发白)。修为 `AlphaScale::Unit`(一行根因修复,通用生效)。散点 `ColorMapService` 连续插值的归一化位置已证 1:1 等于原版 Plotly colorscale。新增 `ScatterHoverTip` 复刻原版 hovertemplate。详见 `HANDOFF-dataset-detail-chart.md §0.1`。 **未完成:** - **其余 dd 类型的详情图渲染**(§2.4):`dd_ert_measurement_data`、`dd_ert_measurement_gr_data`、`dd_grid`、`dd_trajectory_data`、测井(深度/时序折线)、GPR(`dd/gpr/channel/image`)、TEM 等。控制器目前对非 `dd_inversion_data` 直接「暂不支持该类型预览」。**现实约束:当前租户仅 ERT/TEM/GPR 三类,GPR 对象无数据、无测井数据 → 多数类型无活样本,须先取样本。** 实现计划见 `plans/2026-06-11-dataset-detail-other-dd-types.md`(如已生成)。 @@ -54,8 +56,53 @@ 网格化参数(GridDialog)、色阶配置(colorEditor)、白化(WhiteningDialog)、滤波/迭代处理、异常框注/自动标注(AutoAnnotationDialog)、另存为(SaveAsDialog)、导出(ExportDialog)、描述富文本编辑、大视图(Esc) 全屏。 ### 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 等。 -> 现实约束:当前可访问租户只有 ERT/TEM/GPR 三类,且 GPR 对象无数据、无测井数据。其它 dd 类型详情页**无活样本可参照**,须待拿到数据样本后再精确复刻。 +其余 dd 类型详情图见下文 **§2.5(2026-06-12 全量实测编目)** 与实现计划 `plans/2026-06-11-dataset-detail-other-dd-types.md`。客户端按真实数据类型走 `ChartStrategyRegistry` 分派。 + +### 2.5 数据类型全景:设计 taxonomy(Excel)vs 实测运行 ddCode + 渲染映射(2026-06-12) + +> 用脚本直连 API 全量遍历两个账号(威立雅 + 赛盈地空"数据多"账号,20 项目/108 TM/752 DS)+ 逐个打开详情看截图确认渲染。**关键纠正:详情渲染由数据集"真实数据类型"决定,URL 详情链接的 `ddCode` 经常标错,客户端分派须用 ds 元数据的真实类型。** + +#### 2.5.1 实测 10 种 ddCode → 7 种渲染视图(均有活样本、看截图确认) + +| 渲染视图 | ddCode | 数据类型 | 客户端 | +|---|---|---|---| +| **① 原数据(Plotly散点,cauto) + 网格数据(等值面+等值线)** | `dd_inversion_data` | ERT/TEM 反演剖面、视电阻率数据 | ✅ 已做 | +| **② 柱状图(Y=电阻欧姆,X=电极点) + 列表** | `dd_ert_measurement_gr_data` | ERT接地电阻 | ❌ | +| **③ 散点伪剖面(斜距/伪深度,视电阻率着色,反演运算工具) + 数据列表** | `dd_ert_measurement_data` | ERT原始数据 | ❌ | +| **④ 轨迹:地图 + 列表 + 高程 3页签** | `dd_trajectory_data` | ERT电极坐标、TEM坐标 | ❌ | +| **⑤ 列表(序号/x/y)** | `dd_grid` | 白化数据 | ❌ | +| **⑥ 雷达剖面灰度图像(B-scan) + 单道波形(A-scan) + 对比度/灰度色阶/频谱** | `dd_gpr_channel_detail`、`dd_gpr_channel_image` | 雷达单通道剖面/图片列表 | ❌ | +| **⑦ 地图轨迹(真实GIS底图 + GPS路径 + 起点/终点)**(RTK 另带坐标点设置/解状态过滤面板) | `dd_radar_channel_trajectory`、`dd_radar_rtk_trajectory` | 雷达单通道轨迹/RTK轨迹 | ❌ | +| (无独立可视详情,疑数据中间态) | `dd_radar_preprocess_data` | 雷达预处理数据 | — | + +**共 7 种渲染视图,客户端已做 1 种(①);待做 6 种:② 柱状图、③ 散点伪剖面、④ 轨迹、⑤ 列表、⑥ 雷达剖面图像、⑦ 地图轨迹。** + +#### 2.5.2 与 `Geopro3.0 菜单.xlsx`「DD类型」设计 taxonomy 的对应 + +Excel「DD类型」页签是**设计期格式蓝图**,用的是另一套命名(`dd_Section`/`dd_Track`/`dd_ERTRawData`/`dd_GPRSection`/`dd_Images`/`dd_TimeVarious`…)。与实际运行 ddCode **命名对不上、语义对得上**: + +| Excel 设计格式 | 描述(Excel) | 对应实测运行 ddCode | 样本 | +|---|---|---|---| +| `dd_Section`("最重要":水平/垂直剖面) | 反演剖面 | `dd_inversion_data`(①)、`dd_gpr_channel_detail`(⑥) | ✅ | +| `dd_ERTRawData` | ERT原始数据 | `dd_ert_measurement_data`(③)、`dd_ert_measurement_gr_data`(②) | ✅ | +| `dd_GPRSection`(设计注:讨论是否并入 dd_Section) | 雷达剖面 | `dd_gpr_channel_detail`/`image`(⑥) | ✅ | +| `dd_Track`(坐标+轨迹合并) | 电极坐标/采集轨迹 | `dd_trajectory_data`(④)、`dd_radar_*_trajectory`(⑦) | ✅ | +| `dd_Images` | 图片类 | `dd_gpr_channel_image`(⑥) | ✅ | +| `dd_Files` | 文件打包(如三维雷达原始包) | `dd_radar_preprocess_data`(疑) | ✅部分 | +| `dd_TimeVarious` | 时间连续变量 | =设计中的 TEM时序/timeSensor 折线 | ❌ 无样本 | +| `dd_Stratigraphy` | 地层编录/**测井** | =设计中的测井折线/深度 | ❌ 无样本 | +| `dd_Segy` | 地震 SEGY(只展示) | — | ❌ 无地震数据 | +| `dd_Sampling` | 采样/化学(XRF/VOCs) | — | ❌ | +| `dd_LinearVarious` / `dd_Structual3D` / `dd_Property3D` / `dd_Video` | 线性变量/三维结构/三维属性/视频 | — | ❌ | +| `dd_KML`/`dd_GeoJson`/`dd_shp`/`dd_dem`/`dd_bim`/`dd_czml`… | 背景资料**图层**(导入/预览/坐标对齐) | — | 非"数据详情图",是地图图层 | + +**结论:** Excel 是"应有哪些数据格式"的设计蓝图;实测 10 种是"现在真有数据、详情页真能出图"的子集(电法 ERT + 地质雷达 GPR 两大类的落地)。两者语义吻合;Excel 里 时序(dd_TimeVarious)/测井(dd_Stratigraphy)/地震(dd_Segy)/采样/三维模型/视频 等当前**无活样本,BLOCKED**(与 plan §1.2 一致);KML/shp/dem/bim 等是地图图层,不属本 spec 的详情图范畴。 + +#### 2.5.3 枚举 DS 的 API(实测可用,踩坑记录) + +`POST my/profile/project/page`{pageNo,pageSize} → `GET projectStruct/queryProjectStruct/{pid}`(扁平节点:type=1=项目/GS,type=2=TM,parentId 链表层级;TM 可挂项目或 GS 下)→ `POST dsObject/data/page` body **`{projectId, structParentId:, 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 复刻。 --- diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index f98119e..1ef6c5a 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -29,18 +29,21 @@ add_executable(geopro_desktop WIN32 panels/DescriptionPanel.cpp panels/chart/RawDataChartView.cpp panels/chart/GridDataChartView.cpp + panels/chart/ChartTheme.cpp panels/chart/ColorMapService.cpp panels/chart/ColorBarWidget.cpp panels/chart/ScatterPlotItem.cpp panels/chart/ContourPlotItem.cpp panels/chart/LivePanner.cpp + panels/chart/ScatterHoverTip.cpp panels/AnomalyTablePanel.cpp panels/LoadingOverlay.cpp panels/DatasetDetailPage.cpp panels/DatasetDetailPanel.cpp CentralScene.cpp ProjectListDialog.cpp - SettingsDialog.cpp) + SettingsDialog.cpp + Logging.cpp) target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) # QtKeychain 经 FetchContent 接入,头文件不随 target 传播,显式加源/构建目录(含生成的 export 头)。 @@ -68,6 +71,11 @@ endif() vtk_module_autoinit(TARGETS geopro_desktop MODULES ${VTK_LIBRARIES}) +# 崩溃 minidump:DbgHelp(Windows 自带)提供 MiniDumpWriteDump。 +if(WIN32) + target_link_libraries(geopro_desktop PRIVATE Dbghelp) +endif() + if(WIN32) add_custom_command(TARGET geopro_desktop POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different diff --git a/src/app/Logging.cpp b/src/app/Logging.cpp new file mode 100644 index 0000000..4b2e2e5 --- /dev/null +++ b/src/app/Logging.cpp @@ -0,0 +1,243 @@ +#include "Logging.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#ifdef Q_OS_WIN +// clang-format off +#include +#include // 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(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(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(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( + 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(code), 0, 16) + .arg(reinterpret_cast(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(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(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(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(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(GetModuleHandleW(nullptr)), 0, nullptr, 0); + if (selfBase == 0 && GetLastError() != ERROR_SUCCESS) + std::fprintf(stderr, "[Logging] SymLoadModuleExW 失败 err=%lu\n", GetLastError()); + AddVectoredExceptionHandler(1, throwStackVeh); // first=1:先于 SEH 链,抛出瞬间捕获 +#endif + // 未捕获 C++ 异常(跨事件循环逃逸)→ 记录后终止。SEH 过滤器通常已先写 dump。 + std::set_terminate([] { + QString what = QStringLiteral("(无异常信息)"); + if (std::exception_ptr e = std::current_exception()) { + try { + std::rethrow_exception(e); + } catch (const std::exception& ex) { + what = QString::fromUtf8(ex.what()); + } catch (...) { + what = QStringLiteral("(非 std::exception)"); + } + } + writeLine(QStringLiteral("%1 [FATAL] std::terminate 未捕获异常: %2") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")), what)); + std::abort(); + }); +} + +} // namespace + +void initLogging() { + const QString base = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation); + g_logDir = base + QStringLiteral("/logs"); + QDir().mkpath(g_logDir); + pruneOldFiles(g_logDir); + + const QString fname = QStringLiteral("geopro_%1.log") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd"))); + g_logFile.setFileName(g_logDir + QStringLiteral("/") + fname); + const bool opened = g_logFile.open(QIODevice::Append | QIODevice::Text); + if (!opened) { // 打不开(权限等)则仅写 stderr(writeLine 已容错) + std::fprintf(stderr, "[Logging] 无法打开日志文件: %s\n", qUtf8Printable(g_logFile.fileName())); + } else if (g_logFile.size() == 0) { + g_logFile.write("\xEF\xBB\xBF", 3); // 新建文件写 UTF-8 BOM,便于中文 Windows 记事本正确识别编码 + } + + qInstallMessageHandler(messageHandler); + installCrashHandlers(); + + qInfo("=== Geopro 启动 v%s | 日志目录 %s ===", + qUtf8Printable(QCoreApplication::applicationVersion().isEmpty() + ? QStringLiteral("dev") + : QCoreApplication::applicationVersion()), + qUtf8Printable(g_logDir)); +} + +QString logDirectory() { return g_logDir; } + +} // namespace geopro::app diff --git a/src/app/Logging.hpp b/src/app/Logging.hpp new file mode 100644 index 0000000..f9b45a9 --- /dev/null +++ b/src/app/Logging.hpp @@ -0,0 +1,18 @@ +#pragma once +#include + +namespace geopro::app { + +// 初始化全局日志与崩溃捕获。需在 QApplication 构造、setOrganizationName/setApplicationName +// 之后调用一次(依赖 AppLocalDataLocation 定位日志目录)。 +// - 安装 qInstallMessageHandler:全 App 的 qDebug/qInfo/qWarning/qCritical/qFatal 写入 +// 带时间戳/级别的滚动日志文件(%LOCALAPPDATA%///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 diff --git a/src/app/main.cpp b/src/app/main.cpp index 1dafd25..d22883c 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -56,6 +57,7 @@ #include #include #include +#include #include #include @@ -73,6 +75,7 @@ #include "AuthService.hpp" #include "Credential.hpp" #include "Glyphs.hpp" +#include "Logging.hpp" #include "PanelHeader.hpp" #include "Theme.hpp" #include "SettingsDialog.hpp" @@ -415,7 +418,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 左下 dock:数据真实显示栏(选中测线后列其采集批次=数据集;tab 数据/文件)。 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); datasetTabs->addTab(datasetList, QStringLiteral("数据")); auto* fileList = new QListWidget(); @@ -483,24 +493,25 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }; // ── 单击左下数据列表的采集批次(DS) → 属性表单 + 聚焦详情已开页 ── - QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, - [&nav, &detailCtrl](QListWidgetItem* item) { - if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { + QObject::connect(datasetList, &QTreeWidget::itemClicked, datasetList, + [&nav, &detailCtrl](QTreeWidgetItem* item, int) { + if (item->data(0, geopro::app::kDsLoadMoreRole).toBool()) { nav.loadMoreData(); return; } - const QString dsId = item->data(geopro::app::kDsIdRole).toString(); + const QString dsId = item->data(0, geopro::app::kDsIdRole).toString(); if (dsId.isEmpty()) return; nav.selectDataset(dsId); // 属性表单(现状) detailCtrl.focusDataset(dsId); // 单击=聚焦已开页 }); // ── 双击 → 打开/聚焦该数据集的详情图表页(拉真实反演剖面/散点/异常/色阶)── - QObject::connect(datasetList, &QListWidget::itemDoubleClicked, datasetList, - [&detailCtrl](QListWidgetItem* item) { - const QString dsId = item->data(geopro::app::kDsIdRole).toString(); - const QString ddCode = item->data(geopro::app::kDsDdCodeRole).toString(); - if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode); + QObject::connect(datasetList, &QTreeWidget::itemDoubleClicked, datasetList, + [&detailCtrl](QTreeWidgetItem* item, int) { + const QString dsId = item->data(0, geopro::app::kDsIdRole).toString(); + const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString(); + 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 → 反向高亮数据集列表对应行 ── QObject::connect(detailPanel, &geopro::app::DatasetDetailPanel::activeDatasetChanged, datasetList, [datasetList](const QString& dsId) { - for (int i = 0; i < datasetList->count(); ++i) - if (datasetList->item(i)->data(geopro::app::kDsIdRole).toString() == - dsId) - datasetList->setCurrentRow(i); + for (QTreeWidgetItemIterator it(datasetList); *it; ++it) + if ((*it)->data(0, geopro::app::kDsIdRole).toString() == dsId) { + datasetList->setCurrentItem(*it); + break; + } }); // 「视图详情」浮层显隐:仅三维显示,置于 QVTK 左上(工具条下方)并置顶。 @@ -635,6 +647,25 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re } 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, &geopro::controller::WorkbenchNavController::switchWorkspace); QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav, @@ -711,12 +742,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re datasetTabs->setTabText(1, QStringLiteral("文件")); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList, - [removeLoadMore, addLoadMore, datasetList, datasetTitle, datasetTabs]( + [removeTreeLoadMore, addTreeLoadMore, datasetList, datasetTitle, datasetTabs]( const QString&, const std::vector& rows, int total, bool append) { - removeLoadMore(datasetList); + removeTreeLoadMore(datasetList); geopro::app::populateDatasetList(datasetList, rows, append); - const int loaded = addLoadMore(datasetList, total); + const int loaded = addTreeLoadMore(datasetList, total); if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集")); datasetTabs->setTabText( 0, total > 0 ? QStringLiteral("数据 (%1/%2)").arg(loaded).arg(total) @@ -789,6 +820,32 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re } // 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(e->type()) : 0); + } catch (...) { + qCritical("[guard] 拦截未捕获非 std 异常 | receiver=%s | event=%d", + receiver ? receiver->metaObject()->className() : "null", + e ? static_cast(e->type()) : 0); + } + return false; + } +}; +} // namespace + int main(int argc, char* argv[]) { // 高 DPI 缩放采用直通策略:在 125%/150% 等分数缩放下字体/图标按真实比例渲染,更清晰。 @@ -798,7 +855,7 @@ int main(int argc, char* argv[]) // QVTK 默认 surface format 必须在 QApplication 之前设置(全局一次,两个 QVTK widget 共用)。 QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); - QApplication app(argc, argv); + GuardedApplication app(argc, argv); // 顶层异常护栏:slot/事件里的异常不致客户端崩溃 // 异步 ApiCall::finished 等信号携带 ApiResponse;注册元类型以支持跨 QueuedConnection 传递 // (当前详情链路为同线程 DirectConnection,非严格必需,但作防御性注册,见 spec §5.1)。 @@ -808,6 +865,9 @@ int main(int argc, char* argv[]) QCoreApplication::setOrganizationName(QStringLiteral("Geomative")); QCoreApplication::setApplicationName(QStringLiteral("Geopro3")); + // 日志 + 崩溃捕获:尽早安装(依赖上面的 Org/App 名定位日志目录)。生产桌面端问题可回溯。 + geopro::app::initLogging(); + // 主题与字号:应用持久化偏好(跟随系统/浅/深 + 字号缩放),再套用 Fusion + 全局 QSS + 调色板。 // 在弹登录窗之前完成 → 登录页与主页 明暗/字号 统一。 geopro::app::applyPersistedThemeMode(); diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index 765a002..205b843 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -22,7 +22,9 @@ void DatasetDetailPanel::openOrUpdate(const geopro::controller::DatasetDetailCon auto* p = pageFor(d.dsId); if (!p) { 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); } diff --git a/src/app/panels/DatasetListPanel.cpp b/src/app/panels/DatasetListPanel.cpp index 8faf540..d2e2ab8 100644 --- a/src/app/panels/DatasetListPanel.cpp +++ b/src/app/panels/DatasetListPanel.cpp @@ -1,6 +1,8 @@ #include "panels/DatasetListPanel.hpp" +#include #include +#include #include #include #include @@ -8,6 +10,9 @@ #include #include #include +#include +#include +#include #include "Theme.hpp" @@ -104,20 +109,53 @@ public: }; } // namespace -void populateDatasetList(QListWidget* list, const std::vector& rows, bool append) { - if (!list) return; - if (!append) list->clear(); - for (const auto& d : rows) { - QString text = QString::fromStdString(d.dsName); - QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间 - if (!d.typeName.empty()) - sub += QStringLiteral(" · %1").arg(QString::fromStdString(d.typeName)); // 再跟类型 - if (!sub.isEmpty()) text += QStringLiteral("\n%1").arg(sub); - auto* item = new QListWidgetItem(text, list); - item->setData(kDsIdRole, QString::fromStdString(d.id)); - item->setData(kDsDdTypeRole, QString::fromStdString(d.ddCode)); - item->setData(kDsDdCodeRole, QString::fromStdString(d.ddCode)); +namespace { +// 建一条数据集树项(不挂载):列0 文本 = dsName +「创建时间 · 类型名」,data 存各角色。 +QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) { + QString text = QString::fromStdString(d.dsName); + QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间 + if (!d.typeName.empty()) + sub += QStringLiteral(" · %1").arg(QString::fromStdString(d.typeName)); // 再跟类型 + if (!sub.isEmpty()) text += QStringLiteral("\n%1").arg(sub); + auto* item = new QTreeWidgetItem(); + item->setText(0, text); + item->setData(0, kDsIdRole, QString::fromStdString(d.id)); + 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& rows, bool append) { + if (!tree) return; + if (!append) tree->clear(); + + // id→已在树中的项:!append 时为空;append(分页)时含已加载行,使新行能挂到既有父下。 + QHash 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 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& rows, bool append) { @@ -141,13 +179,13 @@ void populateFileList(QListWidget* list, const std::vector& } } -void applyDatasetCardDelegate(QListWidget* list) { - if (!list) return; - list->setItemDelegate(new DatasetCardDelegate(list)); - list->setMouseTracking(true); // 让委托收到 hover 状态 - list->setSpacing(0); // 卡间距由委托内边距控制 - QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, list, - [list]() { list->viewport()->update(); }); +void applyDatasetCardDelegate(QAbstractItemView* view) { + if (!view) return; + view->setItemDelegate(new DatasetCardDelegate(view)); + view->setMouseTracking(true); // 让委托收到 hover 状态 + if (auto* list = qobject_cast(view)) list->setSpacing(0); // 卡间距由委托内边距控制 + QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, view, + [view]() { view->viewport()->update(); }); } } // namespace geopro::app diff --git a/src/app/panels/DatasetListPanel.hpp b/src/app/panels/DatasetListPanel.hpp index d6b7f9f..a06d937 100644 --- a/src/app/panels/DatasetListPanel.hpp +++ b/src/app/panels/DatasetListPanel.hpp @@ -4,6 +4,8 @@ #include "repo/RepoTypes.hpp" class QListWidget; +class QTreeWidget; +class QAbstractItemView; namespace geopro::app { @@ -13,13 +15,17 @@ constexpr int kDsDdTypeRole = 0x0101; // Qt::UserRole + 1 constexpr int kDsFileUrlRole = 0x0102; // Qt::UserRole + 2(文件下载 url,备用) constexpr int kDsLoadMoreRole = 0x0103; // 标记"加载更多"行 constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4(ddCode,双击详情选策略用) +constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5(dsName,详情页签标题用) -// 数据页签:每条 = dsName +(类型名);UserRole 存 dsId、+1 存 ddCode。 -void populateDatasetList(QListWidget* list, const std::vector& rows, bool append); +// 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。 +// 每项列0:文本 = dsName +「创建时间 · 类型名」;data(0,角色) 存 dsId/ddCode/dsName。 +// append=true 时把新行挂到已加载的父节点下(分页)。 +void populateDatasetList(QTreeWidget* tree, const std::vector& rows, bool append); // 文件页签:每条 = 文件名 +(可读大小);UserRole 存 dsId、+2 存文件 url。空时显示占位。 void populateFileList(QListWidget* list, const std::vector& rows, bool append); // 给数据/文件列表套用卡片委托(标题+元信息双行、悬停/选中圆角高亮+左强调竖条,规范§6.2)。 -void applyDatasetCardDelegate(QListWidget* list); +// 接受 QListWidget(文件)或 QTreeWidget(数据树)——故形参为其共同基类 QAbstractItemView。 +void applyDatasetCardDelegate(QAbstractItemView* view); } // namespace geopro::app diff --git a/src/app/panels/LoadingOverlay.cpp b/src/app/panels/LoadingOverlay.cpp index 00bc80f..095c1f9 100644 --- a/src/app/panels/LoadingOverlay.cpp +++ b/src/app/panels/LoadingOverlay.cpp @@ -1,19 +1,32 @@ #include "panels/LoadingOverlay.hpp" +#include #include #include #include +#include "Theme.hpp" + namespace geopro::app { LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QLabel(this)) { Q_ASSERT(parent); // 契约:必须有父(遮罩几何跟随父,无父无法工作) setAttribute(Qt::WA_StyledBackground, true); - setStyleSheet(QStringLiteral("background: rgba(255,255,255,160);")); label_->setText(QStringLiteral("加载中…")); label_->setAlignment(Qt::AlignCenter); auto* lay = new QVBoxLayout(this); lay->addWidget(label_); 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(); } diff --git a/src/app/panels/chart/ChartTheme.cpp b/src/app/panels/chart/ChartTheme.cpp new file mode 100644 index 0000000..6abb138 --- /dev/null +++ b/src/app/panels/chart/ChartTheme.cpp @@ -0,0 +1,45 @@ +#include "panels/chart/ChartTheme.hpp" + +#include +#include +#include +#include +#include +#include + +#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(it)) { + g->setMajorPen(gridMajor, 1.0, Qt::SolidLine); + g->setMinorPen(gridMinor, 1.0, Qt::DotLine); + } else if (auto* m = dynamic_cast(it)) { + if (m->lineStyle() != QwtPlotMarker::NoLine) m->setLinePen(zeroPen, 1.0); + } + } + plot->replot(); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ChartTheme.hpp b/src/app/panels/chart/ChartTheme.hpp new file mode 100644 index 0000000..81fb6e9 --- /dev/null +++ b/src/app/panels/chart/ChartTheme.hpp @@ -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 diff --git a/src/app/panels/chart/ColorBarWidget.cpp b/src/app/panels/chart/ColorBarWidget.cpp index 4e34289..a637760 100644 --- a/src/app/panels/chart/ColorBarWidget.cpp +++ b/src/app/panels/chart/ColorBarWidget.cpp @@ -2,6 +2,8 @@ #include #include +#include "Theme.hpp" + namespace geopro::app { static constexpr int kBarHeight = 18; // 色带高度(px) @@ -11,6 +13,9 @@ static constexpr int kFontSize = 9; // 刻度字号 ColorBarWidget::ColorBarWidget(QWidget* parent) : QWidget(parent) { setFixedHeight(36); + // 主题热切换:底色/边框/刻度字跟随主题重绘(色带本身=数据色,不变)。 + connect(&ThemeManager::instance(), &ThemeManager::changed, this, + qOverload<>(&QWidget::update)); } void ColorBarWidget::setColorScale(const core::ColorScale& scale) { @@ -23,7 +28,12 @@ void ColorBarWidget::paintEvent(QPaintEvent* /*event*/) { p.setRenderHint(QPainter::Antialiasing, false); const int W = width(); 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(); 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.setPen(QPen(QColor(120, 120, 120), 1)); + p.setPen(QPen(borderColor, 1)); p.drawRect(barLeft, barY, barW - 1, barH - 1); - // 边界值标签(深色文字,白底可见),在各分格边界下方。 + // 边界值标签(随主题:浅色深字 / 暗色浅字),在各分格边界下方。 QFont font = p.font(); font.setPixelSize(kFontSize); p.setFont(font); - p.setPen(QColor(60, 60, 60)); + p.setPen(textColor); QFontMetrics fm(font); const int tickY = barY + barH + 1; for (int i = 0; i <= nSeg; ++i) { diff --git a/src/app/panels/chart/ColorMapService.cpp b/src/app/panels/chart/ColorMapService.cpp index 475ddf9..e316468 100644 --- a/src/app/panels/chart/ColorMapService.cpp +++ b/src/app/panels/chart/ColorMapService.cpp @@ -19,10 +19,14 @@ ColorMapService::ColorMapService(const core::ColorScale& scale) if (raw.empty()) { minVal_ = 0.0; maxVal_ = 1.0; + dataMin_ = minVal_; + dataMax_ = maxVal_; return; } minVal_ = raw.front().first; maxVal_ = raw.back().first; + dataMin_ = minVal_; // 默认数据范围 = 断点范围;setDataRange 可改为数据 min/max(cauto) + dataMax_ = maxVal_; double range = maxVal_ - minVal_; normStops_.reserve(raw.size()); 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 range = maxVal_ - minVal_; + double range = dataMax_ - dataMin_; if (range <= 0.0) return 0.0; - return clamp01((v - minVal_) / range); + return clamp01((v - dataMin_) / range); } 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; double t = normalized(v); + // 非有限值(NaN/Inf,可能来自降级后端的脏数据或退化数据范围):回退首断点色, + // 避免下方 upper_bound 用 NaN 比较返回 end() 后解引用越界(崩溃)。 + if (!std::isfinite(t)) return normStops_.front().color; // 找到 t 落在哪两个 normStop 之间 if (t <= normStops_.front().pos) return normStops_.front().color; diff --git a/src/app/panels/chart/ColorMapService.hpp b/src/app/panels/chart/ColorMapService.hpp index a824794..f686851 100644 --- a/src/app/panels/chart/ColorMapService.hpp +++ b/src/app/panels/chart/ColorMapService.hpp @@ -11,7 +11,12 @@ class ColorMapService { public: 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; // 连续插值取色(散点用):按断点位置线性插值 RGB。 @@ -31,8 +36,10 @@ private: core::Rgba color; }; std::vector normStops_; - double minVal_; - double maxVal_; + double minVal_; // 断点值范围下界(色阶形状用) + double maxVal_; // 断点值范围上界 + double dataMin_; // 数据归一化下界(默认=minVal_,setDataRange 可改) + double dataMax_; // 数据归一化上界(默认=maxVal_) }; } // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index c41eaa4..f3c16c2 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -15,8 +15,10 @@ #include #include "PanelHeader.hpp" +#include "Theme.hpp" #include "panels/AnomalyTablePanel.hpp" #include "panels/DescriptionPanel.hpp" +#include "panels/chart/ChartTheme.hpp" #include "panels/chart/ColorBarWidget.hpp" #include "panels/chart/ColorMapService.hpp" #include "panels/chart/ContourPlotItem.hpp" @@ -103,16 +105,8 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { plot_->enableAxis(QwtPlot::xTop, false); plot_->enableAxis(QwtPlot::yLeft, true); - // 白底浅色(对齐原版 web 图表)。 - plot_->setCanvasBackground(QBrush(Qt::white)); - 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 按主题设置(ctor 末尾 + 主题热切换): + // 浅色=原版白底深灰字(1:1),暗色=深色画布避免刺眼白底。 // 交互:LivePanner 统一左键实时平移 + 滚轮缩放(消费滚轮事件,不冒泡触发滚动条)。 new LivePanner(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this); @@ -171,6 +165,11 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { showLabels_ = on; if (contourItem_) { contourItem_->setShowLabels(on); plot_->replot(); } }); + + // 主题配色:当前主题套一次 + 监听切换热更新。 + applyChartPlotTheme(plot_); + QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_, + [this]() { applyChartPlotTheme(plot_); }); } GridDataChartView::~GridDataChartView() { diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 5840f61..3748a86 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -1,5 +1,7 @@ #include "panels/chart/RawDataChartView.hpp" +#include "panels/chart/ChartTheme.hpp" #include "panels/chart/ColorBarWidget.hpp" +#include "panels/chart/ScatterHoverTip.hpp" #include "panels/chart/ScatterPlotItem.hpp" #include @@ -14,7 +16,12 @@ #include #include +#include +#include +#include + #include "panels/chart/LivePanner.hpp" +#include "Theme.hpp" namespace geopro::app { @@ -61,18 +68,10 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { plot_->enableAxis(QwtPlot::xBottom, false); plot_->enableAxis(QwtPlot::yLeft, true); - // 白底浅色(对齐原版 web 图表,与 App 暗色主题独立):画布白、轴文字深灰。 - plot_->setCanvasBackground(QBrush(Qt::white)); - 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 按主题统一设置(见 ctor 末尾 + 主题热切换)。 + // 浅色与原版 web 一致(白底深灰字);暗色改深色画布避免刺眼白底。 - // 横纵网格线(对齐原版浅灰网格)。 + // 横纵网格线(浅灰,暗色下由 applyChartPlotTheme 重着色)。 auto* grid = new QwtPlotGrid(); grid->setMajorPen(QColor(225, 225, 225), 1.0, Qt::SolidLine); grid->setMinorPen(QColor(240, 240, 240), 1.0, Qt::DotLine); @@ -98,6 +97,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { // 交互:LivePanner 统一处理左键实时平移 + 滚轮缩放(并消费滚轮事件,不冒泡触发滚动条)。 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); @@ -113,6 +117,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { colorBar_ = new ColorBarWidget(this); colorBar_->setObjectName(QStringLiteral("rawColorScaleBar")); lay->addWidget(colorBar_); + + // 主题配色:当前主题套一次 + 监听切换热更新(暗色给深底,浅色保持白底=原版)。 + applyChartPlotTheme(plot_); + QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_, + [this]() { applyChartPlotTheme(plot_); }); } RawDataChartView::~RawDataChartView() { @@ -127,6 +136,7 @@ QWidget* RawDataChartView::plotArea() const { void RawDataChartView::setData( const geopro::controller::DatasetDetailController::ChartData& d) { data_ = d; + if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖) if (d.scatterScale.empty()) return; @@ -134,6 +144,17 @@ void RawDataChartView::setData( delete colorSvc_; colorSvc_ = new ColorMapService(d.scatterScale); + // 散点颜色归一化对齐原版 Plotly(cmin/cmax 未设 → cauto=数据 min/max): + // 按 vlist 有限值的 min/max 设数据范围,使整段色阶铺满数据实际范围(而非压进 colorBar 全程一小段)。 + double vMin = std::numeric_limits::max(); + double vMax = std::numeric_limits::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)。 // 必须先 detach()(从 dict 移除)再 delete,否则 QwtPlot 析构时会 double-free。 if (scatterItem_) { diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index 0344849..befb465 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -11,6 +11,7 @@ namespace geopro::app { class ColorBarWidget; class ScatterPlotItem; +class ScatterHoverTip; // 原数据图表视图:工具条 + QwtPlot(x 轴顶部、Panner/Magnifier)+ 独立色阶条。 class RawDataChartView : public QWidget { @@ -34,6 +35,7 @@ private: // 使用 unique_ptr 管理生命周期;attach 后 QwtPlot 接管绘制,但我们仍持有指针 ColorMapService* colorSvc_ = nullptr; // heap,由 setData 重建 ScatterPlotItem* scatterItem_ = nullptr; + ScatterHoverTip* hoverTip_ = nullptr; // 散点 hover 提示(QObject,this 持有) }; } // namespace geopro::app diff --git a/src/app/panels/chart/ScatterHoverTip.cpp b/src/app/panels/chart/ScatterHoverTip.cpp new file mode 100644 index 0000000..f38280b --- /dev/null +++ b/src/app/panels/chart/ScatterHoverTip.cpp @@ -0,0 +1,72 @@ +#include "panels/chart/ScatterHoverTip.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace geopro::app { + +ScatterHoverTip::ScatterHoverTip(QwtPlot* plot, int xAxis, int yAxis, QObject* parent) + : QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) { + if (plot_ && plot_->canvas()) { + // 默认 widget 仅在按键按下时才收到 MouseMove;hover(无按键)需开启鼠标跟踪。 + plot_->canvas()->setMouseTracking(true); + plot_->canvas()->installEventFilter(this); + } +} + +bool ScatterHoverTip::eventFilter(QObject* obj, QEvent* ev) { + if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev); + + if (ev->type() == QEvent::Leave) { + QToolTip::hideText(); + return false; + } + if (ev->type() != QEvent::MouseMove) return QObject::eventFilter(obj, ev); + + auto* me = static_cast(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::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 diff --git a/src/app/panels/chart/ScatterHoverTip.hpp b/src/app/panels/chart/ScatterHoverTip.hpp new file mode 100644 index 0000000..7c83409 --- /dev/null +++ b/src/app/panels/chart/ScatterHoverTip.hpp @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include "model/Field.hpp" // core::ScatterField + +class QwtPlot; + +namespace geopro::app { + +// 格式化散点 hover 提示文本,对齐原版 Plotly hovertemplate: +// X: {x:.3f}
Y: {y:.3f}
值: {v:.3f} +// inline 纯函数:无 qwt 依赖,可独立单测。 +inline QString scatterHoverText(double x, double y, double v) { + return QStringLiteral("X: %1
Y: %2
值: %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 diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index c753e86..c21d7a6 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -1,4 +1,5 @@ #include "DatasetDetailController.hpp" +#include #include "repo/IAsyncDatasetRepository.hpp" #include "api/DatasetLoadHandles.hpp" namespace geopro::controller { @@ -12,8 +13,12 @@ DatasetDetailController::~DatasetDetailController() { 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())) { // 未注册策略 → 优雅降级 + qWarning("[detail] 未注册策略 ddCode=%s → 降级提示", qUtf8Printable(ddCode)); emit loadFailed(dsId, QStringLiteral("暂不支持该数据类型的预览")); return; } @@ -22,12 +27,13 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd chartLoad_ = load; emit loadStarted(dsId, LoadPhase::Chart); 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 句柄身份比对:丢弃迟到信号 chartLoad_.clear(); ChartData d; d.dsId = dsId; d.ddCode = ddCode; + d.dsName = dsName; d.scatter = parts.scatter; d.scatterScale = parts.scatterScale; emit chartReady(d); @@ -36,6 +42,7 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd [this, load, dsId](const QString& msg) { if (load != chartLoad_) return; chartLoad_.clear(); + qWarning("[detail] 原数据加载失败 id=%s: %s", qUtf8Printable(dsId), qUtf8Printable(msg)); emit loadFailed(dsId, msg); }); } @@ -62,6 +69,7 @@ void DatasetDetailController::loadGridData(const QString& dsId, const QString& d [this, load, dsId](const QString& msg) { if (load != gridLoad_) return; gridLoad_.clear(); + qWarning("[detail] 网格数据加载失败 id=%s: %s", qUtf8Printable(dsId), qUtf8Printable(msg)); emit loadFailed(dsId, msg); }); } diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index d103e1d..5487bf0 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -18,7 +18,7 @@ public: Q_ENUM(LoadPhase) struct ChartData { - QString dsId, ddCode; + QString dsId, ddCode, dsName; // dsName:页签标题用(空则回退 dsId) geopro::core::ScatterField scatter; geopro::core::ColorScale scatterScale; geopro::core::Grid grid{1, 1}; // Grid 无默认构造;以占位值初始化,openDataset 会覆盖 @@ -37,7 +37,7 @@ public: ChartStrategyRegistry& registry, QObject* parent = nullptr); ~DatasetDetailController() override; // 退出契约(spec §7):abort 在飞句柄,避免迟到信号打到已析构 this 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 loadGridData(const QString& dsId, const QString& ddCode); signals: diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index 8843727..9993240 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -1,5 +1,10 @@ #include "WorkbenchNavController.hpp" +#include +#include +#include +#include + #include "api/NavLoads.hpp" #include "api/NavRequest.hpp" #include "dto/NavDto.hpp" @@ -7,7 +12,14 @@ namespace geopro::controller { +namespace { +// 数据页树形:一次取全的大 pageSize(远超单 TM 实际 DS 数;超出会日志告警)+ 每页根节点数。 +constexpr int kFetchAllPageSize = 1000; +constexpr int kDataRootPageSize = 5; +} // namespace + using data::DsPage; +using data::DsRow; using data::DynamicForm; using data::ExceptionRow; using data::NavRequest; @@ -23,7 +35,7 @@ WorkbenchNavController::~WorkbenchNavController() { abortAll(); } bool WorkbenchNavController::anyInflight() const { if (startStepReq_ || structReq_ || selDataReq_ || selFileReq_ || selDetailReq_ || - moreDataReq_ || moreFilesReq_ || datasetReq_) + moreFilesReq_ || datasetReq_) return true; for (const auto& h : checkedInflight_) if (h) return true; @@ -44,7 +56,6 @@ void WorkbenchNavController::abortAll() { if (selDataReq_) selDataReq_->abort(); if (selFileReq_) selFileReq_->abort(); if (selDetailReq_) selDetailReq_->abort(); - if (moreDataReq_) moreDataReq_->abort(); if (moreFilesReq_) moreFilesReq_->abort(); if (datasetReq_) datasetReq_->abort(); for (const auto& h : checkedInflight_) @@ -56,6 +67,9 @@ void WorkbenchNavController::resetSelectionState() { tmExceptionCache_.clear(); currentParentId_.clear(); currentParentConfType_ = 0; + allDataRows_.clear(); + dataRootsShown_ = 0; + dataTotal_ = 0; } // ── start / switchWorkspace 依赖链:listWorkspaces → pageProjects → loadStructure ── @@ -204,8 +218,11 @@ void WorkbenchNavController::selectObject(const QString& objectId, int confType) const std::string pid = currentProjectId_; dataPageNo_ = 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; NavRequest* fReq = repo_.loadRowsAsync(pid, currentParentId_, confType, 1, filePageNo_); selFileReq_ = fReq; @@ -217,8 +234,12 @@ void WorkbenchNavController::selectObject(const QString& objectId, int confType) if (dReq != selDataReq_) return; selDataReq_.clear(); const auto page = qvariant_cast(v); - dataTotal_ = page.total; - emit datasetsLoaded(objectId, page.rows, page.total, false); + if (static_cast(page.rows.size()) < page.total) + qWarning() << "[nav] data/page 未取全:listCount=" << page.rows.size() + << " total=" << page.total << " → 树可能不完整(pageSize 不足)"; + allDataRows_ = page.rows; // 全量缓存;按根分页由 emitNextDataRootPage 切 + dataRootsShown_ = 0; + emitNextDataRootPage(false); // 首页(append=false) emitBusyIfChanged(); }); QObject::connect(dReq, &NavRequest::failed, this, [this, dReq](const QString& msg) { @@ -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 ids; + ids.reserve(allDataRows_.size()); + for (const auto& r : allDataRows_) ids.insert(r.id); + // 根索引(按原序)+ parentId→子索引表。 + std::vector rootIdx; + std::unordered_map> 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(rootIdx.size()); + dataTotal_ = rootCount; + + // 取本页根 [shown, end) 的整棵子树(DFS 收集索引),再按原序输出保稳定。 + const int end = std::min(dataRootsShown_ + kDataRootPageSize, rootCount); + std::unordered_set picked; + for (int k = dataRootsShown_; k < end; ++k) { + std::vector 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 out; + out.reserve(picked.size()); + for (std::size_t i = 0; i < allDataRows_.size(); ++i) + if (picked.count(i)) out.push_back(allDataRows_[i]); + + dataRootsShown_ = end; + emit datasetsLoaded(parent, out, rootCount, append); +} + +// ── loadMoreData:数据页树形——同步切下一页根(无请求)。loadMoreFiles:文件页服务端分页 ── void WorkbenchNavController::loadMoreData() { if (currentParentId_.empty()) return; - if (moreDataReq_) moreDataReq_->abort(); - NavRequest* req = - 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(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(); - }); + if (dataRootsShown_ >= dataTotal_) return; // 无更多根 + emitNextDataRootPage(true); } void WorkbenchNavController::loadMoreFiles() { diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index d2a9551..6869e03 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -61,6 +61,9 @@ private: void emitBusyIfChanged(); // 据「是否存在任一在飞句柄」去抖发 busyChanged bool anyInflight() const; // OR 所有在飞 QPointer / 集合 void assembleAndEmitExceptionTree(const QStringList& tmObjectIds); // 缓存命中后组装异常树 + // 数据页树形分页:从 allDataRows_(一次取全的整棵)按「第一层节点(根)」切下一页, + // 每页 kDataRootPageSize 个根 + 各自整棵子树;total=根总数。append=false 首页、true 加载更多。 + void emitNextDataRootPage(bool append); data::IAsyncProjectRepository& repo_; bool lastBusy_ = false; @@ -71,8 +74,7 @@ private: QPointer selDataReq_; // selectObject:data 行 QPointer selFileReq_; // selectObject:file 行 QPointer selDetailReq_; // selectObject:对象详情 - QPointer moreDataReq_; - QPointer moreFilesReq_; + QPointer moreFilesReq_; // loadMoreFiles(数据页改客户端按根分页,无在飞句柄) QPointer datasetReq_; std::vector> checkedInflight_; // setCheckedTms:未命中缓存的并发批 @@ -84,8 +86,10 @@ private: std::map> tmExceptionCache_; int dataPageNo_ = 0; int filePageNo_ = 0; - int dataTotal_ = 0; + int dataTotal_ = 0; // 数据页:根节点总数(树形分页单位) int fileTotal_ = 0; + std::vector allDataRows_; // 当前 TM 一次取全的所有数据行(树形按根客户端分页用) + int dataRootsShown_ = 0; // 已 emit 的根节点数(loadMoreData 续切) }; } // namespace geopro::controller diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index c822806..273a977 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -78,7 +78,7 @@ NavRequest* ApiProjectRepository::loadStructureAsync(const std::string& projectI NavRequest* ApiProjectRepository::loadRowsAsync(const std::string& projectId, 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") : QStringLiteral("/business/dsObject/data/page"); const QJsonObject body{ @@ -87,7 +87,7 @@ NavRequest* ApiProjectRepository::loadRowsAsync(const std::string& projectId, {QStringLiteral("structParentConfType"), parentConfType}, {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, {QStringLiteral("pageNo"), pageNo}, - {QStringLiteral("pageSize"), 5}}; + {QStringLiteral("pageSize"), pageSize}}; auto* call = api_.postJsonAsync(path, body); return new ApiNavRequest(call, [](const net::ApiResponse& r) { return QVariant::fromValue(dto::parseDsPage(r.data)); diff --git a/src/data/api/ApiProjectRepository.hpp b/src/data/api/ApiProjectRepository.hpp index ed391e8..28f10c9 100644 --- a/src/data/api/ApiProjectRepository.hpp +++ b/src/data/api/ApiProjectRepository.hpp @@ -20,7 +20,8 @@ public: NavRequest* listProjectTypesAsync() override; NavRequest* loadStructureAsync(const std::string& projectId) override; 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* loadDatasetFormAsync(const std::string& dsObjectId) override; NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) override; diff --git a/src/data/dto/DatasetChartDto.cpp b/src/data/dto/DatasetChartDto.cpp index 5cdf49c..5bc6ead 100644 --- a/src/data/dto/DatasetChartDto.cpp +++ b/src/data/dto/DatasetChartDto.cpp @@ -49,7 +49,9 @@ ColorScale parseColorBar(const QJsonObject& data) { if (pair.size() < 2) continue; const double val = num(pair.at(0)); const std::string rgba = pair.at(1).toString().toStdString(); - cs.addStop(val, parseColor(rgba, AlphaScale::Bit255)); + // API colorBar 颜色为混合格式:hex(#RRGGBB) 与 CSS rgba(r,g,b,a),其中 a 是 0–1 浮点 + // (实测 "rgba(0, 0, 170, 1)")。须用 Unit 标度(a*255),否则 a=1 被当字节 → alpha≈1 近透明。 + cs.addStop(val, parseColor(rgba, AlphaScale::Unit)); } return cs; } diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index 2f91e4a..6f38f88 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -123,6 +123,9 @@ std::vector parseDsRows(const QJsonArray& arr) { d.typeName = str(o, "name"); // 注意:name 字段=ds类型名 d.ddCode = str(o, "ddCode"); 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(); d.fileName = str(f, "name"); d.fileUrl = str(f, "url"); diff --git a/src/data/repo/IAsyncProjectRepository.hpp b/src/data/repo/IAsyncProjectRepository.hpp index 9f9ed26..d7d354a 100644 --- a/src/data/repo/IAsyncProjectRepository.hpp +++ b/src/data/repo/IAsyncProjectRepository.hpp @@ -18,8 +18,11 @@ public: const std::string& typeId, int pageNo, int pageSize) = 0; // ProjectListPage virtual NavRequest* listProjectTypesAsync() = 0; // std::vector virtual NavRequest* loadStructureAsync(const std::string& projectId) = 0; // std::vector + // pageSize 默认 5(文件页/数据页服务端分页);数据页树形需一次取全→控制器传大 pageSize 取整棵子树, + // 再按“第一层节点(根)”客户端分页(详见 WorkbenchNavController::emitNextDataRootPage)。 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* loadDatasetFormAsync(const std::string& dsObjectId) = 0; // DynamicForm virtual NavRequest* loadExceptionsByTmAsync(const std::string& tmObjectId) = 0; // std::vector diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index f1ecb2a..47261f5 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -5,8 +5,11 @@ namespace geopro::data { struct DsNode { std::string id, name, ddType; }; // data/page 或 file/page 的一条 ds。数据行只用 dsName/typeName/ddCode;文件行另含 file*。 +// parentId = 数据集树的父节点 id(取 sourceShowParentId,回退 parentId);空或不在本批=树根。 +// 原版数据列表是树:源「原始数据」为根,派生「反演/接地电阻」挂其下。 struct DsRow { std::string id, dsName, typeName, ddCode, createTime; + std::string parentId; std::string fileName, fileUrl; long long fileSize = 0; }; diff --git a/src/net/ApiCall.cpp b/src/net/ApiCall.cpp index a23d9ed..cbbe880 100644 --- a/src/net/ApiCall.cpp +++ b/src/net/ApiCall.cpp @@ -1,6 +1,7 @@ #include "ApiCall.hpp" #include +#include #include "ApiResponseParse.hpp" namespace geopro::net { @@ -15,7 +16,14 @@ void ApiCall::onFinished() { QNetworkReply* reply = reply_.data(); // 快照:意图明确 + 防御 reply_ 中途被置空 if (!reply) return; ApiResponse resp = buildResponse(reply); + const QString url = reply->url().toString(); // 先快照 URL(reply 即将 deleteLater) reply->deleteLater(); + // 错误判定口径同 spec §7:code != 200 || !rawError.isEmpty()。 + if (resp.rawError.isEmpty() && resp.code == 200) + qInfo("[net] %s http=%d code=%d", qUtf8Printable(url), resp.httpStatus, resp.code); + else + qWarning("[net] %s http=%d code=%d err=%s", qUtf8Printable(url), resp.httpStatus, resp.code, + qUtf8Printable(resp.rawError.isEmpty() ? resp.msg : resp.rawError)); emit finished(resp); deleteLater(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 087c5bc..25f26f3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -104,6 +104,8 @@ target_sources(geopro_tests PRIVATE app/test_colormap_service.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)。 find_package(Qt6 COMPONENTS Test REQUIRED) diff --git a/tests/app/test_colormap_service.cpp b/tests/app/test_colormap_service.cpp index fb41754..c5a7e3d 100644 --- a/tests/app/test_colormap_service.cpp +++ b/tests/app/test_colormap_service.cpp @@ -1,4 +1,5 @@ #include +#include #include "panels/chart/ColorMapService.hpp" using namespace geopro; @@ -36,6 +37,43 @@ TEST(ColorMapService, ColorAtContinuousAtExtremes) { EXPECT_EQ(cMax.b, 220); } +// 复刻原版 Plotly:散点颜色按**数据 min/max(cauto)**归一化,而色阶形状(断点位置)仍按 +// colorBar 断点值范围。数据范围窄于色阶范围时,应把整段光谱铺满数据范围(而非压进一小段)。 +TEST(ColorMapService, DataRangeDecouplesFromColorscaleShape) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{0, 0, 255, 255}); // blue pos 0.0 + cs.addStop(500.0, core::Rgba{0, 255, 0, 255}); // green pos 0.5 + cs.addStop(1000.0, core::Rgba{255, 0, 0, 255}); // red pos 1.0 + app::ColorMapService svc(cs); + + // 默认数据范围 = 断点范围 [0,1000]:值 250 → 归一化 0.25 → 蓝绿之间(非纯绿)。 + EXPECT_NEAR(svc.normalized(250.0), 0.25, 1e-9); + + // 收窄数据范围到 [0,500](模拟 cauto 取数据 min/max): + svc.setDataRange(0.0, 500.0); + EXPECT_NEAR(svc.normalized(250.0), 0.5, 1e-9); + // 值 250 → 归一化 0.5 → 落在色阶位置 0.5 = 纯绿。 + auto mid = svc.colorAtContinuous(250.0); + EXPECT_EQ(mid.r, 0); EXPECT_EQ(mid.g, 255); EXPECT_EQ(mid.b, 0); + // 数据范围两端铺到色阶两端:0→蓝、500→红。 + auto lo = svc.colorAtContinuous(0.0); + EXPECT_EQ(lo.b, 255); EXPECT_EQ(lo.r, 0); + auto hi = svc.colorAtContinuous(500.0); + EXPECT_EQ(hi.r, 255); EXPECT_EQ(hi.b, 0); +} + +// 脏数据健壮性:NaN 值取色不得崩溃(曾因 upper_bound 用 NaN 比较返回 end() 后解引用越界)。 +TEST(ColorMapService, ColorAtContinuousNaNSafe) { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{11, 22, 33, 255}); + cs.addStop(100.0, core::Rgba{200, 210, 220, 255}); + app::ColorMapService svc(cs); + auto c = svc.colorAtContinuous(std::nan("")); // 不崩 → 回退首断点色 + EXPECT_EQ(c.r, 11); + EXPECT_EQ(c.g, 22); + EXPECT_EQ(c.b, 33); +} + TEST(ColorMapService, ScaleRefReturnsOriginal) { core::ColorScale cs; cs.addStop(0.0, core::Rgba{0, 0, 0, 255}); diff --git a/tests/app/test_scatter_hover.cpp b/tests/app/test_scatter_hover.cpp new file mode 100644 index 0000000..e640929 --- /dev/null +++ b/tests/app/test_scatter_hover.cpp @@ -0,0 +1,17 @@ +#include +#include "panels/chart/ScatterHoverTip.hpp" + +using geopro::app::scatterHoverText; + +// 对齐原版 Plotly hovertemplate: +// X: %{x:.3f}
Y: %{y:.3f}
值: %{marker.color:.3f} +TEST(ScatterHoverTip, FormatsXYValueWith3Decimals) { + const QString t = scatterHoverText(12.3456, 24.0, 60.77); + EXPECT_EQ(t, QStringLiteral("X: 12.346
Y: 24.000
值: 60.770")); +} + +TEST(ScatterHoverTip, RoundsAndPadsToFixed3) { + // 负值、整数、需补零各覆盖一次 + EXPECT_EQ(scatterHoverText(-1.0, 0.5, 1.0), + QStringLiteral("X: -1.000
Y: 0.500
值: 1.000")); +} diff --git a/tests/controller/test_workbench_nav_controller.cpp b/tests/controller/test_workbench_nav_controller.cpp index 6a60b2e..a74ba2f 100644 --- a/tests/controller/test_workbench_nav_controller.cpp +++ b/tests/controller/test_workbench_nav_controller.cpp @@ -42,7 +42,7 @@ struct StubAsyncRepo : data::IAsyncProjectRepository { return lastStructure = new StubNavRequest; } data::NavRequest* loadRowsAsync(const std::string&, const std::string&, int, int classifyType, - int) override { + int, int) override { auto* r = new StubNavRequest; if (classifyType == 1) lastFile = r; @@ -208,6 +208,48 @@ TEST(WorkbenchNavController, SelectObjectOneFailureEmitsPartialResults) { 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 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(rows.size())}); +} +} // namespace + +// 数据页树形分页:按「第一层节点(根)」分页(每页 5 根),total=根总数,子树随根整棵带出; +// loadMoreData 同步续切下一页根。 +TEST(WorkbenchNavController, DataPaginatesByRootNodeNotFlatCount) { + qRegisterMetaType>(); + 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>(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>(dsSpy.at(1).at(1)); + EXPECT_EQ(rows1.size(), 1u); // 第二页第 6 个根 r6 + EXPECT_EQ(rows1[0].id, "r6"); + EXPECT_EQ(dsSpy.at(1).at(2).toInt(), 6); + EXPECT_TRUE(dsSpy.at(1).at(3).toBool()); // append=true + + c.loadMoreData(); // 已无更多根 → 不再 emit + EXPECT_EQ(dsSpy.count(), 2); +} + // 失败路径:start 首级失败 → loadFailed + busy 复位。 TEST(WorkbenchNavController, StartWorkspacesFailureEmitsLoadFailed) { StubAsyncRepo repo; diff --git a/tests/data/test_dataset_chart_dto.cpp b/tests/data/test_dataset_chart_dto.cpp index 876da41..0c36ac1 100644 --- a/tests/data/test_dataset_chart_dto.cpp +++ b/tests/data/test_dataset_chart_dto.cpp @@ -17,15 +17,31 @@ TEST(DatasetChartDto, ParsesInversionGrid) { EXPECT_DOUBLE_EQ(g.vmax, 6.0); } TEST(DatasetChartDto, ParsesColorBar) { - // Use "json" delimiter to avoid raw-string termination by ")" inside rgba() - const char* colorBarJson = "{\"properties\":{\"colorBar\":[[\"10\",\"rgba(0,0,255,255)\"],[\"20\",\"rgba(255,0,0,255)\"]]}}"; + // Real API colorBar format: MIXED hex (#RRGGBB) and CSS rgba() with alpha in 0–1 scale + // (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 cs = parseColorBar(d); - auto stops = cs.stopValues(); - ASSERT_EQ(stops.size(), 2u); - EXPECT_DOUBLE_EQ(stops[0], 10.0); - auto c = cs.colorAt(12.0); // [10,20) -> blue - EXPECT_GT(c.b, c.r); + auto stops = cs.stops(); + ASSERT_EQ(stops.size(), 4u); + EXPECT_DOUBLE_EQ(stops[0].first, 0.0); + EXPECT_DOUBLE_EQ(stops[1].first, 1.51); + // 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) { auto arr = QJsonDocument::fromJson( diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index eb4a42f..b42f9cf 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -176,6 +176,24 @@ TEST(NavDto, ParseDsRowsDataAndFile) { 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) { const auto v = dto::parseProjectList(arrOf(R"([ {"id":"p1","projectName":"演示","projectCode":"001","status":2,