From 8ac5f33c7f2e4955c3827061b71d59168d2accdd Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 19:27:32 +0800 Subject: [PATCH 01/18] =?UTF-8?q?docs(spec):=20=E5=AF=B9=E8=B1=A1=E5=8D=95?= =?UTF-8?q?=E5=87=BB/=E5=8B=BE=E9=80=89=E9=A9=B1=E5=8A=A8=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=88=97=E8=A1=A8=C2=B7=E5=BC=82=E5=B8=B8=C2=B7?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=20=E4=B8=89=E9=9D=A2=E6=9D=BF=EF=BC=88?= =?UTF-8?q?=E6=8E=A5=E7=9C=9F=E5=AE=9EAPI=EF=BC=89=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-06-10-object-selection-panels-design.md | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-10-object-selection-panels-design.md diff --git a/docs/superpowers/specs/2026-06-10-object-selection-panels-design.md b/docs/superpowers/specs/2026-06-10-object-selection-panels-design.md new file mode 100644 index 0000000..ae8d3d0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-object-selection-panels-design.md @@ -0,0 +1,258 @@ +# 对象单击/勾选 驱动 数据列表·异常·属性 三面板(接真实 API)— 设计文档 + +- 日期:2026-06-10 +- 分支:main(建议拉子分支 `feat/object-selection-panels`) +- 状态:已与需求方确认范围与关键决策,待 spec 评审 +- 上游:本轮是 `docs/superpowers/specs/2026-06-09-real-api-navigation-design.md` §12.1「下一轮」的一个**聚焦子集**——只覆盖左下「数据集」列表与右侧「异常 / 对象属性 / 数据集属性」三面板,**不含**中央 2D/3D 与「数据详情」的 VTK 真实剖面渲染。 + +--- + +## 1. 背景与目标 + +`real-api-navigation` 轮已把**顶层导航壳**(工作空间 / 项目 / 对象树)接到真实后端,并把渲染与导航解耦: +- 对象树 `ObjectTreePanel` 已显示真实结构(项目根 → GS → TM,叶子=TM 可勾选)。 +- 单击 TM(`tmClicked`)→ `WorkbenchNavController::selectTm` → 左下「数据集」列出该 TM 的 DS(数据/文件两页签,分页)。 +- `tmCheckToggled` 是**前瞻钩子,本轮前无消费者**;`loadDataset`、右上「异常」`AnomalyListPanel`(本地 `core::Anomaly`)、右上「对象属性」与右下「数据集属性」面板均为**占位**。 + +本轮补齐三件被推迟的交互,全部接**真实业务 API**(不回退本地样本): + +1. **单击对象(GS 或 TM)** → ① 左下数据列表显示其下所有 DS(GS=该工区全部 DS;TM=该测线 DS);② 右上「对象属性」显示该对象动态表单详情。当前单击行高亮(单选)。 +2. **勾选 / 反勾选 GS/TM** → 右上「异常」列表显示**所有被勾选 TM 叶子**下异常的聚合并集。 +3. **单击数据列表某 DS** → 右下「数据集属性」显示该 DS 动态表单详情。 + +**非目标(仍占位,留后续轮)**:中央 2D/3D 与「数据详情」的 dd/ert 真实剖面/反演渲染;异步仓储;文件下载动作。`render/*` 与 `LocalSampleRepository` 代码保留不删。 + +## 2. 关键决策(需求方已拍板) + +| # | 决策点 | 结论 | +|---|---|---| +| D1 | 三面板数据来源 | **接真实业务 API**(新增仓储方法 + DTO 映射),不复用本地样本 | +| D2 | 勾选 GS 行为 | **联动**:勾 GS 自动勾其下所有 TM;子 TM 可单独取消(GS 转半选态);取消联动取消其异常 | +| D3 | 「对象属性」跟随谁 | **跟随当前单击高亮的行**(与勾选集相互独立) | +| D4 | 属性展示深度 | **完整动态表单**(分组键值);字段显示名**取自 API**(`FormItemVO.fieldName`),无需推断 | + +**D2 的连锁简化**:因勾选源只数 **TM 叶子**(GS 仅作批量开关,且无 GS 级异常接口), +- 「GS→后代 TM」的展开由 Qt 原生三态复选框承担(`ItemIsAutoTristate` 级联),控制器只读叶子集; +- 聚合 = 被勾选 TM 叶子异常的并集(功能本身固有); +- **不存在去重问题**(叶子 id 唯一)。 + +## 3. 接口映射 + +网关与会话沿用现有 `ApiClient`(`http://tenant.geomative.cn/pop-api`,token 已注入)。成功判定 `code==200`。 + +| 能力 | 方法 | 路径 | 请求 / 返回要点 | +|---|---|---|---| +| 数据列表(GS/TM 级) | POST | `/business/dsObject/data/page` | body `{projectId, structParentId, structParentConfType, classifyTypeList:[3], pageNo, pageSize:5}`;`structParentConfType`:**TM=2(已固化),GS=1(待联调验证)**。返回沿用 `DsPage` | +| 文件列表(GS/TM 级) | POST | `/business/dsObject/file/page` | body 同上 `classifyTypeList:[1]` | +| 对象详情(GS) | GET | `/business/gsObject/getGsObjectDetail/{gsId}` | `data: DynamicFormVO` | +| 对象详情(TM) | GET | `/business/tmObject/getDetail/{tmObjectId}` | `data: DynamicFormVO` | +| 数据集详情 | GET | `/business/dsObject/dynamicForm/{dsObjectId}` | `data: DynamicFormVO`(与对象详情**同结构**,复用渲染) | +| 异常(按 TM) | GET | `/business/exception/queryExceptionByTmObjectId/{tmObjectId}` | `data: [ExceptionVO]`。**无 GS/项目级异常接口** → GS 勾选靠级联到 TM 叶子解决 | + +**`DynamicFormVO` 形状**:`{ name, confCode, description, formList:[FormVO], properties:object, ... }`。 +- `FormVO{ groupName, groupSort, values:[FormItemVO] }`;`FormItemVO{ fieldName(显示名), fieldCode, displaySort, ... }`。 +- `properties` 为 `{ fieldCode: value }` 键值对(值容器;**待联调确认确实按 fieldCode 索引**)。 +- 渲染规则:按 `groupSort` 排组、组内按 `displaySort` 排字段,逐项显示 `fieldName : properties[fieldCode]`。 + +**`ExceptionVO` 关键字段**:`id, exceptionName, exceptionTypeName, exceptionMarkTypeName, createTime, remark, location, latitudeLongitude, geographicalCoordinates, parentId, parentConfType`。 + +## 4. 架构分层 + +沿用既有四层,依赖单向向下,UI 不直接碰 `ApiClient`: + +``` +UI(app) ObjectTreePanel(三态勾选) DynamicFormView(新) AnomalyListPanel(+异常行渲染) + │ 信号(单击/勾选) ▲ 槽(模型) +controller WorkbenchNavController ← selectObject / setCheckedTms / selectDataset + │ IProjectRepository(同步契约) +data ApiProjectRepository + NavDto(parseDynamicForm/parseExceptions) + │ ApiClient +net ApiClient / AuthService(复用,不改) + +模型(RepoTypes.hpp):+ DynamicForm / DynamicFormGroup / DynamicFormField / ExceptionRow +``` + +## 5. 数据 / 模型层 + +### 5.1 模型(`src/data/repo/RepoTypes.hpp`,纯结构,无 Qt/VTK) + +```cpp +// 动态表单(GS/TM/DS 详情统一模型)。 +struct DynamicFormField { std::string name, value; }; // 显示名 + 值(已合并) +struct DynamicFormGroup { std::string name; std::vector fields; }; +struct DynamicForm { + std::string name; // 对象/数据集名称(表头) + std::vector groups; // 已按 sort 排好序 +}; + +// 异常列表行(右上「异常」面板用;列表展示,本轮不联动 VTK)。 +struct ExceptionRow { + std::string id, name, typeName, markTypeName, createTime, remark; +}; +``` + +### 5.2 DTO(`src/data/dto/NavDto.{hpp,cpp}`,纯函数,可单测) + +```cpp +// DynamicFormVO 对象 → DynamicForm:合并 formList(字段定义) + properties(值)。 +// - 组按 groupSort、字段按 displaySort 排序;值取 properties[fieldCode](缺失→空串)。 +// - properties 中存在但 formList 未定义的键:本轮忽略(以表单定义为准;待联调复核)。 +DynamicForm parseDynamicForm(const QJsonObject& data); + +// ExceptionVO 数组 → [ExceptionRow](字段直映射;name=exceptionName、typeName=exceptionTypeName、 +// markTypeName=exceptionMarkTypeName)。 +std::vector parseExceptions(const QJsonArray& arr); +``` + +> 不需要 `collectDescendantTmIds`:GS→TM 展开由 UI 三态复选框级联承担(见 §7.1)。 + +### 5.3 数据访问层 `IProjectRepository` 扩展(`src/data/repo/IProjectRepository.hpp`) + +```cpp +// 既有 loadTmRows 泛化:把硬编码 structParentConfType=2 改为入参 parentConfType(支持 GS=1)。 +// classifyType 3=数据 1=文件;pageNo 从 1 起,pageSize 固定 5。 +// (保留旧名或重命名为 loadRows;调用点同步更新。) +virtual RepoResult loadRows(const std::string& projectId, const std::string& parentId, + int parentConfType, int classifyType, int pageNo) = 0; + +// 对象详情:按 confType 选端点(1=GS→getGsObjectDetail,2=TM→tmObject/getDetail)。 +virtual RepoResult loadObjectDetail(const std::string& objectId, int confType) = 0; + +// 数据集详情:dsObject/dynamicForm/{dsObjectId}。 +virtual RepoResult loadDatasetForm(const std::string& dsObjectId) = 0; + +// 单 TM 异常列表:exception/queryExceptionByTmObjectId/{tmObjectId}。 +virtual RepoResult> loadExceptionsByTm(const std::string& tmObjectId) = 0; +``` + +`ApiProjectRepository` 按 §3 路径实现;id 进 URL 前 `QUrl::toPercentEncoding`;错误归一为 `RepoResult{ok=false, error=msg}`。 + +## 6. 逻辑层 `WorkbenchNavController` + +新增状态与契约(不碰 widget): + +```cpp +public slots: + // 泛化原 selectTm:单击对象(GS/TM)→ 加载其 data/file 首页(loadRows 按 confType) + // + 加载对象详情 → emit objectDetailLoaded。confType: 1=GS 2=TM。 + void selectObject(const QString& objectId, int confType); + // 勾选集变化(已是 TM 叶子集合,由面板算好并合并发射):逐 TM 查异常→并集→emit exceptionsLoaded。 + void setCheckedTms(const QStringList& tmObjectIds); + // 单击数据集 → 加载 dynamicForm → emit datasetDetailLoaded。 + void selectDataset(const QString& dsObjectId); + void loadMoreData(); void loadMoreFiles(); // 适配泛化 parent(见下) + +signals: + void objectDetailLoaded(const QString& title, const geopro::data::DynamicForm& form); + void exceptionsLoaded(const std::vector& rows, int tmCount); + void datasetDetailLoaded(const geopro::data::DynamicForm& form); + +private: + std::string currentParentId_; int currentParentConfType_ = 0; // 加载更多用(替代 currentTmId_) + std::map> tmExceptionCache_; // per-TM 异常缓存 +``` + +- `selectObject`:置 `currentParentId_/ConfType_`、`dataPageNo_=filePageNo_=1`,`loadRows` 拉数据+文件首页(emit `datasetsLoaded/filesLoaded`),再 `loadObjectDetail` emit `objectDetailLoaded`。 +- `setCheckedTms`:对集合中每个 TM,命中 `tmExceptionCache_` 则复用、否则 `loadExceptionsByTm` 并入缓存;合并为并集(顺序:按勾选/树序稳定),emit `exceptionsLoaded(rows, tmCount=集合大小)`。空集合 → emit 空列表(面板回占位)。 +- `selectDataset`:`loadDatasetForm` → emit `datasetDetailLoaded`。 +- `loadMoreData/Files`:用 `currentParentId_/ConfType_` 续页(原依赖 `currentTmId_`,改为泛化 parent)。 +- 切项目/工作空间:清 `tmExceptionCache_`、`currentParentId_`,并(经既有 `structureLoaded` 接线)清空三面板。 +- 沿用 `BusyGuard` 重入保护与 `busyChanged`(同步阻塞 + WaitCursor)。 + +## 7. UI 层 `app` + +### 7.1 `ObjectTreePanel`(三态勾选 + GS 可单击) + +- **GS 节点**:设 `Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate`,存 `confType=1` 角色,可单击。 +- **TM 叶子**:`Qt::ItemIsUserCheckable`,存 `confType=2` + `tmObjectId`(沿用现有角色)。 +- **级联**:Qt 原生三态——勾 GS 自动勾全部子 TM;子 TM 单独取消 → GS 转 `PartiallyChecked`(满足 D2)。 +- **信号改造**: + - `objectClicked(QString objectId, int confType)`(取代 `tmClicked`)——单击行(GS 或 TM)。 + - `checkedTmsChanged(QStringList tmObjectIds)`(取代 `tmCheckToggled`)——发射**当前全部被勾选的 TM 叶子 id**。 +- **合并发射**:`itemChanged` 在 GS 级联时会对每个子项各触发一次。用 `QTimer::singleShot(0, ...)` 合并:标脏 → 事件循环回合一次性遍历树收集所有勾选叶子 → 发一次 `checkedTmsChanged`(避免一次点击触发 N 次重算/N 组请求)。 +- 单击高亮当前行(`QTreeWidget` 默认单选即可)。 + +### 7.2 `DynamicFormView`(新增,共享键值渲染器) + +- `src/app/panels/DynamicFormView.{hpp,cpp}`:`QWidget`,方法 `void setForm(const DynamicForm&)` / `void showMessage(const QString&)`(空/错占位)。 +- 布局:`QScrollArea` 内纵向堆叠——每组一个组标题 + `QFormLayout`(左 `字段名`、右 `值`,值支持换行)。无数据 → 居中淡色占位。 +- 主题化:颜色取全局令牌(`text/primary`、`text/secondary` 等),随 `ThemeManager::changed` 重绘。 +- **「对象属性」与「数据集属性」两面板各持一个 `DynamicFormView` 实例**(取代现有两个占位 `QLabel`)。 + +### 7.3 `AnomalyListPanel`(新增异常行渲染) + +- 新增 `void populateExceptionList(QListWidget*, const std::vector&)`:复用现有卡片视觉 + (左色条可用中性/警示色、标题=`name`、第二行=`typeName · markTypeName · createTime`), + **本轮去掉「眼睛/显隐」**(无 VTK 详情可联动)。 +- 既有 `populateAnomalyList(vector)` + `AnomalyCardDelegate`(带眼睛、联动 VTK)**保留不动**,留给未来「数据详情真实渲染」轮;本轮不调用。 +- 右上「异常」Tab 数量徽标:填 `rows.size()`,空则隐藏。 + +### 7.4 `main.cpp` 接线(增量) + +- `objectTree.objectClicked → nav.selectObject(id, confType)`。 +- `nav.objectDetailLoaded → 对象属性 DynamicFormView.setForm(form)`(并可用 title 更新 Tab/表头)。 +- `objectTree.checkedTmsChanged → nav.setCheckedTms(tmIds)`。 +- `nav.exceptionsLoaded → AnomalyListPanel.populateExceptionList + 徽标`。 +- `datasetList.itemClicked`(非「加载更多」行)→ `nav.selectDataset(dsId)`;移除当前写入 `propLabel` 的占位文案。 +- `nav.datasetDetailLoaded → 数据集属性 DynamicFormView.setForm(form)`。 +- `structureLoaded`(切项目/空间):清空对象属性、数据集属性两 `DynamicFormView` 与异常列表/徽标(回占位)。 +- 既有 `selectTm` 接线点替换为 `selectObject`;`loadMore*` 接线不变(控制器内部改用泛化 parent)。 + +## 8. 交互时序 + +``` +单击对象(GS/TM): ObjectTreePanel.objectClicked(id, confType) + → nav.selectObject: + loadRows(pid,id,confType,3,1)+loadRows(...,1,1) → datasetsLoaded/filesLoaded → 左下数据/文件页签 + loadObjectDetail(id,confType) → objectDetailLoaded → 右上「对象属性」DynamicFormView + +勾选/取消(GS 三态级联到 TM 叶子): ObjectTreePanel 合并 → checkedTmsChanged([tm...]) + → nav.setCheckedTms: 逐 TM(缓存)查异常 → 并集 → exceptionsLoaded(rows, n) → 右上「异常」列表+徽标 + (取消某 TM/GS → 叶子集变小 → 重算并集 → 该 TM 异常自动消失) + +单击数据集: datasetList.itemClicked(dsId) + → nav.selectDataset: loadDatasetForm(dsId) → datasetDetailLoaded → 右下「数据集属性」DynamicFormView + +切项目/空间: structureLoaded → 清空三面板 + 勾选集 + 异常缓存 +``` + +## 9. 边界与错误处理 + +- **GS confType=1 待联调**:若 `data/page` 在 `structParentConfType=1` 下**不返回**GS 下全部 DS(不递归), + 回退方案:对 GS 的直接子 TM 逐个 `data/page` 合并展示(分页退化为"加载全部"或按 TM 分段)。实现前用 live 接口验证一次再定。 +- **properties 索引键待联调**:`parseDynamicForm` 按 `fieldCode` 取值;若实测 `properties` 用其它键(如 `confFieldId`)索引,调整映射键即可(隔离在 DTO 一处)。 +- **异常聚合性能**:同步多请求 + WaitCursor;`tmExceptionCache_` 避免增量勾选时重复请求;切项目清缓存。 +- **空 / 错状态**:任一面板无数据 → 居中「暂无…」占位;请求失败 → `loadFailed(stage,msg)` 状态栏提示 + 面板错误占位;**不回退本地样本**。 +- **输入边界**:id 为空短路不发请求;URL 中 id 百分号编码。 +- **重入**:沿用 `busy_` 保护(快速连点不污染状态)。 + +## 10. 测试策略 + +聚焦纯逻辑单测(GoogleTest + CTest),沿用 `tests/data/test_nav_dto.cpp`: +- `parseDynamicForm`:分组+值合并、`groupSort/displaySort` 排序、`properties` 缺失值→空串、空 `formList`、`name` 透传。 +- `parseExceptions`:字段映射(`exceptionName→name` 等)、空数组、缺字段容错。 +- UI 三态级联 / 控制器聚合:依赖 Qt/live,靠手动联调验证(无桩)。 + +## 11. 文件清单 + +**改造** +- `src/data/repo/RepoTypes.hpp` — `+ DynamicForm/DynamicFormGroup/DynamicFormField/ExceptionRow` +- `src/data/dto/NavDto.{hpp,cpp}` — `+ parseDynamicForm / parseExceptions` +- `src/data/repo/IProjectRepository.hpp` — `loadTmRows` 泛化为 `loadRows`;`+ loadObjectDetail / loadDatasetForm / loadExceptionsByTm` +- `src/data/api/ApiProjectRepository.{hpp,cpp}` — 实现上述新方法 +- `src/controller/WorkbenchNavController.{hpp,cpp}` — `+ selectObject / setCheckedTms / selectDataset / 三新信号 / per-TM 缓存`;`selectTm` 退役、`loadMore*` 改泛化 parent +- `src/app/panels/ObjectTreePanel.{hpp,cpp}` — GS 三态可勾选+可单击;信号改 `objectClicked / checkedTmsChanged`(合并发射) +- `src/app/panels/AnomalyListPanel.{hpp,cpp}` — `+ populateExceptionList`(旧 `core::Anomaly` 路径保留) +- `src/app/main.cpp` — 三面板接线(见 §7.4);移除 DS 单击占位文案 +- `tests/data/test_nav_dto.cpp` — `+ parseDynamicForm / parseExceptions` 用例 + +**新增** +- `src/app/panels/DynamicFormView.{hpp,cpp}` — 共享键值渲染器(对象属性 + 数据集属性) + +**保留不删**:`LocalSampleRepository`、`render/*`、`AnomalyCardDelegate`/`populateAnomalyList`、中央/详情渲染代码(留未来 dd/ert 渲染轮)。 + +## 12. 未决 / 验证点(实现前用 live 接口确认) + +1. GS 级 `data/page` 的 `structParentConfType` 取值与是否递归返回全部 DS(§9 回退)。 +2. `DynamicFormVO.properties` 的索引键(`fieldCode` 假设)。 +3. `exception/queryExceptionByTmObjectId` 实际字段名与 `ExceptionVO` 是否一致(标记类型字段命名)。 -- 2.40.1 From c3cedd8c2aabec104be12b00cd330e7dcc2a42da Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 19:38:29 +0800 Subject: [PATCH 02/18] =?UTF-8?q?docs(spec):=20=E5=BC=82=E5=B8=B8=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E7=BA=B3=E5=85=A5=E5=BC=82=E5=B8=B8=E4=BD=93=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=A0=91=EF=BC=88=E5=AF=B9=E8=B1=A1=E2=86=92=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E4=BD=93=E2=86=92=E5=BC=82=E5=B8=B8+=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E5=BC=82=E5=B8=B8=EF=BC=89=EF=BC=8C=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=B1=95=E5=BC=80=E5=81=9A/=E7=9C=BC=E7=9D=9B=E6=8E=A8?= =?UTF-8?q?=E8=BF=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...26-06-10-object-selection-panels-design.md | 260 +++++++++--------- 1 file changed, 132 insertions(+), 128 deletions(-) diff --git a/docs/superpowers/specs/2026-06-10-object-selection-panels-design.md b/docs/superpowers/specs/2026-06-10-object-selection-panels-design.md index ae8d3d0..deadd41 100644 --- a/docs/superpowers/specs/2026-06-10-object-selection-panels-design.md +++ b/docs/superpowers/specs/2026-06-10-object-selection-panels-design.md @@ -1,26 +1,24 @@ -# 对象单击/勾选 驱动 数据列表·异常·属性 三面板(接真实 API)— 设计文档 +# 对象单击/勾选 驱动 数据列表·异常(含异常体)·属性 面板(接真实 API)— 设计文档 - 日期:2026-06-10 - 分支:main(建议拉子分支 `feat/object-selection-panels`) - 状态:已与需求方确认范围与关键决策,待 spec 评审 -- 上游:本轮是 `docs/superpowers/specs/2026-06-09-real-api-navigation-design.md` §12.1「下一轮」的一个**聚焦子集**——只覆盖左下「数据集」列表与右侧「异常 / 对象属性 / 数据集属性」三面板,**不含**中央 2D/3D 与「数据详情」的 VTK 真实剖面渲染。 +- 上游:本轮是 `docs/superpowers/specs/2026-06-09-real-api-navigation-design.md` §12.1「下一轮」的**聚焦子集**——只覆盖左下「数据集」列表与右侧「异常 / 对象属性 / 数据集属性」面板,**不含**中央 2D/3D 与「数据详情」的 VTK 真实剖面渲染。 +- 参考:`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`「视图定义」表;`docs/apis/business_OpenAPI.json`。 --- ## 1. 背景与目标 -`real-api-navigation` 轮已把**顶层导航壳**(工作空间 / 项目 / 对象树)接到真实后端,并把渲染与导航解耦: -- 对象树 `ObjectTreePanel` 已显示真实结构(项目根 → GS → TM,叶子=TM 可勾选)。 -- 单击 TM(`tmClicked`)→ `WorkbenchNavController::selectTm` → 左下「数据集」列出该 TM 的 DS(数据/文件两页签,分页)。 -- `tmCheckToggled` 是**前瞻钩子,本轮前无消费者**;`loadDataset`、右上「异常」`AnomalyListPanel`(本地 `core::Anomaly`)、右上「对象属性」与右下「数据集属性」面板均为**占位**。 +`real-api-navigation` 轮已把顶层导航壳(工作空间/项目/对象树)接到真实后端并把渲染与导航解耦:对象树已显示真实结构(项目根→GS→TM,叶子=TM 可勾选),单击 TM 已能列出其 DS(数据/文件分页)。但 `tmCheckToggled`、右上「异常」(本地 `core::Anomaly` 占位)、「对象属性」「数据集属性」面板均为**占位**。 -本轮补齐三件被推迟的交互,全部接**真实业务 API**(不回退本地样本): +本轮补齐三件交互,全部接**真实业务 API**(不回退本地样本): 1. **单击对象(GS 或 TM)** → ① 左下数据列表显示其下所有 DS(GS=该工区全部 DS;TM=该测线 DS);② 右上「对象属性」显示该对象动态表单详情。当前单击行高亮(单选)。 -2. **勾选 / 反勾选 GS/TM** → 右上「异常」列表显示**所有被勾选 TM 叶子**下异常的聚合并集。 +2. **勾选 / 反勾选 GS/TM** → 右上「对象异常」面板显示**所有勾选对象**下的「**异常 + 异常体**」树(见 §2 决策、§7.3)。 3. **单击数据列表某 DS** → 右下「数据集属性」显示该 DS 动态表单详情。 -**非目标(仍占位,留后续轮)**:中央 2D/3D 与「数据详情」的 dd/ert 真实剖面/反演渲染;异步仓储;文件下载动作。`render/*` 与 `LocalSampleRepository` 代码保留不删。 +**非目标(仍占位/推迟)**:中央 2D/3D 与「数据详情」dd/ert 真实剖面渲染;异常项「眼睛(显隐)」(联动 VTK,与 VTK 同轮接);异常体的「拖拽合并/编辑」(写操作);异步仓储;文件下载动作。`render/*`、`LocalSampleRepository`、旧 `core::Anomaly` 渲染路径**保留不删**。 ## 2. 关键决策(需求方已拍板) @@ -29,47 +27,42 @@ | D1 | 三面板数据来源 | **接真实业务 API**(新增仓储方法 + DTO 映射),不复用本地样本 | | D2 | 勾选 GS 行为 | **联动**:勾 GS 自动勾其下所有 TM;子 TM 可单独取消(GS 转半选态);取消联动取消其异常 | | D3 | 「对象属性」跟随谁 | **跟随当前单击高亮的行**(与勾选集相互独立) | -| D4 | 属性展示深度 | **完整动态表单**(分组键值);字段显示名**取自 API**(`FormItemVO.fieldName`),无需推断 | +| D4 | 属性展示深度 | **完整动态表单**(分组键值);字段显示名取自 API(`FormItemVO.fieldName`) | +| D5 | 异常面板含异常体 | **只读树**:「对象 → 异常体(ID/类型) → 异常(ID/类型)」+ 未合并的「独立异常」单列;**拖拽合并/编辑推迟** | +| D6 | 详情展开 / 眼睛 | **详情展开做**(异常叶子可展开看其详情,内联已加载字段);**眼睛(显隐)推迟**(随 VTK 同轮) | -**D2 的连锁简化**:因勾选源只数 **TM 叶子**(GS 仅作批量开关,且无 GS 级异常接口), -- 「GS→后代 TM」的展开由 Qt 原生三态复选框承担(`ItemIsAutoTristate` 级联),控制器只读叶子集; -- 聚合 = 被勾选 TM 叶子异常的并集(功能本身固有); -- **不存在去重问题**(叶子 id 唯一)。 +**D2 的连锁简化**:勾选源只数 **TM 叶子**(GS 仅作批量开关,且无 GS 级异常接口)→ GS→TM 展开由 Qt 原生三态复选框承担、控制器只读叶子集;聚合 = 被勾选 TM 异常/异常体的并集;**无去重问题**(叶子唯一)。 ## 3. 接口映射 -网关与会话沿用现有 `ApiClient`(`http://tenant.geomative.cn/pop-api`,token 已注入)。成功判定 `code==200`。 +网关与会话沿用现有 `ApiClient`,成功判定 `code==200`。 -| 能力 | 方法 | 路径 | 请求 / 返回要点 | +| 能力 | 方法 | 路径 | 要点 | |---|---|---|---| | 数据列表(GS/TM 级) | POST | `/business/dsObject/data/page` | body `{projectId, structParentId, structParentConfType, classifyTypeList:[3], pageNo, pageSize:5}`;`structParentConfType`:**TM=2(已固化),GS=1(待联调验证)**。返回沿用 `DsPage` | | 文件列表(GS/TM 级) | POST | `/business/dsObject/file/page` | body 同上 `classifyTypeList:[1]` | | 对象详情(GS) | GET | `/business/gsObject/getGsObjectDetail/{gsId}` | `data: DynamicFormVO` | | 对象详情(TM) | GET | `/business/tmObject/getDetail/{tmObjectId}` | `data: DynamicFormVO` | | 数据集详情 | GET | `/business/dsObject/dynamicForm/{dsObjectId}` | `data: DynamicFormVO`(与对象详情**同结构**,复用渲染) | -| 异常(按 TM) | GET | `/business/exception/queryExceptionByTmObjectId/{tmObjectId}` | `data: [ExceptionVO]`。**无 GS/项目级异常接口** → GS 勾选靠级联到 TM 叶子解决 | +| 异常(按 TM) | GET | `/business/exception/queryExceptionByTmObjectId/{tmObjectId}` | `data: [ExceptionVO]`。**异常的「异常体归属」字段需 live 验证**(候选:`parentId`+`parentConfType`,或专有 consortium 字段) | +| 异常体树(项目级,备用) | GET | `/business/exceptionConsortium/getExceptionConsortiumTree/{projectId}` | `data: [{id, name, type, exceptionTypeId, parentId, status, ...}]`(项目级,不按对象过滤;作 §9 回退用) | -**`DynamicFormVO` 形状**:`{ name, confCode, description, formList:[FormVO], properties:object, ... }`。 -- `FormVO{ groupName, groupSort, values:[FormItemVO] }`;`FormItemVO{ fieldName(显示名), fieldCode, displaySort, ... }`。 -- `properties` 为 `{ fieldCode: value }` 键值对(值容器;**待联调确认确实按 fieldCode 索引**)。 -- 渲染规则:按 `groupSort` 排组、组内按 `displaySort` 排字段,逐项显示 `fieldName : properties[fieldCode]`。 +**`DynamicFormVO`**:`{ name, confCode, description, formList:[FormVO], properties:object }`;`FormVO{ groupName, groupSort, values:[FormItemVO] }`;`FormItemVO{ fieldName, fieldCode, displaySort }`;`properties` 为 `{fieldCode: value}`。渲染:组按 `groupSort`、字段按 `displaySort`,逐项 `fieldName : properties[fieldCode]`。 -**`ExceptionVO` 关键字段**:`id, exceptionName, exceptionTypeName, exceptionMarkTypeName, createTime, remark, location, latitudeLongitude, geographicalCoordinates, parentId, parentConfType`。 +**`ExceptionVO` 关键字段**:`id, exceptionName, exceptionTypeName, exceptionMarkType, exceptionMarkTypeName, createTime, remark, location, latitudeLongitude, geographicalCoordinates, elevationList, zlist, parentId, parentConfType, type`。 ## 4. 架构分层 -沿用既有四层,依赖单向向下,UI 不直接碰 `ApiClient`: - ``` -UI(app) ObjectTreePanel(三态勾选) DynamicFormView(新) AnomalyListPanel(+异常行渲染) - │ 信号(单击/勾选) ▲ 槽(模型) -controller WorkbenchNavController ← selectObject / setCheckedTms / selectDataset +UI(app) ObjectTreePanel(三态勾选) DynamicFormView(新) ObjectExceptionPanel(新树面板) + │ 信号(单击/勾选) ▲ 槽(模型) +controller WorkbenchNavController ← selectObject / setCheckedTms / selectDataset │ IProjectRepository(同步契约) -data ApiProjectRepository + NavDto(parseDynamicForm/parseExceptions) +data ApiProjectRepository + NavDto(parseDynamicForm/parseExceptions/groupExceptionsByConsortium) │ ApiClient -net ApiClient / AuthService(复用,不改) +net ApiClient / AuthService(复用,不改) -模型(RepoTypes.hpp):+ DynamicForm / DynamicFormGroup / DynamicFormField / ExceptionRow +模型(RepoTypes.hpp):+ DynamicForm 系列 / ExceptionRow / ConsortiumGroup / ObjectExceptionGroup ``` ## 5. 数据 / 模型层 @@ -78,181 +71,192 @@ net ApiClient / AuthService(复用,不改) ```cpp // 动态表单(GS/TM/DS 详情统一模型)。 -struct DynamicFormField { std::string name, value; }; // 显示名 + 值(已合并) +struct DynamicFormField { std::string name, value; }; struct DynamicFormGroup { std::string name; std::vector fields; }; -struct DynamicForm { - std::string name; // 对象/数据集名称(表头) - std::vector groups; // 已按 sort 排好序 -}; +struct DynamicForm { std::string name; std::vector groups; }; // 已按 sort 排好 -// 异常列表行(右上「异常」面板用;列表展示,本轮不联动 VTK)。 +// 异常(树叶;本轮只读)。detailSummary = 详情展开内联显示的派生摘要(坐标/高程/标记类型/备注)。 struct ExceptionRow { - std::string id, name, typeName, markTypeName, createTime, remark; + std::string id, name, typeName, createTime; + std::string consortiumId, consortiumName, consortiumType; // 异常体归属(空=独立异常) + std::string detailSummary; // 详情展开用(已加载字段拼接) +}; +// 异常体分组(树中间层)。 +struct ConsortiumGroup { std::string id, name, typeName; std::vector exceptions; }; +// 对象分组(树根层,对应一个被勾选 TM)。 +struct ObjectExceptionGroup { + std::string objectId, objectName; + std::vector consortia; // 该对象下的异常体(含其异常) + std::vector looseExceptions; // 未合并进异常体的独立异常 }; ``` ### 5.2 DTO(`src/data/dto/NavDto.{hpp,cpp}`,纯函数,可单测) ```cpp -// DynamicFormVO 对象 → DynamicForm:合并 formList(字段定义) + properties(值)。 -// - 组按 groupSort、字段按 displaySort 排序;值取 properties[fieldCode](缺失→空串)。 -// - properties 中存在但 formList 未定义的键:本轮忽略(以表单定义为准;待联调复核)。 -DynamicForm parseDynamicForm(const QJsonObject& data); +DynamicForm parseDynamicForm(const QJsonObject& data); // formList(定义)+properties(值) 合并 +std::vector parseExceptions(const QJsonArray& arr); // ExceptionVO→ExceptionRow(含 consortium* + detailSummary) -// ExceptionVO 数组 → [ExceptionRow](字段直映射;name=exceptionName、typeName=exceptionTypeName、 -// markTypeName=exceptionMarkTypeName)。 -std::vector parseExceptions(const QJsonArray& arr); +// 把一个对象(TM)的异常行,按 consortiumId 分组成「异常体列表 + 独立异常列表」。纯函数、可单测。 +// - 同 consortiumId 归一组(组名/类型取首个非空 consortiumName/Type); +// - consortiumId 为空 → looseExceptions。 +struct GroupedExceptions { std::vector consortia; std::vector loose; }; +GroupedExceptions groupExceptionsByConsortium(const std::vector& rows); ``` -> 不需要 `collectDescendantTmIds`:GS→TM 展开由 UI 三态复选框级联承担(见 §7.1)。 +> `detailSummary` 在 `parseExceptions` 内由 `exceptionMarkTypeName / createTime / 坐标(latitudeLongitude 或 location 质心) / 高程(elevationList/zlist 极值) / remark` 拼成可读多行串(无额外请求)。 ### 5.3 数据访问层 `IProjectRepository` 扩展(`src/data/repo/IProjectRepository.hpp`) ```cpp -// 既有 loadTmRows 泛化:把硬编码 structParentConfType=2 改为入参 parentConfType(支持 GS=1)。 -// classifyType 3=数据 1=文件;pageNo 从 1 起,pageSize 固定 5。 -// (保留旧名或重命名为 loadRows;调用点同步更新。) +// 既有 loadTmRows 泛化:structParentConfType 由硬编码 2 改入参(支持 GS=1)。 virtual RepoResult loadRows(const std::string& projectId, const std::string& parentId, int parentConfType, int classifyType, int pageNo) = 0; - -// 对象详情:按 confType 选端点(1=GS→getGsObjectDetail,2=TM→tmObject/getDetail)。 +// 对象详情:按 confType 选端点(1=GS getGsObjectDetail,2=TM tmObject/getDetail)。 virtual RepoResult loadObjectDetail(const std::string& objectId, int confType) = 0; - // 数据集详情:dsObject/dynamicForm/{dsObjectId}。 virtual RepoResult loadDatasetForm(const std::string& dsObjectId) = 0; - -// 单 TM 异常列表:exception/queryExceptionByTmObjectId/{tmObjectId}。 +// 单 TM 异常(含异常体归属字段):exception/queryExceptionByTmObjectId/{tmObjectId}。 virtual RepoResult> loadExceptionsByTm(const std::string& tmObjectId) = 0; ``` -`ApiProjectRepository` 按 §3 路径实现;id 进 URL 前 `QUrl::toPercentEncoding`;错误归一为 `RepoResult{ok=false, error=msg}`。 +`ApiProjectRepository` 按 §3 实现;id 进 URL 前百分号编码;错误归一为 `RepoResult{ok=false,error=msg}`。 ## 6. 逻辑层 `WorkbenchNavController` -新增状态与契约(不碰 widget): - ```cpp public slots: - // 泛化原 selectTm:单击对象(GS/TM)→ 加载其 data/file 首页(loadRows 按 confType) - // + 加载对象详情 → emit objectDetailLoaded。confType: 1=GS 2=TM。 - void selectObject(const QString& objectId, int confType); - // 勾选集变化(已是 TM 叶子集合,由面板算好并合并发射):逐 TM 查异常→并集→emit exceptionsLoaded。 - void setCheckedTms(const QStringList& tmObjectIds); - // 单击数据集 → 加载 dynamicForm → emit datasetDetailLoaded。 - void selectDataset(const QString& dsObjectId); - void loadMoreData(); void loadMoreFiles(); // 适配泛化 parent(见下) - + void selectObject(const QString& objectId, int confType); // 单击对象→DS列表(loadRows)+对象详情 + void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→逐TM异常→分组→异常树 + void selectDataset(const QString& dsObjectId); // 单击DS→dynamicForm + void loadMoreData(); void loadMoreFiles(); // 用泛化 parent 续页 signals: void objectDetailLoaded(const QString& title, const geopro::data::DynamicForm& form); - void exceptionsLoaded(const std::vector& rows, int tmCount); + void exceptionTreeLoaded(const std::vector& groups, int tmCount); void datasetDetailLoaded(const geopro::data::DynamicForm& form); - private: - std::string currentParentId_; int currentParentConfType_ = 0; // 加载更多用(替代 currentTmId_) + std::string currentParentId_; int currentParentConfType_ = 0; // 加载更多用(替代 currentTmId_) + std::vector lastStructNodes_; // tmId→name 解析 std::map> tmExceptionCache_; // per-TM 异常缓存 ``` -- `selectObject`:置 `currentParentId_/ConfType_`、`dataPageNo_=filePageNo_=1`,`loadRows` 拉数据+文件首页(emit `datasetsLoaded/filesLoaded`),再 `loadObjectDetail` emit `objectDetailLoaded`。 -- `setCheckedTms`:对集合中每个 TM,命中 `tmExceptionCache_` 则复用、否则 `loadExceptionsByTm` 并入缓存;合并为并集(顺序:按勾选/树序稳定),emit `exceptionsLoaded(rows, tmCount=集合大小)`。空集合 → emit 空列表(面板回占位)。 +- `selectObject`:置 `currentParentId_/ConfType_`、页码归 1,`loadRows` 拉数据+文件首页(emit `datasetsLoaded/filesLoaded`),再 `loadObjectDetail` → emit `objectDetailLoaded`。 +- `setCheckedTms`:对集合每个 TM——命中 `tmExceptionCache_` 复用、否则 `loadExceptionsByTm` 入缓存;对每个 TM 调 `groupExceptionsByConsortium` 装配一个 `ObjectExceptionGroup`(objectName 由 `lastStructNodes_` 解析);emit `exceptionTreeLoaded(groups, tmCount)`。空集 → emit 空(面板回占位)。 - `selectDataset`:`loadDatasetForm` → emit `datasetDetailLoaded`。 -- `loadMoreData/Files`:用 `currentParentId_/ConfType_` 续页(原依赖 `currentTmId_`,改为泛化 parent)。 -- 切项目/工作空间:清 `tmExceptionCache_`、`currentParentId_`,并(经既有 `structureLoaded` 接线)清空三面板。 -- 沿用 `BusyGuard` 重入保护与 `busyChanged`(同步阻塞 + WaitCursor)。 +- 切项目/工作空间:清 `tmExceptionCache_`、`currentParentId_`;经既有 `structureLoaded` 接线清空各面板。 +- 沿用 `BusyGuard` 重入保护 + `busyChanged`(同步阻塞 + WaitCursor)。`structureLoaded` 时控制器留存 `lastStructNodes_`。 ## 7. UI 层 `app` ### 7.1 `ObjectTreePanel`(三态勾选 + GS 可单击) -- **GS 节点**:设 `Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate`,存 `confType=1` 角色,可单击。 +- **GS**:`Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate`,存 `confType=1` 角色,可单击。 - **TM 叶子**:`Qt::ItemIsUserCheckable`,存 `confType=2` + `tmObjectId`(沿用现有角色)。 -- **级联**:Qt 原生三态——勾 GS 自动勾全部子 TM;子 TM 单独取消 → GS 转 `PartiallyChecked`(满足 D2)。 -- **信号改造**: - - `objectClicked(QString objectId, int confType)`(取代 `tmClicked`)——单击行(GS 或 TM)。 - - `checkedTmsChanged(QStringList tmObjectIds)`(取代 `tmCheckToggled`)——发射**当前全部被勾选的 TM 叶子 id**。 -- **合并发射**:`itemChanged` 在 GS 级联时会对每个子项各触发一次。用 `QTimer::singleShot(0, ...)` 合并:标脏 → 事件循环回合一次性遍历树收集所有勾选叶子 → 发一次 `checkedTmsChanged`(避免一次点击触发 N 次重算/N 组请求)。 -- 单击高亮当前行(`QTreeWidget` 默认单选即可)。 +- **级联**:Qt 原生三态——勾 GS 自动勾全部子 TM;子 TM 取消 → GS 转 `PartiallyChecked`。 +- **信号**:`objectClicked(QString objectId, int confType)`(取代 `tmClicked`);`checkedTmsChanged(QStringList tmObjectIds)`(取代 `tmCheckToggled`,发射**当前全部被勾选的 TM 叶子 id**)。 +- **合并发射**:GS 级联会对每个子项各触发 `itemChanged`。用 `QTimer::singleShot(0,…)` 合并:标脏 → 回合一次性遍历收集勾选叶子 → 发一次 `checkedTmsChanged`(避免一次点击触发 N 次重算/N 组请求)。 ### 7.2 `DynamicFormView`(新增,共享键值渲染器) -- `src/app/panels/DynamicFormView.{hpp,cpp}`:`QWidget`,方法 `void setForm(const DynamicForm&)` / `void showMessage(const QString&)`(空/错占位)。 -- 布局:`QScrollArea` 内纵向堆叠——每组一个组标题 + `QFormLayout`(左 `字段名`、右 `值`,值支持换行)。无数据 → 居中淡色占位。 -- 主题化:颜色取全局令牌(`text/primary`、`text/secondary` 等),随 `ThemeManager::changed` 重绘。 -- **「对象属性」与「数据集属性」两面板各持一个 `DynamicFormView` 实例**(取代现有两个占位 `QLabel`)。 +- `src/app/panels/DynamicFormView.{hpp,cpp}`:`QWidget`,`setForm(const DynamicForm&)` / `showMessage(QString)`。 +- 布局:`QScrollArea` 内每组「组标题 + `QFormLayout`(左字段名/右值,值可换行)」;无数据→居中占位。主题化随 `ThemeManager::changed` 重绘。 +- 「对象属性」「数据集属性」两面板各持一个实例(取代现有两占位 `QLabel`)。 -### 7.3 `AnomalyListPanel`(新增异常行渲染) +### 7.3 `ObjectExceptionPanel`(新增,异常+异常体 只读树) -- 新增 `void populateExceptionList(QListWidget*, const std::vector&)`:复用现有卡片视觉 - (左色条可用中性/警示色、标题=`name`、第二行=`typeName · markTypeName · createTime`), - **本轮去掉「眼睛/显隐」**(无 VTK 详情可联动)。 -- 既有 `populateAnomalyList(vector)` + `AnomalyCardDelegate`(带眼睛、联动 VTK)**保留不动**,留给未来「数据详情真实渲染」轮;本轮不调用。 -- 右上「异常」Tab 数量徽标:填 `rows.size()`,空则隐藏。 +替代右上「异常」Tab 内原扁平列表(原 `QListWidget`),改为 `QTreeWidget`: + +``` +对象A(TM 名) +├─ ▸ 异常体X(异常体类型) ← 中间层:ConsortiumGroup +│ ├─ 异常1(异常类型) ← 叶子:ExceptionRow +│ │ └─ ▸ 详情:标记类型/坐标/高程/创建时间/备注 ← D6 详情展开(内联,懒构建) +│ └─ 异常2(异常类型) +├─ 独立异常(未合并) ← looseExceptions 分组节点 +│ └─ 异常3(异常类型) +对象B(TM 名)… +``` + +- `void setGroups(const std::vector&)`:重建树;`showMessage(QString)` 空/错占位。 +- **详情展开(D6)**:异常叶子下挂一个「详情」子节点,显示 `ExceptionRow.detailSummary`(已加载字段,无额外请求);默认折叠。 +- **眼睛/显隐(推迟)**:不画眼睛列(无 VTK 可联动)。 +- 顶部数量徽标:填异常总数(跨所有对象组),空则隐藏。 +- 主题化随 `ThemeManager::changed`。 +- 旧 `AnomalyListPanel`(`populateAnomalyList(core::Anomaly)` + `AnomalyCardDelegate` + 眼睛 + VTK 联动)**整体保留不删**,留未来「数据详情真实渲染 + 眼睛联动」轮;本轮不接线。 ### 7.4 `main.cpp` 接线(增量) -- `objectTree.objectClicked → nav.selectObject(id, confType)`。 -- `nav.objectDetailLoaded → 对象属性 DynamicFormView.setForm(form)`(并可用 title 更新 Tab/表头)。 -- `objectTree.checkedTmsChanged → nav.setCheckedTms(tmIds)`。 -- `nav.exceptionsLoaded → AnomalyListPanel.populateExceptionList + 徽标`。 -- `datasetList.itemClicked`(非「加载更多」行)→ `nav.selectDataset(dsId)`;移除当前写入 `propLabel` 的占位文案。 -- `nav.datasetDetailLoaded → 数据集属性 DynamicFormView.setForm(form)`。 -- `structureLoaded`(切项目/空间):清空对象属性、数据集属性两 `DynamicFormView` 与异常列表/徽标(回占位)。 +- `objectTree.objectClicked → nav.selectObject(id, confType)`;`nav.objectDetailLoaded → 对象属性 DynamicFormView.setForm`。 +- `objectTree.checkedTmsChanged → nav.setCheckedTms(tmIds)`;`nav.exceptionTreeLoaded → ObjectExceptionPanel.setGroups + 徽标`。 +- `datasetList.itemClicked`(非「加载更多」行)→ `nav.selectDataset(dsId)`;移除现写入 `propLabel` 的占位文案。 +- `nav.datasetDetailLoaded → 数据集属性 DynamicFormView.setForm`。 +- `structureLoaded`(切项目/空间):清空对象属性、数据集属性两 `DynamicFormView` 与异常树/徽标(回占位)。 - 既有 `selectTm` 接线点替换为 `selectObject`;`loadMore*` 接线不变(控制器内部改用泛化 parent)。 +- 右上 Tab 面板:把原 `anomalyList(QListWidget)` 槽位替换为 `ObjectExceptionPanel`;「对象属性」Tab 内容替换为 `DynamicFormView`。 ## 8. 交互时序 ``` -单击对象(GS/TM): ObjectTreePanel.objectClicked(id, confType) - → nav.selectObject: - loadRows(pid,id,confType,3,1)+loadRows(...,1,1) → datasetsLoaded/filesLoaded → 左下数据/文件页签 - loadObjectDetail(id,confType) → objectDetailLoaded → 右上「对象属性」DynamicFormView +单击对象(GS/TM): objectClicked(id, confType) + → selectObject: loadRows×2(数据+文件首页)→datasetsLoaded/filesLoaded→左下; + loadObjectDetail(id,confType)→objectDetailLoaded→右上「对象属性」 -勾选/取消(GS 三态级联到 TM 叶子): ObjectTreePanel 合并 → checkedTmsChanged([tm...]) - → nav.setCheckedTms: 逐 TM(缓存)查异常 → 并集 → exceptionsLoaded(rows, n) → 右上「异常」列表+徽标 - (取消某 TM/GS → 叶子集变小 → 重算并集 → 该 TM 异常自动消失) +勾选/取消(GS三态级联到TM叶子): 合并→checkedTmsChanged([tm...]) + → setCheckedTms: 逐TM(缓存)查异常→groupExceptionsByConsortium→[ObjectExceptionGroup] + → exceptionTreeLoaded → 右上「对象异常」树(对象→异常体→异常 + 独立异常)+ 徽标 + (取消某TM/GS → 叶子集变小 → 重算 → 其异常/异常体自动消失) -单击数据集: datasetList.itemClicked(dsId) - → nav.selectDataset: loadDatasetForm(dsId) → datasetDetailLoaded → 右下「数据集属性」DynamicFormView +单击数据集: itemClicked(dsId) + → selectDataset: loadDatasetForm(dsId)→datasetDetailLoaded→右下「数据集属性」 -切项目/空间: structureLoaded → 清空三面板 + 勾选集 + 异常缓存 +切项目/空间: structureLoaded → 清空各面板 + 勾选集 + 异常缓存 ``` ## 9. 边界与错误处理 -- **GS confType=1 待联调**:若 `data/page` 在 `structParentConfType=1` 下**不返回**GS 下全部 DS(不递归), - 回退方案:对 GS 的直接子 TM 逐个 `data/page` 合并展示(分页退化为"加载全部"或按 TM 分段)。实现前用 live 接口验证一次再定。 -- **properties 索引键待联调**:`parseDynamicForm` 按 `fieldCode` 取值;若实测 `properties` 用其它键(如 `confFieldId`)索引,调整映射键即可(隔离在 DTO 一处)。 -- **异常聚合性能**:同步多请求 + WaitCursor;`tmExceptionCache_` 避免增量勾选时重复请求;切项目清缓存。 -- **空 / 错状态**:任一面板无数据 → 居中「暂无…」占位;请求失败 → `loadFailed(stage,msg)` 状态栏提示 + 面板错误占位;**不回退本地样本**。 -- **输入边界**:id 为空短路不发请求;URL 中 id 百分号编码。 -- **重入**:沿用 `busy_` 保护(快速连点不污染状态)。 +- **GS confType=1 待联调**:若 `data/page` 在 `structParentConfType=1` 下不递归返回 GS 全部 DS,回退:对 GS 直接子 TM 逐个 `data/page` 合并(分页退化为按 TM 分段或一次拉全)。实现前 live 验证。 +- **异常体归属字段待联调(关键)**:`groupExceptionsByConsortium` 依赖 `ExceptionRow.consortiumId`,其来源字段需 live 验证(候选 `ExceptionVO.parentId`+`parentConfType`,或专有字段)。 + - 回退:若单 TM 异常不带异常体归属,则改用项目级 `getExceptionConsortiumTree/{projectId}` 建「异常id→异常体」映射,再对勾选 TM 的异常套用该映射分组;仍无 → 全部作独立异常列出(保证面板可用,异常体层为空)。 + - `consortiumName/Type` 来源同样 live 验证(异常 payload 的 `parentName`,或异常体树/`getDetail`)。 +- **聚合性能**:同步多请求 + WaitCursor;`tmExceptionCache_` 避免增量勾选重复请求;切项目清缓存。 +- **空/错状态**:各面板无数据→「暂无…」占位;失败→`loadFailed(stage,msg)` 状态栏提示 + 面板错误占位;**不回退本地样本**。 +- **输入边界**:id 为空短路;URL 中 id 百分号编码。**重入**:沿用 `busy_` 保护。 ## 10. 测试策略 -聚焦纯逻辑单测(GoogleTest + CTest),沿用 `tests/data/test_nav_dto.cpp`: -- `parseDynamicForm`:分组+值合并、`groupSort/displaySort` 排序、`properties` 缺失值→空串、空 `formList`、`name` 透传。 -- `parseExceptions`:字段映射(`exceptionName→name` 等)、空数组、缺字段容错。 -- UI 三态级联 / 控制器聚合:依赖 Qt/live,靠手动联调验证(无桩)。 +纯逻辑单测(GoogleTest + CTest),沿用 `tests/data/test_nav_dto.cpp`: +- `parseDynamicForm`:分组+值合并、`groupSort/displaySort` 排序、缺失值→空、空 `formList`、`name` 透传。 +- `parseExceptions`:字段映射、`detailSummary` 拼接、`consortium*` 提取、空数组、缺字段容错。 +- `groupExceptionsByConsortium`:同 consortiumId 归组、空 consortiumId→loose、组名取首个非空、空输入、全独立、全归属。 +- UI 三态级联 / 控制器装配:依赖 Qt/live,手动联调验证(无桩)。 ## 11. 文件清单 **改造** -- `src/data/repo/RepoTypes.hpp` — `+ DynamicForm/DynamicFormGroup/DynamicFormField/ExceptionRow` -- `src/data/dto/NavDto.{hpp,cpp}` — `+ parseDynamicForm / parseExceptions` -- `src/data/repo/IProjectRepository.hpp` — `loadTmRows` 泛化为 `loadRows`;`+ loadObjectDetail / loadDatasetForm / loadExceptionsByTm` -- `src/data/api/ApiProjectRepository.{hpp,cpp}` — 实现上述新方法 -- `src/controller/WorkbenchNavController.{hpp,cpp}` — `+ selectObject / setCheckedTms / selectDataset / 三新信号 / per-TM 缓存`;`selectTm` 退役、`loadMore*` 改泛化 parent +- `src/data/repo/RepoTypes.hpp` — `+ DynamicForm 系列 / ExceptionRow / ConsortiumGroup / ObjectExceptionGroup` +- `src/data/dto/NavDto.{hpp,cpp}` — `+ parseDynamicForm / parseExceptions / groupExceptionsByConsortium` +- `src/data/repo/IProjectRepository.hpp` — `loadTmRows`→`loadRows` 泛化;`+ loadObjectDetail / loadDatasetForm / loadExceptionsByTm` +- `src/data/api/ApiProjectRepository.{hpp,cpp}` — 实现新方法 +- `src/controller/WorkbenchNavController.{hpp,cpp}` — `+ selectObject / setCheckedTms / selectDataset / 三新信号 / per-TM 缓存 / lastStructNodes_`;`selectTm` 退役、`loadMore*` 改泛化 parent - `src/app/panels/ObjectTreePanel.{hpp,cpp}` — GS 三态可勾选+可单击;信号改 `objectClicked / checkedTmsChanged`(合并发射) -- `src/app/panels/AnomalyListPanel.{hpp,cpp}` — `+ populateExceptionList`(旧 `core::Anomaly` 路径保留) -- `src/app/main.cpp` — 三面板接线(见 §7.4);移除 DS 单击占位文案 -- `tests/data/test_nav_dto.cpp` — `+ parseDynamicForm / parseExceptions` 用例 +- `src/app/main.cpp` — 各面板接线(§7.4);移除 DS 单击占位文案;右上 Tab 槽位替换为新面板 +- `tests/data/test_nav_dto.cpp` — `+ parseDynamicForm / parseExceptions / groupExceptionsByConsortium` 用例 **新增** - `src/app/panels/DynamicFormView.{hpp,cpp}` — 共享键值渲染器(对象属性 + 数据集属性) +- `src/app/panels/ObjectExceptionPanel.{hpp,cpp}` — 异常+异常体 只读树面板 -**保留不删**:`LocalSampleRepository`、`render/*`、`AnomalyCardDelegate`/`populateAnomalyList`、中央/详情渲染代码(留未来 dd/ert 渲染轮)。 +**保留不删**:`LocalSampleRepository`、`render/*`、`AnomalyListPanel`(`populateAnomalyList`/`AnomalyCardDelegate`/眼睛/VTK 联动)、中央/详情渲染代码(留未来 dd/ert 渲染 + 眼睛联动轮)。 ## 12. 未决 / 验证点(实现前用 live 接口确认) 1. GS 级 `data/page` 的 `structParentConfType` 取值与是否递归返回全部 DS(§9 回退)。 2. `DynamicFormVO.properties` 的索引键(`fieldCode` 假设)。 -3. `exception/queryExceptionByTmObjectId` 实际字段名与 `ExceptionVO` 是否一致(标记类型字段命名)。 +3. **异常的「异常体归属」字段**(`consortiumId/Name/Type` 来源):单 TM 异常 payload 是否自带,否则走项目级异常体树映射(§9 回退)。 +4. `exceptionConsortium` 各字段命名与「异常体类型」取值来源(`exceptionTypeId` vs 类型名)。 + +## 13. 后续轮(本轮明确不做) + +- 异常体**拖拽合并/编辑**(`exceptionConsortium` 新增/关联/取消关联/删除写操作)。 +- 异常项**眼睛(显隐)** 联动中央/详情视图——随 dd/ert 真实剖面渲染同轮接入。 +- 中央 2D/3D 与「数据详情」真实数据渲染、项目 CRS 替换、异步仓储、文件下载。 -- 2.40.1 From 37300d523e49d8fbf92e1e98a4965ca6f5571fff Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 19:48:22 +0800 Subject: [PATCH 03/18] =?UTF-8?q?docs(plan):=20=E5=AF=B9=E8=B1=A1=E5=8D=95?= =?UTF-8?q?=E5=87=BB/=E5=8B=BE=E9=80=89=E9=A9=B1=E5=8A=A8=E4=B8=89?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=20=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92?= =?UTF-8?q?=EF=BC=88TDD=EF=BC=8C10=20=E4=BB=BB=E5=8A=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-10-object-selection-panels.md | 1354 +++++++++++++++++ 1 file changed, 1354 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-object-selection-panels.md diff --git a/docs/superpowers/plans/2026-06-10-object-selection-panels.md b/docs/superpowers/plans/2026-06-10-object-selection-panels.md new file mode 100644 index 0000000..9bbc284 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-object-selection-panels.md @@ -0,0 +1,1354 @@ +# 对象单击/勾选 驱动 数据列表·异常(含异常体)·属性 面板 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让单击对象加载其 DS 列表与对象属性、勾选对象聚合显示「异常+异常体」只读树、单击数据集显示数据集属性,全部接真实业务 API。 + +**Architecture:** 沿用四层(net→data→controller→app)。数据层新增纯函数 DTO(TDD 单测)+ 仓储方法;控制器新增 slots/signals 编排;UI 新增两个被动面板(`DynamicFormView`、`ObjectExceptionPanel`)并改造 `ObjectTreePanel` 为三态勾选。中央/详情 VTK 渲染本轮不动。 + +**Tech Stack:** C++17、Qt6 Widgets、GoogleTest/CTest、CMake(preset `msvc-release`)、现有 `ApiClient`(同步 HTTP,token 已注入)。 + +**Spec:** `docs/superpowers/specs/2026-06-10-object-selection-panels-design.md` + +--- + +## 构建 / 测试命令(全程通用) + +- 配置(仅首次或改 CMakeLists 后):`cmake --preset msvc-release` +- 构建测试目标:`cmake --build --preset release --target geopro_tests` +- 构建桌面程序:`cmake --build --preset release --target geopro_desktop` +- 跑 DTO 单测:`ctest --test-dir build/release -C Release -R NavDto --output-on-failure` + +> MSVC 多配置:`ctest` 必须带 `-C Release`。新增 test 源文件后需重新 `cmake --preset msvc-release` 再构建。 + +--- + +## 文件结构 + +**数据/模型层** +- `src/data/repo/RepoTypes.hpp`(改)— 新增 `DynamicFormField/Group/DynamicForm`、`ExceptionRow`、`ConsortiumGroup`、`ObjectExceptionGroup`、`GroupedExceptions` +- `src/data/dto/NavDto.hpp`/`.cpp`(改)— 新增 `parseDynamicForm` / `parseExceptions` / `groupExceptionsByConsortium` +- `src/data/repo/IProjectRepository.hpp`(改)— `loadTmRows`→`loadRows` 泛化 + 3 个新方法 +- `src/data/api/ApiProjectRepository.hpp`/`.cpp`(改)— 实现新方法 +- `tests/data/test_nav_dto.cpp`(改)— 新增 3 组用例 + +**逻辑层** +- `src/controller/WorkbenchNavController.hpp`/`.cpp`(改)— 新 slots/signals/状态 + +**UI 层** +- `src/app/panels/DynamicFormView.hpp`/`.cpp`(新)— 动态表单键值渲染器 +- `src/app/panels/ObjectExceptionPanel.hpp`/`.cpp`(新)— 异常+异常体只读树 +- `src/app/panels/ObjectTreePanel.hpp`/`.cpp`(改)— GS 三态勾选 + 新信号 +- `src/app/main.cpp`(改)— 接线 + 移除占位 +- `src/app/CMakeLists.txt`(改)— 加两个新源文件 + +--- + +## Task 1: 模型 + `parseDynamicForm`(TDD) + +**Files:** +- Modify: `src/data/repo/RepoTypes.hpp` +- Modify: `src/data/dto/NavDto.hpp` +- Modify: `src/data/dto/NavDto.cpp` +- Test: `tests/data/test_nav_dto.cpp` + +- [ ] **Step 1: 在 `RepoTypes.hpp` 加动态表单模型** + +在 `RepoTypes.hpp` 的 `namespace geopro::data {` 内、`StructNode` 之后加: + +```cpp +// 动态表单(GS/TM/DS 详情统一模型)。值已与字段定义合并、已按 sort 排好序。 +struct DynamicFormField { std::string name, value; }; +struct DynamicFormGroup { std::string name; std::vector fields; }; +struct DynamicForm { std::string name; std::vector groups; }; +``` + +- [ ] **Step 2: 在 `NavDto.hpp` 声明 `parseDynamicForm`** + +在 `namespace geopro::data::dto {` 内加: + +```cpp +// DynamicFormVO 对象 → DynamicForm:合并 formList(字段定义) + properties(值)。 +// 组按 groupSort、字段按 displaySort 排序;值取 properties[fieldCode](缺失→空串)。 +// 表头 name 取 data["name"]。 +DynamicForm parseDynamicForm(const QJsonObject& data); +``` + +- [ ] **Step 3: 写失败测试** + +在 `tests/data/test_nav_dto.cpp` 末尾(`using namespace geopro::data;` 已在文件顶部)加: + +```cpp +TEST(NavDto, ParseDynamicFormMergesFieldsValuesAndSorts) { + const auto data = objOf(R"({ + "name": "测线1", + "properties": { "depth": "120", "len": "300", "owner": "张三" }, + "formList": [ + { "groupName": "几何", "groupSort": 1, "values": [ + { "fieldName": "长度", "fieldCode": "len", "displaySort": 2 }, + { "fieldName": "深度", "fieldCode": "depth", "displaySort": 1 } + ]}, + { "groupName": "归属", "groupSort": 2, "values": [ + { "fieldName": "负责人", "fieldCode": "owner", "displaySort": 1 }, + { "fieldName": "缺失项", "fieldCode": "nope", "displaySort": 2 } + ]} + ] + })"); + const auto form = dto::parseDynamicForm(data); + EXPECT_EQ(form.name, "测线1"); + ASSERT_EQ(form.groups.size(), 2u); + EXPECT_EQ(form.groups[0].name, "几何"); // groupSort 升序 + ASSERT_EQ(form.groups[0].fields.size(), 2u); + EXPECT_EQ(form.groups[0].fields[0].name, "深度"); // displaySort 升序 + EXPECT_EQ(form.groups[0].fields[0].value, "120"); + EXPECT_EQ(form.groups[0].fields[1].name, "长度"); + EXPECT_EQ(form.groups[0].fields[1].value, "300"); + EXPECT_EQ(form.groups[1].fields[1].value, ""); // properties 缺失 → 空串 +} + +TEST(NavDto, ParseDynamicFormEmptyFormListYieldsNoGroups) { + const auto data = objOf(R"({ "name":"空", "properties":{}, "formList":[] })"); + const auto form = dto::parseDynamicForm(data); + EXPECT_EQ(form.name, "空"); + EXPECT_TRUE(form.groups.empty()); +} +``` + +- [ ] **Step 4: 跑测试确认失败** + +Run: `cmake --preset msvc-release && cmake --build --preset release --target geopro_tests` +Expected: 编译失败(`parseDynamicForm` 未定义)。 + +- [ ] **Step 5: 在 `NavDto.cpp` 实现 `parseDynamicForm`** + +`NavDto.cpp` 顶部确保有 `#include `(若无则加)。在 `namespace geopro::data::dto {` 内加: + +```cpp +DynamicForm parseDynamicForm(const QJsonObject& data) { + DynamicForm form; + form.name = str(data, "name"); + const QJsonObject props = data.value(QStringLiteral("properties")).toObject(); + + QJsonArray groups = data.value(QStringLiteral("formList")).toArray(); + std::vector gv; + gv.reserve(static_cast(groups.size())); + for (const QJsonValue& g : groups) gv.push_back(g.toObject()); + std::stable_sort(gv.begin(), gv.end(), [](const QJsonObject& a, const QJsonObject& b) { + return a.value(QStringLiteral("groupSort")).toInt() < + b.value(QStringLiteral("groupSort")).toInt(); + }); + + for (const QJsonObject& g : gv) { + DynamicFormGroup group; + group.name = str(g, "groupName"); + QJsonArray vals = g.value(QStringLiteral("values")).toArray(); + std::vector fv; + fv.reserve(static_cast(vals.size())); + for (const QJsonValue& v : vals) fv.push_back(v.toObject()); + std::stable_sort(fv.begin(), fv.end(), [](const QJsonObject& a, const QJsonObject& b) { + return a.value(QStringLiteral("displaySort")).toInt() < + b.value(QStringLiteral("displaySort")).toInt(); + }); + for (const QJsonObject& f : fv) { + DynamicFormField field; + field.name = str(f, "fieldName"); + const QString code = f.value(QStringLiteral("fieldCode")).toString(); + field.value = props.value(code).toVariant().toString().toStdString(); // 值可能非字符串 + group.fields.push_back(std::move(field)); + } + form.groups.push_back(std::move(group)); + } + return form; +} +``` + +- [ ] **Step 6: 跑测试确认通过** + +Run: `cmake --build --preset release --target geopro_tests && ctest --test-dir build/release -C Release -R NavDto --output-on-failure` +Expected: PASS(含 `ParseDynamicForm*` 两条)。 + +- [ ] **Step 7: 提交** + +```bash +git add src/data/repo/RepoTypes.hpp src/data/dto/NavDto.hpp src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp +git commit -m "feat(data): parseDynamicForm 合并动态表单字段定义与值(含排序)" +``` + +--- + +## Task 2: `parseExceptions`(TDD) + +**Files:** +- Modify: `src/data/repo/RepoTypes.hpp` +- Modify: `src/data/dto/NavDto.hpp` +- Modify: `src/data/dto/NavDto.cpp` +- Test: `tests/data/test_nav_dto.cpp` + +- [ ] **Step 1: 在 `RepoTypes.hpp` 加 `ExceptionRow`** + +紧接 `DynamicForm` 之后加: + +```cpp +// 异常(树叶,本轮只读)。consortium* 空 = 独立异常;detailSummary = 详情展开内联显示。 +struct ExceptionRow { + std::string id, name, typeName, createTime; + std::string consortiumId, consortiumName, consortiumType; + std::string detailSummary; +}; +``` + +- [ ] **Step 2: 在 `NavDto.hpp` 声明 `parseExceptions`** + +```cpp +// ExceptionVO 数组 → [ExceptionRow]。字段:id、name=exceptionName、typeName=exceptionTypeName、 +// createTime;consortium* 取自 consortiumId/consortiumName/consortiumType(来源待 live 验证,§12.3); +// detailSummary 由 exceptionMarkTypeName/createTime/elevationList/remark 拼成可读多行串。 +std::vector parseExceptions(const QJsonArray& arr); +``` + +- [ ] **Step 3: 写失败测试** + +在 `tests/data/test_nav_dto.cpp` 末尾加: + +```cpp +TEST(NavDto, ParseExceptionsMapsFieldsAndSummary) { + const auto arr = arrOf(R"([ + { "id":"e1", "exceptionName":"空洞A", "exceptionTypeName":"空洞", + "exceptionMarkTypeName":"自动", "createTime":"2026-06-01", + "elevationList":[120.0, 80.0, 100.0], "remark":"复核中", + "consortiumId":"c1", "consortiumName":"体A", "consortiumType":"溶洞群" }, + { "id":"e2", "exceptionName":"裂隙B", "exceptionTypeName":"裂隙", + "exceptionMarkTypeName":"手动", "createTime":"2026-06-02", + "elevationList":[], "remark":"" } + ])"); + const auto rows = dto::parseExceptions(arr); + ASSERT_EQ(rows.size(), 2u); + EXPECT_EQ(rows[0].id, "e1"); + EXPECT_EQ(rows[0].name, "空洞A"); + EXPECT_EQ(rows[0].typeName, "空洞"); + EXPECT_EQ(rows[0].consortiumId, "c1"); + EXPECT_EQ(rows[0].consortiumName, "体A"); + EXPECT_EQ(rows[0].consortiumType, "溶洞群"); + // detailSummary 含标记类型、创建时间、高程极值、备注 + EXPECT_NE(rows[0].detailSummary.find("自动"), std::string::npos); + EXPECT_NE(rows[0].detailSummary.find("2026-06-01"), std::string::npos); + EXPECT_NE(rows[0].detailSummary.find("80"), std::string::npos); + EXPECT_NE(rows[0].detailSummary.find("120"), std::string::npos); + EXPECT_NE(rows[0].detailSummary.find("复核中"), std::string::npos); + // e2 无 consortium、无高程、无备注:consortiumId 空、summary 不崩 + EXPECT_TRUE(rows[1].consortiumId.empty()); + EXPECT_NE(rows[1].detailSummary.find("手动"), std::string::npos); +} +``` + +- [ ] **Step 4: 跑测试确认失败** + +Run: `cmake --build --preset release --target geopro_tests` +Expected: 编译失败(`parseExceptions` 未定义)。 + +- [ ] **Step 5: 在 `NavDto.cpp` 实现 `parseExceptions`** + +`NavDto.cpp` 顶部确保有 `#include ` 与 ``(若无则加)。加: + +```cpp +namespace { +// elevationList 极值拼 "高程 min~max m";空返回空串。 +std::string elevationSummary(const QJsonArray& el) { + if (el.isEmpty()) return {}; + double lo = std::numeric_limits::max(), hi = -std::numeric_limits::max(); + for (const QJsonValue& v : el) { + const double d = v.toDouble(); + if (d < lo) lo = d; + if (d > hi) hi = d; + } + return QStringLiteral("高程 %1~%2m") + .arg(lo, 0, 'f', 0).arg(hi, 0, 'f', 0).toStdString(); +} +} // namespace + +std::vector parseExceptions(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(arr.size())); + for (const QJsonValue& v : arr) { + const QJsonObject o = v.toObject(); + ExceptionRow r; + r.id = str(o, "id"); + r.name = str(o, "exceptionName"); + r.typeName = str(o, "exceptionTypeName"); + r.createTime = str(o, "createTime"); + r.consortiumId = str(o, "consortiumId"); // §12.3 待 live 验证字段名 + r.consortiumName = str(o, "consortiumName"); + r.consortiumType = str(o, "consortiumType"); + // detailSummary:标记类型 · 创建时间 [\n 高程 …] [\n 备注 …] + QStringList lines; + lines << QStringLiteral("标记 %1 · 创建 %2") + .arg(QString::fromStdString(str(o, "exceptionMarkTypeName"))) + .arg(QString::fromStdString(r.createTime)); + const std::string elev = elevationSummary(o.value(QStringLiteral("elevationList")).toArray()); + if (!elev.empty()) lines << QString::fromStdString(elev); + const std::string remark = str(o, "remark"); + if (!remark.empty()) lines << QStringLiteral("备注 %1").arg(QString::fromStdString(remark)); + r.detailSummary = lines.join(QLatin1Char('\n')).toStdString(); + out.push_back(std::move(r)); + } + return out; +} +``` + +> `NavDto.cpp` 需 `#include `(若未含)。 + +- [ ] **Step 6: 跑测试确认通过** + +Run: `cmake --build --preset release --target geopro_tests && ctest --test-dir build/release -C Release -R NavDto --output-on-failure` +Expected: PASS(含 `ParseExceptionsMapsFieldsAndSummary`)。 + +- [ ] **Step 7: 提交** + +```bash +git add src/data/repo/RepoTypes.hpp src/data/dto/NavDto.hpp src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp +git commit -m "feat(data): parseExceptions 映射异常字段 + 详情摘要" +``` + +--- + +## Task 3: `groupExceptionsByConsortium`(TDD) + +**Files:** +- Modify: `src/data/repo/RepoTypes.hpp` +- Modify: `src/data/dto/NavDto.hpp` +- Modify: `src/data/dto/NavDto.cpp` +- Test: `tests/data/test_nav_dto.cpp` + +- [ ] **Step 1: 在 `RepoTypes.hpp` 加分组模型** + +紧接 `ExceptionRow` 之后加: + +```cpp +// 异常体分组(树中间层)+ 对象分组(树根层,对应一个被勾选 TM)。 +struct ConsortiumGroup { std::string id, name, typeName; std::vector exceptions; }; +struct ObjectExceptionGroup { + std::string objectId, objectName; + std::vector consortia; + std::vector looseExceptions; +}; +struct GroupedExceptions { std::vector consortia; std::vector loose; }; +``` + +- [ ] **Step 2: 在 `NavDto.hpp` 声明 `groupExceptionsByConsortium`** + +```cpp +// 把一个对象(TM)的异常行按 consortiumId 分组:同 id 归一组(组名/类型取首个非空); +// consortiumId 空 → loose。保持首次出现顺序稳定。纯函数、可单测。 +GroupedExceptions groupExceptionsByConsortium(const std::vector& rows); +``` + +- [ ] **Step 3: 写失败测试** + +在 `tests/data/test_nav_dto.cpp` 末尾加(顶部已有 `#include `?若无则在文件顶部 includes 处加 `#include `): + +```cpp +TEST(NavDto, GroupExceptionsByConsortiumSplitsLooseAndGroups) { + std::vector rows = { + { "e1","空洞A","空洞","t1","c1","体A","溶洞群","" }, + { "e2","空洞B","空洞","t1","c1","","","" }, // 同体 c1,名/类型取首个非空 + { "e3","裂隙X","裂隙","t1","","","","" }, // 独立异常 + { "e4","空洞C","空洞","t1","c2","体B","溶洞群","" }, + }; + const auto g = dto::groupExceptionsByConsortium(rows); + ASSERT_EQ(g.consortia.size(), 2u); + EXPECT_EQ(g.consortia[0].id, "c1"); + EXPECT_EQ(g.consortia[0].name, "体A"); // 首个非空 + EXPECT_EQ(g.consortia[0].typeName, "溶洞群"); + ASSERT_EQ(g.consortia[0].exceptions.size(), 2u); + EXPECT_EQ(g.consortia[1].id, "c2"); + ASSERT_EQ(g.loose.size(), 1u); + EXPECT_EQ(g.loose[0].id, "e3"); +} + +TEST(NavDto, GroupExceptionsAllLooseWhenNoConsortium) { + std::vector rows = { + { "e1","a","t","t1","","","","" }, { "e2","b","t","t1","","","","" } }; + const auto g = dto::groupExceptionsByConsortium(rows); + EXPECT_TRUE(g.consortia.empty()); + EXPECT_EQ(g.loose.size(), 2u); +} +``` + +- [ ] **Step 4: 跑测试确认失败** + +Run: `cmake --build --preset release --target geopro_tests` +Expected: 编译失败(`groupExceptionsByConsortium` 未定义)。 + +- [ ] **Step 5: 在 `NavDto.cpp` 实现** + +`NavDto.cpp` 顶部确保有 `#include `。加: + +```cpp +GroupedExceptions groupExceptionsByConsortium(const std::vector& rows) { + GroupedExceptions out; + std::unordered_map indexById; // consortiumId → out.consortia 下标 + for (const auto& r : rows) { + if (r.consortiumId.empty()) { + out.loose.push_back(r); + continue; + } + auto it = indexById.find(r.consortiumId); + if (it == indexById.end()) { + ConsortiumGroup g; + g.id = r.consortiumId; + g.name = r.consortiumName; + g.typeName = r.consortiumType; + indexById.emplace(r.consortiumId, out.consortia.size()); + out.consortia.push_back(std::move(g)); + it = indexById.find(r.consortiumId); + } + ConsortiumGroup& g = out.consortia[it->second]; + if (g.name.empty() && !r.consortiumName.empty()) g.name = r.consortiumName; // 取首个非空 + if (g.typeName.empty() && !r.consortiumType.empty()) g.typeName = r.consortiumType; + g.exceptions.push_back(r); + } + return out; +} +``` + +- [ ] **Step 6: 跑测试确认通过** + +Run: `cmake --build --preset release --target geopro_tests && ctest --test-dir build/release -C Release -R NavDto --output-on-failure` +Expected: PASS(含 `GroupExceptions*` 两条)。 + +- [ ] **Step 7: 提交** + +```bash +git add src/data/repo/RepoTypes.hpp src/data/dto/NavDto.hpp src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp +git commit -m "feat(data): groupExceptionsByConsortium 按异常体分组 + 独立异常" +``` + +--- + +## Task 4: 仓储接口扩展 + API 实现 + +无单测(依赖 live 后端);以**编译通过**为验证。 + +**Files:** +- Modify: `src/data/repo/IProjectRepository.hpp` +- Modify: `src/data/api/ApiProjectRepository.hpp` +- Modify: `src/data/api/ApiProjectRepository.cpp` + +- [ ] **Step 1: 改 `IProjectRepository.hpp`:`loadTmRows`→`loadRows` + 3 新方法** + +把现有 `loadTmRows` 纯虚声明整段替换为: + +```cpp + // 按结构父节点分页拉数据/文件行:parentConfType 1=GS 2=TM;classifyType 3=数据 1=文件; + // pageNo 从 1 起,pageSize 固定 5。 + virtual RepoResult loadRows(const std::string& projectId, const std::string& parentId, + int parentConfType, int classifyType, int pageNo) = 0; + // 对象详情:confType 1=GS(getGsObjectDetail) 2=TM(tmObject/getDetail) → 动态表单。 + virtual RepoResult loadObjectDetail(const std::string& objectId, int confType) = 0; + // 数据集详情:dsObject/dynamicForm/{dsObjectId} → 动态表单。 + virtual RepoResult loadDatasetForm(const std::string& dsObjectId) = 0; + // 单 TM 异常列表(含异常体归属字段)。 + virtual RepoResult> loadExceptionsByTm(const std::string& tmObjectId) = 0; +``` + +- [ ] **Step 2: 改 `ApiProjectRepository.hpp`:同步声明** + +把 `loadTmRows` 的 `override` 声明整段替换为(保持类内 `public:` 区): + +```cpp + RepoResult loadRows(const std::string& projectId, const std::string& parentId, + int parentConfType, int classifyType, int pageNo) override; + RepoResult loadObjectDetail(const std::string& objectId, int confType) override; + RepoResult loadDatasetForm(const std::string& dsObjectId) override; + RepoResult> loadExceptionsByTm(const std::string& tmObjectId) override; +``` + +- [ ] **Step 3: 改 `ApiProjectRepository.cpp`:实现** + +把现有 `ApiProjectRepository::loadTmRows(...)` 整个函数体替换为下面 4 个函数(`loadRows` 即原 `loadTmRows` 把 `structParentConfType` 从写死 2 改为入参 `parentConfType`): + +```cpp +RepoResult ApiProjectRepository::loadRows(const std::string& projectId, + const std::string& parentId, int parentConfType, + int classifyType, int pageNo) { + const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") + : QStringLiteral("/business/dsObject/data/page"); + const QJsonObject body{ + {QStringLiteral("projectId"), QString::fromStdString(projectId)}, + {QStringLiteral("structParentId"), QString::fromStdString(parentId)}, + {QStringLiteral("structParentConfType"), parentConfType}, + {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, + {QStringLiteral("pageNo"), pageNo}, + {QStringLiteral("pageSize"), 5}}; + const net::ApiResponse r = api_.postJson(path, body); + if (!ok(r)) return {false, {}, errorOf(r, "loadRows failed")}; + return {true, dto::parseDsPage(r.data), {}}; +} + +RepoResult ApiProjectRepository::loadObjectDetail(const std::string& objectId, + int confType) { + const QString path = + (confType == 1) + ? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId)) + : QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId)); + const net::ApiResponse r = api_.get(path); + if (!ok(r)) return {false, {}, errorOf(r, "loadObjectDetail failed")}; + return {true, dto::parseDynamicForm(r.data), {}}; +} + +RepoResult ApiProjectRepository::loadDatasetForm(const std::string& dsObjectId) { + const QString path = + QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId)); + const net::ApiResponse r = api_.get(path); + if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetForm failed")}; + return {true, dto::parseDynamicForm(r.data), {}}; +} + +RepoResult> ApiProjectRepository::loadExceptionsByTm( + const std::string& tmObjectId) { + const QString path = + QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId)); + const net::ApiResponse r = api_.get(path); + if (!ok(r)) return {false, {}, errorOf(r, "loadExceptionsByTm failed")}; + return {true, dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()), {}}; +} +``` + +> 注:`queryExceptionByTmObjectId` 的数组位置(`data` 直接是数组 vs `data.value("value")`)待 live 验证(§12.3);若 `data` 本身即数组,改为 `r.data` 取数组的方式。当前按 `data.value("value")` 写(与 listWorkspaces 同约定)。 + +- [ ] **Step 4: 构建确认通过** + +Run: `cmake --build --preset release --target geopro_desktop` +Expected: 编译失败 —— 因 `WorkbenchNavController.cpp` 仍调用旧 `loadTmRows`。**这是预期**,Task 5 修复。先单独验证 data 库编译: +Run: `cmake --build --preset release --target geopro_data` +Expected: PASS。 + +- [ ] **Step 5: 提交** + +```bash +git add src/data/repo/IProjectRepository.hpp src/data/api/ApiProjectRepository.hpp src/data/api/ApiProjectRepository.cpp +git commit -m "feat(data): 仓储泛化 loadRows + 对象/数据集详情 + 按TM异常 接口实现" +``` + +--- + +## Task 5: 控制器编排(selectObject / setCheckedTms / selectDataset) + +无单测;以**编译通过 + 后续手动联调**验证。 + +**Files:** +- Modify: `src/controller/WorkbenchNavController.hpp` +- Modify: `src/controller/WorkbenchNavController.cpp` + +- [ ] **Step 1: 改 `WorkbenchNavController.hpp`** + +头部 includes 加: + +```cpp +#include +#include +``` + +把 `public slots:` 区的 `void selectTm(const QString& tmObjectId);` 替换为: + +```cpp + void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情 + void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树 + void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单 +``` + +`signals:` 区在 `datasetsLoaded/filesLoaded` 之后加: + +```cpp + void objectDetailLoaded(const QString& title, const geopro::data::DynamicForm& form); + void exceptionTreeLoaded(const std::vector& groups, int tmCount); + void datasetDetailLoaded(const geopro::data::DynamicForm& form); +``` + +`private:` 区:把 `std::string currentTmId_;` 替换为: + +```cpp + std::string currentParentId_; + int currentParentConfType_ = 0; + std::vector lastStructNodes_; // tmId→name 解析 + std::map> tmExceptionCache_; +``` + +- [ ] **Step 2: 改 `WorkbenchNavController.cpp` — 留存结构节点** + +在 `loadProjectsAndStructure()` 里,把 `emit structureLoaded(QString::fromStdString(currentProjectName_), st.value);` **之前**加: + +```cpp + lastStructNodes_ = st.value; +``` + +同样在 `switchProject(...)` 里 `emit structureLoaded(...)` **之前**加 `lastStructNodes_ = st.value;`。 +并在这两处的「暂无项目 → 空树」分支(`emit structureLoaded(QString(), {});`)前加 `lastStructNodes_.clear();`、`tmExceptionCache_.clear();`。 + +- [ ] **Step 3: 改 `WorkbenchNavController.cpp` — 用 selectObject 取代 selectTm** + +把整个 `WorkbenchNavController::selectTm(...)` 函数替换为: + +```cpp +void WorkbenchNavController::selectObject(const QString& objectId, int confType) { + if (objectId.isEmpty() || busy_) return; + BusyGuard guard(this, &busy_); + currentParentId_ = objectId.toStdString(); + currentParentConfType_ = confType; + const std::string pid = currentProjectId_; + dataPageNo_ = 1; + filePageNo_ = 1; + const auto d = repo_.loadRows(pid, currentParentId_, confType, 3, dataPageNo_); + if (!d.ok) { + emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error)); + return; + } + dataTotal_ = d.value.total; + emit datasetsLoaded(objectId, d.value.rows, d.value.total, false); + const auto f = repo_.loadRows(pid, currentParentId_, confType, 1, filePageNo_); + if (!f.ok) { + emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error)); + return; + } + fileTotal_ = f.value.total; + emit filesLoaded(objectId, f.value.rows, f.value.total, false); + + const auto detail = repo_.loadObjectDetail(currentParentId_, confType); + if (!detail.ok) { + emit loadFailed(QStringLiteral("objectDetail"), QString::fromStdString(detail.error)); + return; + } + emit objectDetailLoaded(objectId, detail.value); +} +``` + +- [ ] **Step 4: 改 `WorkbenchNavController.cpp` — loadMoreData/Files 用泛化 parent** + +把 `loadMoreData()` 里的守卫与调用: +`if (currentTmId_.empty() || busy_) return;` → `if (currentParentId_.empty() || busy_) return;` +`repo_.loadTmRows(currentProjectId_, currentTmId_, 3, ++dataPageNo_)` → +`repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 3, ++dataPageNo_)` +`emit datasetsLoaded(QString::fromStdString(currentTmId_), ...)` → +`emit datasetsLoaded(QString::fromStdString(currentParentId_), ...)` + +`loadMoreFiles()` 同样:`currentTmId_`→`currentParentId_`、`loadTmRows(...,1,...)`→`loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 1, ++filePageNo_)`、emit 的 id 改 `currentParentId_`。 + +- [ ] **Step 5: 改 `WorkbenchNavController.cpp` — 新增 setCheckedTms / selectDataset** + +`.cpp` 顶部加(若无): + +```cpp +#include "dto/NavDto.hpp" +``` + +在文件末尾 `} // namespace geopro::controller` **之前**加: + +```cpp +void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { + if (busy_) return; + BusyGuard guard(this, &busy_); + // tmId → 名称(自留存的扁平结构解析;找不到回退用 id)。 + auto nameOf = [this](const std::string& id) -> std::string { + for (const auto& n : lastStructNodes_) + if (n.id == id) return n.name; + return id; + }; + std::vector groups; + int total = 0; + for (const QString& tmQ : tmObjectIds) { + const std::string tm = tmQ.toStdString(); + auto it = tmExceptionCache_.find(tm); + if (it == tmExceptionCache_.end()) { + const auto ex = repo_.loadExceptionsByTm(tm); + if (!ex.ok) { + emit loadFailed(QStringLiteral("exceptions"), QString::fromStdString(ex.error)); + return; + } + it = tmExceptionCache_.emplace(tm, ex.value).first; + } + const auto grouped = data::dto::groupExceptionsByConsortium(it->second); + data::ObjectExceptionGroup g; + g.objectId = tm; + g.objectName = nameOf(tm); + g.consortia = grouped.consortia; + g.looseExceptions = grouped.loose; + total += static_cast(it->second.size()); + groups.push_back(std::move(g)); + } + emit exceptionTreeLoaded(groups, total); +} + +void WorkbenchNavController::selectDataset(const QString& dsObjectId) { + if (dsObjectId.isEmpty() || busy_) return; + BusyGuard guard(this, &busy_); + const auto form = repo_.loadDatasetForm(dsObjectId.toStdString()); + if (!form.ok) { + emit loadFailed(QStringLiteral("datasetDetail"), QString::fromStdString(form.error)); + return; + } + emit datasetDetailLoaded(form.value); +} +``` + +- [ ] **Step 6: 构建确认(仍会因 main.cpp 旧接线失败)** + +Run: `cmake --build --preset release --target geopro_controller` +Expected: PASS(controller 库单独编译通过)。 + +- [ ] **Step 7: 提交** + +```bash +git add src/controller/WorkbenchNavController.hpp src/controller/WorkbenchNavController.cpp +git commit -m "feat(controller): selectObject/setCheckedTms/selectDataset 编排 + 异常缓存" +``` + +--- + +## Task 6: `DynamicFormView` 共享键值渲染器(新) + +**Files:** +- Create: `src/app/panels/DynamicFormView.hpp` +- Create: `src/app/panels/DynamicFormView.cpp` +- Modify: `src/app/CMakeLists.txt` + +- [ ] **Step 1: 写 `DynamicFormView.hpp`** + +```cpp +#pragma once +#include +#include "repo/RepoTypes.hpp" + +class QVBoxLayout; + +namespace geopro::app { + +// 被动:渲染 DynamicForm(分组键值)。对象属性 / 数据集属性两面板共用。 +class DynamicFormView : public QWidget { +public: + explicit DynamicFormView(QWidget* parent = nullptr); + void setForm(const geopro::data::DynamicForm& form); + void showMessage(const QString& message); // 空/错占位 + +private: + void clear(); + QVBoxLayout* content_ = nullptr; // 滚动区内容布局 +}; + +} // namespace geopro::app +``` + +- [ ] **Step 2: 写 `DynamicFormView.cpp`** + +```cpp +#include "panels/DynamicFormView.hpp" + +#include +#include +#include +#include + +#include "Theme.hpp" + +namespace geopro::app { + +DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) { + auto* outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + outer->setSpacing(0); + + auto* scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + auto* host = new QWidget(); + content_ = new QVBoxLayout(host); + content_->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, + geopro::app::space::kLg, geopro::app::space::kMd); + content_->setSpacing(geopro::app::space::kSm); + content_->addStretch(); + scroll->setWidget(host); + outer->addWidget(scroll); + + showMessage(QStringLiteral("(选中后显示属性详情)")); +} + +void DynamicFormView::clear() { + while (content_->count() > 0) { + QLayoutItem* it = content_->takeAt(0); + if (it->widget()) it->widget()->deleteLater(); + delete it; + } +} + +void DynamicFormView::showMessage(const QString& message) { + clear(); + auto* hint = new QLabel(message); + hint->setAlignment(Qt::AlignCenter); + geopro::app::applyTokenizedStyleSheet(hint, + QStringLiteral("color:{{text/disabled}}; padding:16px;")); + content_->addWidget(hint); + content_->addStretch(); +} + +void DynamicFormView::setForm(const geopro::data::DynamicForm& form) { + clear(); + if (form.groups.empty()) { + showMessage(QStringLiteral("(暂无属性)")); + return; + } + for (const auto& group : form.groups) { + auto* title = new QLabel(QString::fromStdString(group.name)); + geopro::app::applyTokenizedStyleSheet( + title, QStringLiteral("color:{{text/secondary}}; font-weight:%1; padding-top:6px;") + .arg(geopro::app::type::kWeightSemibold)); + content_->addWidget(title); + + auto* form_w = new QWidget(); + auto* fl = new QFormLayout(form_w); + fl->setContentsMargins(0, 0, 0, 0); + fl->setLabelAlignment(Qt::AlignLeft | Qt::AlignTop); + fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + for (const auto& f : group.fields) { + auto* k = new QLabel(QString::fromStdString(f.name)); + geopro::app::applyTokenizedStyleSheet(k, QStringLiteral("color:{{text/tertiary}};")); + auto* v = new QLabel(QString::fromStdString(f.value)); + v->setWordWrap(true); + geopro::app::applyTokenizedStyleSheet(v, QStringLiteral("color:{{text/primary}};")); + fl->addRow(k, v); + } + content_->addWidget(form_w); + } + content_->addStretch(); +} + +} // namespace geopro::app +``` + +> 注:`Theme.hpp` 提供 `applyTokenizedStyleSheet` / `space::*` / `type::*`(已被其它面板使用,见 `ObjectTreePanel.cpp`)。若 `text/tertiary` 令牌不存在,改用 `text/secondary`(实现时按 `Theme.hpp` 实际令牌名校正)。 + +- [ ] **Step 3: 加入构建** + +`src/app/CMakeLists.txt` 的 `add_executable(geopro_desktop WIN32 ...)` 源列表里,`panels/ObjectTreePanel.cpp` 之后加一行: + +```cmake + panels/DynamicFormView.cpp +``` + +- [ ] **Step 4: 构建确认通过** + +Run: `cmake --preset msvc-release && cmake --build --preset release --target geopro_desktop` +Expected: 仍会因 main.cpp 旧接线失败属正常;**但 `DynamicFormView.cpp` 本身不应有编译错误**(错误只应来自 main.cpp 的 selectTm/tmClicked)。确认错误仅来自 main.cpp。 + +- [ ] **Step 5: 提交** + +```bash +git add src/app/panels/DynamicFormView.hpp src/app/panels/DynamicFormView.cpp src/app/CMakeLists.txt +git commit -m "feat(ui): DynamicFormView 动态表单分组键值渲染器" +``` + +--- + +## Task 7: `ObjectExceptionPanel` 异常+异常体只读树(新) + +**Files:** +- Create: `src/app/panels/ObjectExceptionPanel.hpp` +- Create: `src/app/panels/ObjectExceptionPanel.cpp` +- Modify: `src/app/CMakeLists.txt` + +- [ ] **Step 1: 写 `ObjectExceptionPanel.hpp`** + +```cpp +#pragma once +#include +#include +#include "repo/RepoTypes.hpp" + +class QTreeWidget; +class QLabel; + +namespace geopro::app { + +// 被动:异常 + 异常体 只读树(对象→异常体→异常→详情 + 独立异常)。数据由控制器推送。 +class ObjectExceptionPanel : public QWidget { +public: + explicit ObjectExceptionPanel(QWidget* parent = nullptr); + void setGroups(const std::vector& groups); + void showMessage(const QString& message); + +private: + QTreeWidget* tree_ = nullptr; + QLabel* hint_ = nullptr; +}; + +} // namespace geopro::app +``` + +- [ ] **Step 2: 写 `ObjectExceptionPanel.cpp`** + +```cpp +#include "panels/ObjectExceptionPanel.hpp" + +#include +#include +#include +#include + +#include "Theme.hpp" + +namespace geopro::app { + +namespace { +QTreeWidgetItem* addException(QTreeWidgetItem* parent, const geopro::data::ExceptionRow& e) { + auto* item = new QTreeWidgetItem(parent); + const QString type = + e.typeName.empty() ? QString() : QStringLiteral("(%1)").arg(QString::fromStdString(e.typeName)); + item->setText(0, QString::fromStdString(e.name) + type); + // 详情展开(D6):异常下挂一个详情子项,显示已加载摘要(多行)。 + if (!e.detailSummary.empty()) { + auto* detail = new QTreeWidgetItem(item); + detail->setText(0, QString::fromStdString(e.detailSummary)); + detail->setFirstColumnSpanned(true); + detail->setForeground(0, geopro::app::tokenColor("text/tertiary")); + } + return item; +} +} // namespace + +ObjectExceptionPanel::ObjectExceptionPanel(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); + + tree_ = new QTreeWidget(this); + tree_->setHeaderHidden(true); + tree_->setIndentation(14); + lay->addWidget(tree_, 1); + + hint_ = new QLabel(QStringLiteral("(勾选对象后显示其异常 / 异常体)"), this); + hint_->setAlignment(Qt::AlignCenter); + geopro::app::applyTokenizedStyleSheet(hint_, QStringLiteral("color:{{text/disabled}}; padding:16px;")); + lay->addWidget(hint_); + tree_->setVisible(false); +} + +void ObjectExceptionPanel::setGroups( + const std::vector& groups) { + tree_->clear(); + bool any = false; + for (const auto& g : groups) { + auto* objItem = new QTreeWidgetItem(tree_); + objItem->setText(0, QString::fromStdString(g.objectName)); + // 异常体分组 + for (const auto& c : g.consortia) { + auto* cItem = new QTreeWidgetItem(objItem); + const QString ctype = c.typeName.empty() + ? QString() + : QStringLiteral("(%1)").arg(QString::fromStdString(c.typeName)); + cItem->setText(0, QStringLiteral("异常体 %1%2") + .arg(QString::fromStdString(c.name.empty() ? c.id : c.name)) + .arg(ctype)); + for (const auto& e : c.exceptions) { addException(cItem, e); any = true; } + } + // 独立异常 + if (!g.looseExceptions.empty()) { + auto* looseItem = new QTreeWidgetItem(objItem); + looseItem->setText(0, QStringLiteral("独立异常(未合并)")); + for (const auto& e : g.looseExceptions) { addException(looseItem, e); any = true; } + } + } + if (!any) { + showMessage(QStringLiteral("(所选对象暂无异常)")); + return; + } + hint_->setVisible(false); + tree_->setVisible(true); + tree_->expandAll(); +} + +void ObjectExceptionPanel::showMessage(const QString& message) { + tree_->clear(); + tree_->setVisible(false); + hint_->setText(message); + hint_->setVisible(true); +} + +} // namespace geopro::app +``` + +> `tokenColor(...)` 已被 `AnomalyListPanel.cpp` 使用,来自 `Theme.hpp`。 + +- [ ] **Step 3: 加入构建** + +`src/app/CMakeLists.txt` 源列表里 `panels/DynamicFormView.cpp` 之后加: + +```cmake + panels/ObjectExceptionPanel.cpp +``` + +- [ ] **Step 4: 构建确认(仅 main.cpp 报错为正常)** + +Run: `cmake --preset msvc-release && cmake --build --preset release --target geopro_desktop` +Expected: 错误仅来自 main.cpp 旧接线;`ObjectExceptionPanel.cpp` 自身无编译错误。 + +- [ ] **Step 5: 提交** + +```bash +git add src/app/panels/ObjectExceptionPanel.hpp src/app/panels/ObjectExceptionPanel.cpp src/app/CMakeLists.txt +git commit -m "feat(ui): ObjectExceptionPanel 异常+异常体只读树(含详情展开)" +``` + +--- + +## Task 8: `ObjectTreePanel` GS 三态勾选 + 新信号 + +**Files:** +- Modify: `src/app/panels/ObjectTreePanel.hpp` +- Modify: `src/app/panels/ObjectTreePanel.cpp` + +- [ ] **Step 1: 改 `ObjectTreePanel.hpp` 信号** + +把 `signals:` 区两行替换为: + +```cpp +signals: + // confType: 1=GS 2=TM。单击行(驱动数据列表 + 对象属性)。 + void objectClicked(const QString& objectId, int confType); + // 当前全部被勾选的 TM 叶子 id(已合并发射)。 + void checkedTmsChanged(const QStringList& tmObjectIds); +``` + +头部 includes 加 `#include `。 + +- [ ] **Step 2: 改 `ObjectTreePanel.cpp` 节点构建(GS 三态可勾选 + 存 confType)** + +`anonymous namespace` 内加一个 confType 角色常量,并改 `addNodes`。把现有: + +```cpp +constexpr int kRoleTmId = Qt::UserRole + 2; + +void addNodes(QTreeWidgetItem* parent, const std::vector& nodes) { + for (const auto& n : nodes) { + auto* item = new QTreeWidgetItem(parent); + item->setText(0, QString::fromStdString(n.node.name)); + if (n.isTm) { + item->setData(0, kRoleTmId, QString::fromStdString(n.node.id)); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(0, Qt::Unchecked); // 真实数据渲染下一轮接入,默认不勾 + } + addNodes(item, n.children); + } +} +``` + +替换为: + +```cpp +constexpr int kRoleObjId = Qt::UserRole + 2; // 节点对象 id(GS/TM 都存) +constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM + +void addNodes(QTreeWidgetItem* parent, const std::vector& nodes) { + for (const auto& n : nodes) { + auto* item = new QTreeWidgetItem(parent); + item->setText(0, QString::fromStdString(n.node.name)); + item->setData(0, kRoleObjId, QString::fromStdString(n.node.id)); + if (n.isTm) { + item->setData(0, kRoleConfType, 2); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(0, Qt::Unchecked); + } else { + item->setData(0, kRoleConfType, 1); // GS + item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); + item->setCheckState(0, Qt::Unchecked); + } + addNodes(item, n.children); + } +} +``` + +- [ ] **Step 3: 改 `ObjectTreePanel.cpp` 信号连接(单击 + 合并发射勾选叶子集)** + +构造函数里把现有两个 `QObject::connect(tree_, ...)`(`itemClicked` / `itemChanged`)整段替换为: + +```cpp + QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) { + const QString id = item->data(0, kRoleObjId).toString(); + const int confType = item->data(0, kRoleConfType).toInt(); + if (!id.isEmpty() && confType != 0) emit objectClicked(id, confType); + }); + // 勾选变化:GS 级联会触发多次 itemChanged,用 0ms 单发合并成一次「收集勾选叶子并发射」。 + QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) { + if (checkPending_) return; + checkPending_ = true; + QTimer::singleShot(0, this, [this]() { + checkPending_ = false; + QStringList tmIds; + std::function walk = [&](QTreeWidgetItem* node) { + for (int i = 0; i < node->childCount(); ++i) { + QTreeWidgetItem* c = node->child(i); + if (c->data(0, kRoleConfType).toInt() == 2 && c->checkState(0) == Qt::Checked) + tmIds << c->data(0, kRoleObjId).toString(); + walk(c); + } + }; + walk(tree_->invisibleRootItem()); + emit checkedTmsChanged(tmIds); + }); + }); +``` + +头部 includes 加 `#include ` 与 `#include `。 + +- [ ] **Step 4: 改 `ObjectTreePanel.hpp` 加合并标志** + +`private:` 区加: + +```cpp + bool checkPending_ = false; // 勾选合并发射防重入 +``` + +- [ ] **Step 5: 构建确认(仅 main.cpp 报错为正常)** + +Run: `cmake --build --preset release --target geopro_desktop` +Expected: 错误仅来自 main.cpp(仍连 `tmClicked`/`selectTm`);`ObjectTreePanel.cpp` 自身无错误。 + +- [ ] **Step 6: 提交** + +```bash +git add src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp +git commit -m "feat(ui): ObjectTreePanel GS三态勾选 + objectClicked/checkedTmsChanged 合并发射" +``` + +--- + +## Task 9: main.cpp 接线 + 移除占位 + +**Files:** +- Modify: `src/app/main.cpp` + +- [ ] **Step 1: 头文件** + +`main.cpp` 顶部 includes 区,`#include "panels/DatasetListPanel.hpp"` 之后加: + +```cpp +#include "panels/DynamicFormView.hpp" +#include "panels/ObjectExceptionPanel.hpp" +``` + +- [ ] **Step 2: 右上面板:异常列表 → ObjectExceptionPanel;对象属性 → DynamicFormView** + +找到右上 dock 构建处(`auto* anomalyList = new QListWidget();` 起,到 `auto* rightArea = dockManager->addDockWidget(ads::RightDockWidgetArea, rightDock);`)。把: + +```cpp + auto* anomalyList = new QListWidget(); + geopro::app::applyAnomalyCardDelegate(anomalyList); + auto* objAttrLabel = new QLabel(QStringLiteral("(选中对象后显示其属性)")); + objAttrLabel->setWordWrap(true); + objAttrLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); + objAttrLabel->setMargin(8); + + auto anomalyPanel = geopro::app::buildTabbedPanel( + {{geopro::app::Glyph::Anomaly, QStringLiteral("异常"), anomalyList, true}, + {geopro::app::Glyph::Property, QStringLiteral("对象属性"), objAttrLabel, false}}, + {{geopro::app::Glyph::Filter, QStringLiteral("筛选")}, + {geopro::app::Glyph::Plus, QStringLiteral("添加异常")}}); + auto* anomalyBadge = anomalyPanel.badges.value(0); // 异常列表 Tab 的数量徽标 +``` + +替换为: + +```cpp + auto* exceptionPanel = new geopro::app::ObjectExceptionPanel(); + auto* objAttrView = new geopro::app::DynamicFormView(); + + auto anomalyPanel = geopro::app::buildTabbedPanel( + {{geopro::app::Glyph::Anomaly, QStringLiteral("对象异常"), exceptionPanel, true}, + {geopro::app::Glyph::Property, QStringLiteral("对象属性"), objAttrView, false}}, + {{geopro::app::Glyph::Filter, QStringLiteral("筛选")}, + {geopro::app::Glyph::Plus, QStringLiteral("添加异常")}}); + auto* anomalyBadge = anomalyPanel.badges.value(0); +``` + +> 紧随其后那段对 `anomalyBadge` 改 `objectName` + unpolish/polish 的代码**保留不动**(仍对徽标着色)。 + +- [ ] **Step 3: 右下面板:数据集属性 QLabel → DynamicFormView** + +找到: + +```cpp + auto* propLabel = new QLabel(QStringLiteral("(单击左侧数据集查看属性与平面剖面)")); + propLabel->setWordWrap(true); + propLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); + propLabel->setMargin(8); + auto* propDock = new ads::CDockWidget(QStringLiteral("数据集属性")); + propDock->setWidget( + wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propLabel)); +``` + +替换为: + +```cpp + auto* propView = new geopro::app::DynamicFormView(); + auto* propDock = new ads::CDockWidget(QStringLiteral("数据集属性")); + propDock->setWidget( + wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propView)); +``` + +- [ ] **Step 4: 删除已停用的 loadDataset/旧异常列表接线** + +删除 `loadDataset` lambda 整段(从 `auto loadDataset = [&repo, propLabel, currentDsId, ...` 到其结束 `};` 以及紧随的 `(void)loadDataset;` 与上方相关 TODO 注释)。 +删除「异常列表勾选(显隐)」整段:`QObject::connect(anomalyList, &QListWidget::itemChanged, ...)`。 + +> 这些都引用了被移除的 `anomalyList`/`propLabel`/`anomalyBadge` 旧用法或已 park 代码。`rebuildDetail`/`currentDsId` 等中央/详情渲染相关保留(仍被主题切换连接使用)。 + +- [ ] **Step 5: 数据集单击 → selectDataset(替换占位文案)** + +把现有数据集单击连接: + +```cpp + QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, + [propLabel, detailRendererPtr, detailRenderWindowPtr, &nav](QListWidgetItem* item) { + if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { + nav.loadMoreData(); + return; + } + const QString name = + item->data(Qt::DisplayRole).toString().section('\n', 0, 0); + detailRendererPtr->RemoveAllViewProps(); + detailRenderWindowPtr->Render(); + propLabel->setText(QStringLiteral( + "数据集: %1\n(该数据集的剖面/反演渲染将在下一阶段接入 dd 接口)").arg(name)); + }); +``` + +替换为: + +```cpp + QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, + [&nav](QListWidgetItem* item) { + if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { + nav.loadMoreData(); + return; + } + const QString dsId = item->data(geopro::app::kDsIdRole).toString(); + if (!dsId.isEmpty()) nav.selectDataset(dsId); + }); +``` + +- [ ] **Step 6: 对象树信号接线(objectClicked / checkedTmsChanged)** + +把: + +```cpp + QObject::connect(objectTree, &geopro::app::ObjectTreePanel::tmClicked, &nav, + &geopro::controller::WorkbenchNavController::selectTm); +``` + +替换为: + +```cpp + QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectClicked, &nav, + &geopro::controller::WorkbenchNavController::selectObject); + QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &nav, + &geopro::controller::WorkbenchNavController::setCheckedTms); +``` + +- [ ] **Step 7: 控制器新信号 → 面板** + +在控制器↔UI 接线区(`structureLoaded` 连接附近)加: + +```cpp + QObject::connect(&nav, &geopro::controller::WorkbenchNavController::objectDetailLoaded, objAttrView, + [objAttrView](const QString&, const geopro::data::DynamicForm& form) { + objAttrView->setForm(form); + }); + QObject::connect(&nav, &geopro::controller::WorkbenchNavController::exceptionTreeLoaded, + exceptionPanel, + [exceptionPanel, anomalyBadge]( + const std::vector& groups, int total) { + exceptionPanel->setGroups(groups); + if (anomalyBadge) { + anomalyBadge->setText(QString::number(total)); + anomalyBadge->setVisible(total > 0); + } + }); + QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetDetailLoaded, propView, + [propView](const geopro::data::DynamicForm& form) { propView->setForm(form); }); +``` + +- [ ] **Step 8: 切项目/空间清空三面板** + +在 `structureLoaded` 的现有 lambda(`objectTree->setStructure(...)` 那个)里,捕获列表加 `exceptionPanel, objAttrView, propView, anomalyBadge`,并在 `datasetList->clear(); fileList->clear();` 之后加: + +```cpp + exceptionPanel->showMessage(QStringLiteral("(勾选对象后显示其异常 / 异常体)")); + objAttrView->showMessage(QStringLiteral("(选中对象后显示其属性)")); + propView->showMessage(QStringLiteral("(单击数据集查看属性)")); + if (anomalyBadge) anomalyBadge->setVisible(false); +``` + +- [ ] **Step 9: 构建确认通过** + +Run: `cmake --build --preset release --target geopro_desktop` +Expected: PASS(无编译错误)。若报 `propLabel`/`anomalyList`/`loadDataset` 未定义残留引用,按 Step 4 清干净。 + +- [ ] **Step 10: 全量测试 + 提交** + +Run: `cmake --build --preset release --target geopro_tests && ctest --test-dir build/release -C Release --output-on-failure` +Expected: 全绿。 + +```bash +git add src/app/main.cpp +git commit -m "feat(ui): 接线 对象单击/勾选/数据集单击 → 三面板(移除占位)" +``` + +--- + +## Task 10: Live 联调验证(§12 验证点)+ 修正 + +无单测;手动运行桌面程序验证。**逐项验证,发现不符按指引改一处。** + +- [ ] **Step 1: 运行程序,登录进入工作台** + +Run: 启动 `build/release` 下的 `geopro_desktop.exe`(或在 IDE 运行)。登录后确认对象树加载真实结构。 + +- [ ] **Step 2: 验证「单击对象」** + +单击一个 TM:左下数据/文件列表出现该 TM 的 DS;右上「对象属性」出现动态表单分组键值。 +单击一个 GS:左下出现该 GS 下 DS。 +- 若 GS 单击左下为空 → §12.1:`structParentConfType=1` 不被支持。改 `ApiProjectRepository::loadRows`:当 `parentConfType==1` 时,改为对该 GS 直接子 TM 逐个查 `data/page` 合并(需控制器传子 TM 列表,或在仓储内先 `loadStructure` 取子 TM)。**先记录现象,必要时回到 Task 4/5 调整。** + +- [ ] **Step 3: 验证「对象属性 / 数据集属性」键值与显示名** + +确认字段显示名为中文、值正确。 +- 若值全空 → §12.2:`properties` 非按 `fieldCode` 索引。用浏览器/抓包看 `getDetail` 真实返回,定位真实索引键,改 `parseDynamicForm` 里 `props.value(code)` 的 `code` 来源(仅这一处)。重跑 Task 1 单测(按真实键调整测试 JSON)。 + +- [ ] **Step 4: 验证「勾选 → 异常 + 异常体树」** + +勾选 TM/GS:右上「对象异常」出现「对象→异常体→异常→详情 / 独立异常」树;徽标显示总数;取消勾选对应异常消失。 +- 若异常体层始终为空(全进独立异常)→ §12.3:异常 payload 不带 `consortiumId/Name/Type`。两种修法择一: + - (a) 若 payload 用别的字段名(如 `parentId`+`parentConfType`),改 `parseExceptions` 三个 `str(o,"consortium*")` 的键名;重跑 Task 2 单测(按真实键调整)。 + - (b) 若需项目级映射,在 `ApiProjectRepository` 增 `getExceptionConsortiumTree/{projectId}` 拉取并在控制器装配时套用映射(回退方案,§9)。 + +- [ ] **Step 5: 验证异常数组位置** + +若 Step 4 异常完全为空且无错 → 检查 `loadExceptionsByTm` 的数组取法:把 `r.data.value("value").toArray()` 改为 `r.data` 直接当数组(部分接口 `data` 即数组)。按真实返回定。 + +- [ ] **Step 6: 回归 + 收尾提交** + +Run: `cmake --build --preset release --target geopro_tests && ctest --test-dir build/release -C Release --output-on-failure` +Expected: 全绿。 + +```bash +git add -A +git commit -m "fix(data): 按 live 联调校正 GS数据/属性键/异常体归属 映射" +``` + +> 若本步无改动则跳过提交。 + +--- + +## 自审清单(写计划后核对,已逐条检查) + +- **Spec 覆盖**:D1(真实API)=Task1-5;D2(GS三态)=Task8;D3(单击高亮)=Task8 itemClicked + Task9 objectDetail;D4(动态表单)=Task1+6;D5(异常体只读树)=Task2-3+7;D6(详情展开做/眼睛推迟)=Task2 detailSummary + Task7 详情子项。三件交互=Task9 接线。验证点=Task10。 +- **占位扫描**:无 TBD/TODO 式步骤;每个代码步给出完整代码。 +- **类型一致**:`loadRows`(非 loadTmRows)、`objectClicked`/`checkedTmsChanged`、`selectObject`/`setCheckedTms`/`selectDataset`、`exceptionTreeLoaded`、`DynamicForm`/`ObjectExceptionGroup`/`ExceptionRow` 各任务间签名一致;`kDsIdRole` 取自 `DatasetListPanel.hpp`(已有)。 +- **保留不删**:`AnomalyListPanel`(populateAnomalyList/眼睛/VTK)、`render/*`、`LocalSampleRepository`、`rebuildDetail` 均保留。 -- 2.40.1 From 0018c11e0af045f979e2cfab53a0144ccade6120 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 19:56:55 +0800 Subject: [PATCH 04/18] =?UTF-8?q?feat(data):=20parseDynamicForm=20?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=8A=A8=E6=80=81=E8=A1=A8=E5=8D=95=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=AE=9A=E4=B9=89=E4=B8=8E=E5=80=BC=EF=BC=88=E5=90=AB?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/dto/NavDto.cpp | 38 +++++++++++++++++++++++++++++++++++++ src/data/dto/NavDto.hpp | 5 +++++ src/data/repo/RepoTypes.hpp | 5 +++++ tests/data/test_nav_dto.cpp | 34 +++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+) diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index ef2adcd..f4cf365 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -154,4 +155,41 @@ std::vector buildStructTree(const std::vector& flat) return build(std::string(), true); } +DynamicForm parseDynamicForm(const QJsonObject& data) { + DynamicForm form; + form.name = str(data, "name"); + const QJsonObject props = data.value(QStringLiteral("properties")).toObject(); + + QJsonArray groups = data.value(QStringLiteral("formList")).toArray(); + std::vector gv; + gv.reserve(static_cast(groups.size())); + for (const QJsonValue& g : groups) gv.push_back(g.toObject()); + std::stable_sort(gv.begin(), gv.end(), [](const QJsonObject& a, const QJsonObject& b) { + return a.value(QStringLiteral("groupSort")).toInt() < + b.value(QStringLiteral("groupSort")).toInt(); + }); + + for (const QJsonObject& g : gv) { + DynamicFormGroup group; + group.name = str(g, "groupName"); + QJsonArray vals = g.value(QStringLiteral("values")).toArray(); + std::vector fv; + fv.reserve(static_cast(vals.size())); + for (const QJsonValue& v : vals) fv.push_back(v.toObject()); + std::stable_sort(fv.begin(), fv.end(), [](const QJsonObject& a, const QJsonObject& b) { + return a.value(QStringLiteral("displaySort")).toInt() < + b.value(QStringLiteral("displaySort")).toInt(); + }); + for (const QJsonObject& f : fv) { + DynamicFormField field; + field.name = str(f, "fieldName"); + const QString code = f.value(QStringLiteral("fieldCode")).toString(); + field.value = props.value(code).toVariant().toString().toStdString(); + group.fields.push_back(std::move(field)); + } + form.groups.push_back(std::move(group)); + } + return form; +} + } // namespace geopro::data::dto diff --git a/src/data/dto/NavDto.hpp b/src/data/dto/NavDto.hpp index 6a3c2b7..ac3ad91 100644 --- a/src/data/dto/NavDto.hpp +++ b/src/data/dto/NavDto.hpp @@ -38,4 +38,9 @@ struct StructTreeNode { }; std::vector buildStructTree(const std::vector& flat); +// DynamicFormVO 对象 → DynamicForm:合并 formList(字段定义) + properties(值)。 +// 组按 groupSort、字段按 displaySort 排序;值取 properties[fieldCode](缺失→空串)。 +// 表头 name 取 data["name"]。 +DynamicForm parseDynamicForm(const QJsonObject& data); + } // namespace geopro::data::dto diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index 1bb99a6..bc31bfa 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -32,4 +32,9 @@ struct ProjectListPage { std::vector rows; int total = 0; }; // 项目结构扁平节点(仅 GS / TM)。客户端按 parentId 建树,叶子=TM。 struct StructNode { std::string id, name, parentId, typeName, confCode; int type = 0; }; + +// 动态表单(GS/TM/DS 详情统一模型)。值已与字段定义合并、已按 sort 排好序。 +struct DynamicFormField { std::string name, value; }; +struct DynamicFormGroup { std::string name; std::vector fields; }; +struct DynamicForm { std::string name; std::vector groups; }; } // namespace geopro::data diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index 2f0e281..03d5c36 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -201,3 +201,37 @@ TEST(NavDto, ParseProjectPageAndTypes) { EXPECT_EQ(types[0].id, "t1"); EXPECT_EQ(types[0].name, "全量类型"); } + +TEST(NavDto, ParseDynamicFormMergesFieldsValuesAndSorts) { + const auto data = objOf(R"({ + "name": "测线1", + "properties": { "depth": "120", "len": "300", "owner": "张三" }, + "formList": [ + { "groupName": "几何", "groupSort": 1, "values": [ + { "fieldName": "长度", "fieldCode": "len", "displaySort": 2 }, + { "fieldName": "深度", "fieldCode": "depth", "displaySort": 1 } + ]}, + { "groupName": "归属", "groupSort": 2, "values": [ + { "fieldName": "负责人", "fieldCode": "owner", "displaySort": 1 }, + { "fieldName": "缺失项", "fieldCode": "nope", "displaySort": 2 } + ]} + ] + })"); + const auto form = dto::parseDynamicForm(data); + EXPECT_EQ(form.name, "测线1"); + ASSERT_EQ(form.groups.size(), 2u); + EXPECT_EQ(form.groups[0].name, "几何"); + ASSERT_EQ(form.groups[0].fields.size(), 2u); + EXPECT_EQ(form.groups[0].fields[0].name, "深度"); + EXPECT_EQ(form.groups[0].fields[0].value, "120"); + EXPECT_EQ(form.groups[0].fields[1].name, "长度"); + EXPECT_EQ(form.groups[0].fields[1].value, "300"); + EXPECT_EQ(form.groups[1].fields[1].value, ""); +} + +TEST(NavDto, ParseDynamicFormEmptyFormListYieldsNoGroups) { + const auto data = objOf(R"({ "name":"空", "properties":{}, "formList":[] })"); + const auto form = dto::parseDynamicForm(data); + EXPECT_EQ(form.name, "空"); + EXPECT_TRUE(form.groups.empty()); +} -- 2.40.1 From acf71bdaef5696f3d8321b371e28d86e5334fcc3 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 20:03:28 +0800 Subject: [PATCH 05/18] =?UTF-8?q?feat(data):=20parseExceptions=20=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E5=BC=82=E5=B8=B8=E5=AD=97=E6=AE=B5=20+=20=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/dto/NavDto.cpp | 43 +++++++++++++++++++++++++++++++++++++ src/data/dto/NavDto.hpp | 5 +++++ src/data/repo/RepoTypes.hpp | 7 ++++++ tests/data/test_nav_dto.cpp | 27 +++++++++++++++++++++++ 4 files changed, 82 insertions(+) diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index f4cf365..91dec64 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -1,9 +1,12 @@ #include "dto/NavDto.hpp" #include +#include +#include #include #include +#include #include namespace geopro::data::dto { @@ -28,6 +31,19 @@ ProjectSummary parseProjectItem(const QJsonObject& o) { p.status = o.value(QStringLiteral("status")).toInt(); return p; } + +// elevationList 极值拼 "高程 min~max m";空返回空串。 +std::string elevationSummary(const QJsonArray& el) { + if (el.isEmpty()) return {}; + double lo = std::numeric_limits::max(), hi = -std::numeric_limits::max(); + for (const QJsonValue& v : el) { + const double d = v.toDouble(); + if (d < lo) lo = d; + if (d > hi) hi = d; + } + return QStringLiteral("高程 %1~%2m") + .arg(lo, 0, 'f', 0).arg(hi, 0, 'f', 0).toStdString(); +} } // namespace std::vector parseWorkspaces(const QJsonArray& arr) { @@ -192,4 +208,31 @@ DynamicForm parseDynamicForm(const QJsonObject& data) { return form; } +std::vector parseExceptions(const QJsonArray& arr) { + std::vector out; + out.reserve(static_cast(arr.size())); + for (const QJsonValue& v : arr) { + const QJsonObject o = v.toObject(); + ExceptionRow r; + r.id = str(o, "id"); + r.name = str(o, "exceptionName"); + r.typeName = str(o, "exceptionTypeName"); + r.createTime = str(o, "createTime"); + r.consortiumId = str(o, "consortiumId"); + r.consortiumName = str(o, "consortiumName"); + r.consortiumType = str(o, "consortiumType"); + QStringList lines; + lines << QStringLiteral("标记 %1 · 创建 %2") + .arg(QString::fromStdString(str(o, "exceptionMarkTypeName"))) + .arg(QString::fromStdString(r.createTime)); + const std::string elev = elevationSummary(o.value(QStringLiteral("elevationList")).toArray()); + if (!elev.empty()) lines << QString::fromStdString(elev); + const std::string remark = str(o, "remark"); + if (!remark.empty()) lines << QStringLiteral("备注 %1").arg(QString::fromStdString(remark)); + r.detailSummary = lines.join(QLatin1Char('\n')).toStdString(); + out.push_back(std::move(r)); + } + return out; +} + } // namespace geopro::data::dto diff --git a/src/data/dto/NavDto.hpp b/src/data/dto/NavDto.hpp index ac3ad91..1c587eb 100644 --- a/src/data/dto/NavDto.hpp +++ b/src/data/dto/NavDto.hpp @@ -43,4 +43,9 @@ std::vector buildStructTree(const std::vector& flat) // 表头 name 取 data["name"]。 DynamicForm parseDynamicForm(const QJsonObject& data); +// ExceptionVO 数组 → [ExceptionRow]。字段:id、name=exceptionName、typeName=exceptionTypeName、 +// createTime;consortium* 取自 consortiumId/consortiumName/consortiumType(来源待 live 验证); +// detailSummary 由 exceptionMarkTypeName/createTime/elevationList/remark 拼成可读多行串。 +std::vector parseExceptions(const QJsonArray& arr); + } // namespace geopro::data::dto diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index bc31bfa..5210d3c 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -37,4 +37,11 @@ struct StructNode { std::string id, name, parentId, typeName, confCode; int type struct DynamicFormField { std::string name, value; }; struct DynamicFormGroup { std::string name; std::vector fields; }; struct DynamicForm { std::string name; std::vector groups; }; + +// 异常(树叶,本轮只读)。consortium* 空 = 独立异常;detailSummary = 详情展开内联显示。 +struct ExceptionRow { + std::string id, name, typeName, createTime; + std::string consortiumId, consortiumName, consortiumType; + std::string detailSummary; +}; } // namespace geopro::data diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index 03d5c36..1eda3c2 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -235,3 +235,30 @@ TEST(NavDto, ParseDynamicFormEmptyFormListYieldsNoGroups) { EXPECT_EQ(form.name, "空"); EXPECT_TRUE(form.groups.empty()); } + +TEST(NavDto, ParseExceptionsMapsFieldsAndSummary) { + const auto arr = arrOf(R"([ + { "id":"e1", "exceptionName":"空洞A", "exceptionTypeName":"空洞", + "exceptionMarkTypeName":"自动", "createTime":"2026-06-01", + "elevationList":[120.0, 80.0, 100.0], "remark":"复核中", + "consortiumId":"c1", "consortiumName":"体A", "consortiumType":"溶洞群" }, + { "id":"e2", "exceptionName":"裂隙B", "exceptionTypeName":"裂隙", + "exceptionMarkTypeName":"手动", "createTime":"2026-06-02", + "elevationList":[], "remark":"" } + ])"); + const auto rows = dto::parseExceptions(arr); + ASSERT_EQ(rows.size(), 2u); + EXPECT_EQ(rows[0].id, "e1"); + EXPECT_EQ(rows[0].name, "空洞A"); + EXPECT_EQ(rows[0].typeName, "空洞"); + EXPECT_EQ(rows[0].consortiumId, "c1"); + EXPECT_EQ(rows[0].consortiumName, "体A"); + EXPECT_EQ(rows[0].consortiumType, "溶洞群"); + EXPECT_NE(rows[0].detailSummary.find("自动"), std::string::npos); + EXPECT_NE(rows[0].detailSummary.find("2026-06-01"), std::string::npos); + EXPECT_NE(rows[0].detailSummary.find("80"), std::string::npos); + EXPECT_NE(rows[0].detailSummary.find("120"), std::string::npos); + EXPECT_NE(rows[0].detailSummary.find("复核中"), std::string::npos); + EXPECT_TRUE(rows[1].consortiumId.empty()); + EXPECT_NE(rows[1].detailSummary.find("手动"), std::string::npos); +} -- 2.40.1 From a21d39281625e75d40433a0535f2b4e5c7be40c3 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 20:09:37 +0800 Subject: [PATCH 06/18] =?UTF-8?q?feat(data):=20groupExceptionsByConsortium?= =?UTF-8?q?=20=E6=8C=89=E5=BC=82=E5=B8=B8=E4=BD=93=E5=88=86=E7=BB=84=20+?= =?UTF-8?q?=20=E7=8B=AC=E7=AB=8B=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/dto/NavDto.cpp | 27 +++++++++++++++++++++++++++ src/data/dto/NavDto.hpp | 4 ++++ src/data/repo/RepoTypes.hpp | 9 +++++++++ tests/data/test_nav_dto.cpp | 28 ++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index 91dec64..2f91e4a 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace geopro::data::dto { @@ -235,4 +236,30 @@ std::vector parseExceptions(const QJsonArray& arr) { return out; } +GroupedExceptions groupExceptionsByConsortium(const std::vector& rows) { + GroupedExceptions out; + std::unordered_map indexById; // consortiumId → out.consortia 下标 + for (const auto& r : rows) { + if (r.consortiumId.empty()) { + out.loose.push_back(r); + continue; + } + auto it = indexById.find(r.consortiumId); + if (it == indexById.end()) { + ConsortiumGroup g; + g.id = r.consortiumId; + g.name = r.consortiumName; + g.typeName = r.consortiumType; + indexById.emplace(r.consortiumId, out.consortia.size()); + out.consortia.push_back(std::move(g)); + it = indexById.find(r.consortiumId); + } + ConsortiumGroup& g = out.consortia[it->second]; + if (g.name.empty() && !r.consortiumName.empty()) g.name = r.consortiumName; + if (g.typeName.empty() && !r.consortiumType.empty()) g.typeName = r.consortiumType; + g.exceptions.push_back(r); + } + return out; +} + } // namespace geopro::data::dto diff --git a/src/data/dto/NavDto.hpp b/src/data/dto/NavDto.hpp index 1c587eb..48649f9 100644 --- a/src/data/dto/NavDto.hpp +++ b/src/data/dto/NavDto.hpp @@ -48,4 +48,8 @@ DynamicForm parseDynamicForm(const QJsonObject& data); // detailSummary 由 exceptionMarkTypeName/createTime/elevationList/remark 拼成可读多行串。 std::vector parseExceptions(const QJsonArray& arr); +// 把一个对象(TM)的异常行按 consortiumId 分组:同 id 归一组(组名/类型取首个非空); +// consortiumId 空 → loose。保持首次出现顺序稳定。纯函数、可单测。 +GroupedExceptions groupExceptionsByConsortium(const std::vector& rows); + } // namespace geopro::data::dto diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index 5210d3c..f1ecb2a 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -44,4 +44,13 @@ struct ExceptionRow { std::string consortiumId, consortiumName, consortiumType; std::string detailSummary; }; + +// 异常体分组(树中间层)+ 对象分组(树根层,对应一个被勾选 TM)。 +struct ConsortiumGroup { std::string id, name, typeName; std::vector exceptions; }; +struct ObjectExceptionGroup { + std::string objectId, objectName; + std::vector consortia; + std::vector looseExceptions; +}; +struct GroupedExceptions { std::vector consortia; std::vector loose; }; } // namespace geopro::data diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index 1eda3c2..eb4a42f 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -4,6 +4,8 @@ #include #include +#include + #include "dto/NavDto.hpp" using namespace geopro::data; @@ -262,3 +264,29 @@ TEST(NavDto, ParseExceptionsMapsFieldsAndSummary) { EXPECT_TRUE(rows[1].consortiumId.empty()); EXPECT_NE(rows[1].detailSummary.find("手动"), std::string::npos); } + +TEST(NavDto, GroupExceptionsByConsortiumSplitsLooseAndGroups) { + std::vector rows = { + { "e1","空洞A","空洞","t1","c1","体A","溶洞群","" }, + { "e2","空洞B","空洞","t1","c1","","","" }, + { "e3","裂隙X","裂隙","t1","","","","" }, + { "e4","空洞C","空洞","t1","c2","体B","溶洞群","" }, + }; + const auto g = dto::groupExceptionsByConsortium(rows); + ASSERT_EQ(g.consortia.size(), 2u); + EXPECT_EQ(g.consortia[0].id, "c1"); + EXPECT_EQ(g.consortia[0].name, "体A"); + EXPECT_EQ(g.consortia[0].typeName, "溶洞群"); + ASSERT_EQ(g.consortia[0].exceptions.size(), 2u); + EXPECT_EQ(g.consortia[1].id, "c2"); + ASSERT_EQ(g.loose.size(), 1u); + EXPECT_EQ(g.loose[0].id, "e3"); +} + +TEST(NavDto, GroupExceptionsAllLooseWhenNoConsortium) { + std::vector rows = { + { "e1","a","t","t1","","","","" }, { "e2","b","t","t1","","","","" } }; + const auto g = dto::groupExceptionsByConsortium(rows); + EXPECT_TRUE(g.consortia.empty()); + EXPECT_EQ(g.loose.size(), 2u); +} -- 2.40.1 From 4621e11c7365a8a93e84a8b13ed6ab41ad3736f7 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 20:15:00 +0800 Subject: [PATCH 07/18] =?UTF-8?q?feat(data):=20=E4=BB=93=E5=82=A8=E6=B3=9B?= =?UTF-8?q?=E5=8C=96=20loadRows=20+=20=E5=AF=B9=E8=B1=A1/=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=9B=86=E8=AF=A6=E6=83=85=20+=20=E6=8C=89TM=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=20=E6=8E=A5=E5=8F=A3=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/api/ApiProjectRepository.cpp | 42 ++++++++++++++++++++++----- src/data/api/ApiProjectRepository.hpp | 7 +++-- src/data/repo/IProjectRepository.hpp | 14 ++++++--- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/data/api/ApiProjectRepository.cpp b/src/data/api/ApiProjectRepository.cpp index ab7cb43..1bd7b14 100644 --- a/src/data/api/ApiProjectRepository.cpp +++ b/src/data/api/ApiProjectRepository.cpp @@ -74,21 +74,49 @@ RepoResult> ApiProjectRepository::loadStructure(const st return {true, dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()), {}}; } -RepoResult ApiProjectRepository::loadTmRows(const std::string& projectId, - const std::string& tmObjectId, int classifyType, - int pageNo) { +RepoResult ApiProjectRepository::loadRows(const std::string& projectId, + const std::string& parentId, int parentConfType, + int classifyType, int pageNo) { const QString path = (classifyType == 1) ? QStringLiteral("/business/dsObject/file/page") : QStringLiteral("/business/dsObject/data/page"); const QJsonObject body{ {QStringLiteral("projectId"), QString::fromStdString(projectId)}, - {QStringLiteral("structParentId"), QString::fromStdString(tmObjectId)}, - {QStringLiteral("structParentConfType"), 2}, + {QStringLiteral("structParentId"), QString::fromStdString(parentId)}, + {QStringLiteral("structParentConfType"), parentConfType}, {QStringLiteral("classifyTypeList"), QJsonArray{classifyType}}, {QStringLiteral("pageNo"), pageNo}, - {QStringLiteral("pageSize"), 5}}; // 数据/文件页签每页 5;不足 total 时"加载更多"追加 + {QStringLiteral("pageSize"), 5}}; const net::ApiResponse r = api_.postJson(path, body); - if (!ok(r)) return {false, {}, errorOf(r, "loadTmRows failed")}; + if (!ok(r)) return {false, {}, errorOf(r, "loadRows failed")}; return {true, dto::parseDsPage(r.data), {}}; } +RepoResult ApiProjectRepository::loadObjectDetail(const std::string& objectId, + int confType) { + const QString path = + (confType == 1) + ? QStringLiteral("/business/gsObject/getGsObjectDetail/%1").arg(enc(objectId)) + : QStringLiteral("/business/tmObject/getDetail/%1").arg(enc(objectId)); + const net::ApiResponse r = api_.get(path); + if (!ok(r)) return {false, {}, errorOf(r, "loadObjectDetail failed")}; + return {true, dto::parseDynamicForm(r.data), {}}; +} + +RepoResult ApiProjectRepository::loadDatasetForm(const std::string& dsObjectId) { + const QString path = + QStringLiteral("/business/dsObject/dynamicForm/%1").arg(enc(dsObjectId)); + const net::ApiResponse r = api_.get(path); + if (!ok(r)) return {false, {}, errorOf(r, "loadDatasetForm failed")}; + return {true, dto::parseDynamicForm(r.data), {}}; +} + +RepoResult> ApiProjectRepository::loadExceptionsByTm( + const std::string& tmObjectId) { + const QString path = + QStringLiteral("/business/exception/queryExceptionByTmObjectId/%1").arg(enc(tmObjectId)); + const net::ApiResponse r = api_.get(path); + if (!ok(r)) return {false, {}, errorOf(r, "loadExceptionsByTm failed")}; + return {true, dto::parseExceptions(r.data.value(QStringLiteral("value")).toArray()), {}}; +} + } // namespace geopro::data diff --git a/src/data/api/ApiProjectRepository.hpp b/src/data/api/ApiProjectRepository.hpp index 9423897..dd27b4b 100644 --- a/src/data/api/ApiProjectRepository.hpp +++ b/src/data/api/ApiProjectRepository.hpp @@ -16,8 +16,11 @@ public: int pageNo, int pageSize) override; RepoResult> listProjectTypes() override; RepoResult> loadStructure(const std::string& projectId) override; - RepoResult loadTmRows(const std::string& projectId, const std::string& tmObjectId, - int classifyType, int pageNo) override; + RepoResult loadRows(const std::string& projectId, const std::string& parentId, + int parentConfType, int classifyType, int pageNo) override; + RepoResult loadObjectDetail(const std::string& objectId, int confType) override; + RepoResult loadDatasetForm(const std::string& dsObjectId) override; + RepoResult> loadExceptionsByTm(const std::string& tmObjectId) override; private: net::ApiClient& api_; diff --git a/src/data/repo/IProjectRepository.hpp b/src/data/repo/IProjectRepository.hpp index 37bd82c..0c7c0af 100644 --- a/src/data/repo/IProjectRepository.hpp +++ b/src/data/repo/IProjectRepository.hpp @@ -26,10 +26,16 @@ public: // 项目类型列表(弹窗类型过滤下拉)。 virtual RepoResult> listProjectTypes() = 0; virtual RepoResult> loadStructure(const std::string& projectId) = 0; - // 按 TM 分页拉数据/文件行:classifyType 3=数据 1=文件;pageNo 从 1 起,pageSize 固定 5。 - virtual RepoResult loadTmRows(const std::string& projectId, - const std::string& tmObjectId, int classifyType, - int pageNo) = 0; + // 按结构父节点分页拉数据/文件行:parentConfType 1=GS 2=TM;classifyType 3=数据 1=文件; + // pageNo 从 1 起,pageSize 固定 5。 + virtual RepoResult loadRows(const std::string& projectId, const std::string& parentId, + int parentConfType, int classifyType, int pageNo) = 0; + // 对象详情:confType 1=GS(getGsObjectDetail) 2=TM(tmObject/getDetail) → 动态表单。 + virtual RepoResult loadObjectDetail(const std::string& objectId, int confType) = 0; + // 数据集详情:dsObject/dynamicForm/{dsObjectId} → 动态表单。 + virtual RepoResult loadDatasetForm(const std::string& dsObjectId) = 0; + // 单 TM 异常列表(含异常体归属字段)。 + virtual RepoResult> loadExceptionsByTm(const std::string& tmObjectId) = 0; }; } // namespace geopro::data -- 2.40.1 From 8cab498f813a78e23f15851dfdf753d2980c25bc Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 20:22:09 +0800 Subject: [PATCH 08/18] =?UTF-8?q?feat(controller):=20selectObject/setCheck?= =?UTF-8?q?edTms/selectDataset=20=E7=BC=96=E6=8E=92=20+=20=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/WorkbenchNavController.cpp | 85 +++++++++++++++++++---- src/controller/WorkbenchNavController.hpp | 14 +++- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index a3366fa..74c286a 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -1,5 +1,7 @@ #include "WorkbenchNavController.hpp" +#include "dto/NavDto.hpp" + namespace geopro::controller { using data::ProjectSummary; @@ -63,6 +65,8 @@ void WorkbenchNavController::loadProjectsAndStructure() { emit projectsLoaded(ps.value.rows, curP, ps.value.total); if (curP.isEmpty()) { + lastStructNodes_.clear(); + tmExceptionCache_.clear(); emit structureLoaded(QString(), {}); // 暂无项目 → 空树 return; } @@ -71,6 +75,7 @@ void WorkbenchNavController::loadProjectsAndStructure() { emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error)); return; } + lastStructNodes_ = st.value; emit structureLoaded(QString::fromStdString(currentProjectName_), st.value); } @@ -100,54 +105,108 @@ void WorkbenchNavController::switchProject(const QString& projectId) { emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error)); return; } + lastStructNodes_ = st.value; + tmExceptionCache_.clear(); emit structureLoaded(QString::fromStdString(currentProjectName_), st.value); } -void WorkbenchNavController::selectTm(const QString& tmObjectId) { - if (tmObjectId.isEmpty() || busy_) return; +void WorkbenchNavController::selectObject(const QString& objectId, int confType) { + if (objectId.isEmpty() || busy_) return; BusyGuard guard(this, &busy_); - currentTmId_ = tmObjectId.toStdString(); + currentParentId_ = objectId.toStdString(); + currentParentConfType_ = confType; const std::string pid = currentProjectId_; dataPageNo_ = 1; filePageNo_ = 1; - const auto d = repo_.loadTmRows(pid, currentTmId_, 3, dataPageNo_); + const auto d = repo_.loadRows(pid, currentParentId_, confType, 3, dataPageNo_); if (!d.ok) { emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error)); return; } dataTotal_ = d.value.total; - emit datasetsLoaded(tmObjectId, d.value.rows, d.value.total, false); - const auto f = repo_.loadTmRows(pid, currentTmId_, 1, filePageNo_); + emit datasetsLoaded(objectId, d.value.rows, d.value.total, false); + const auto f = repo_.loadRows(pid, currentParentId_, confType, 1, filePageNo_); if (!f.ok) { emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error)); return; } fileTotal_ = f.value.total; - emit filesLoaded(tmObjectId, f.value.rows, f.value.total, false); + emit filesLoaded(objectId, f.value.rows, f.value.total, false); + + const auto detail = repo_.loadObjectDetail(currentParentId_, confType); + if (!detail.ok) { + emit loadFailed(QStringLiteral("objectDetail"), QString::fromStdString(detail.error)); + return; + } + emit objectDetailLoaded(objectId, detail.value); } void WorkbenchNavController::loadMoreData() { - if (currentTmId_.empty() || busy_) return; + if (currentParentId_.empty() || busy_) return; BusyGuard guard(this, &busy_); - const auto d = repo_.loadTmRows(currentProjectId_, currentTmId_, 3, ++dataPageNo_); + const auto d = repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 3, ++dataPageNo_); if (!d.ok) { emit loadFailed(QStringLiteral("datasets"), QString::fromStdString(d.error)); return; } dataTotal_ = d.value.total; - emit datasetsLoaded(QString::fromStdString(currentTmId_), d.value.rows, d.value.total, true); + emit datasetsLoaded(QString::fromStdString(currentParentId_), d.value.rows, d.value.total, true); } void WorkbenchNavController::loadMoreFiles() { - if (currentTmId_.empty() || busy_) return; + if (currentParentId_.empty() || busy_) return; BusyGuard guard(this, &busy_); - const auto f = repo_.loadTmRows(currentProjectId_, currentTmId_, 1, ++filePageNo_); + const auto f = repo_.loadRows(currentProjectId_, currentParentId_, currentParentConfType_, 1, ++filePageNo_); if (!f.ok) { emit loadFailed(QStringLiteral("files"), QString::fromStdString(f.error)); return; } fileTotal_ = f.value.total; - emit filesLoaded(QString::fromStdString(currentTmId_), f.value.rows, f.value.total, true); + emit filesLoaded(QString::fromStdString(currentParentId_), f.value.rows, f.value.total, true); +} + +void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { + if (busy_) return; + BusyGuard guard(this, &busy_); + auto nameOf = [this](const std::string& id) -> std::string { + for (const auto& n : lastStructNodes_) + if (n.id == id) return n.name; + return id; + }; + std::vector groups; + int total = 0; + for (const QString& tmQ : tmObjectIds) { + const std::string tm = tmQ.toStdString(); + auto it = tmExceptionCache_.find(tm); + if (it == tmExceptionCache_.end()) { + const auto ex = repo_.loadExceptionsByTm(tm); + if (!ex.ok) { + emit loadFailed(QStringLiteral("exceptions"), QString::fromStdString(ex.error)); + return; + } + it = tmExceptionCache_.emplace(tm, ex.value).first; + } + const auto grouped = data::dto::groupExceptionsByConsortium(it->second); + data::ObjectExceptionGroup g; + g.objectId = tm; + g.objectName = nameOf(tm); + g.consortia = grouped.consortia; + g.looseExceptions = grouped.loose; + total += static_cast(it->second.size()); + groups.push_back(std::move(g)); + } + emit exceptionTreeLoaded(groups, total); +} + +void WorkbenchNavController::selectDataset(const QString& dsObjectId) { + if (dsObjectId.isEmpty() || busy_) return; + BusyGuard guard(this, &busy_); + const auto form = repo_.loadDatasetForm(dsObjectId.toStdString()); + if (!form.ok) { + emit loadFailed(QStringLiteral("datasetDetail"), QString::fromStdString(form.error)); + return; + } + emit datasetDetailLoaded(form.value); } } // namespace geopro::controller diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index 04f6a66..52616ba 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -1,6 +1,8 @@ #pragma once #include #include +#include +#include #include #include @@ -21,7 +23,9 @@ public: public slots: void switchWorkspace(const QString& tenantId); void switchProject(const QString& projectId); - void selectTm(const QString& tmObjectId); + void selectObject(const QString& objectId, int confType); // 单击对象→DS列表+对象详情 + void setCheckedTms(const QStringList& tmObjectIds); // 勾选叶子集→异常树 + void selectDataset(const QString& dsObjectId); // 单击DS→数据集动态表单 void loadMoreData(); void loadMoreFiles(); @@ -35,6 +39,9 @@ signals: int total, bool append); void filesLoaded(const QString& tmObjectId, const std::vector& rows, int total, bool append); + void objectDetailLoaded(const QString& title, const geopro::data::DynamicForm& form); + void exceptionTreeLoaded(const std::vector& groups, int tmCount); + void datasetDetailLoaded(const geopro::data::DynamicForm& form); void loadFailed(const QString& stage, const QString& message); private: @@ -44,7 +51,10 @@ private: bool busy_ = false; std::vector lastProjects_; std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; - std::string currentTmId_; + std::string currentParentId_; + int currentParentConfType_ = 0; + std::vector lastStructNodes_; // tmId→name 解析 + std::map> tmExceptionCache_; int dataPageNo_ = 0; int filePageNo_ = 0; int dataTotal_ = 0; -- 2.40.1 From 6cf53ab19976717e23c7b4e3160fb9fa02d0c822 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 20:29:46 +0800 Subject: [PATCH 09/18] =?UTF-8?q?fix(controller):=20=E6=97=A0=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E6=B8=85=E5=BC=82=E5=B8=B8=E7=BC=93=E5=AD=98(?= =?UTF-8?q?=E8=B7=A8=E7=A9=BA=E9=97=B4)=20+=20=E7=A7=BB=E5=8A=A8=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=20+=20=E4=BF=A1=E5=8F=B7=E5=8F=82=E6=95=B0=E6=AD=A3?= =?UTF-8?q?=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/WorkbenchNavController.cpp | 8 ++++---- src/controller/WorkbenchNavController.hpp | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index 74c286a..4739b3b 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -50,6 +50,7 @@ void WorkbenchNavController::loadProjectsAndStructure() { return; } lastProjects_ = ps.value.rows; + tmExceptionCache_.clear(); QString curP; if (!ps.value.rows.empty()) { const auto& first = ps.value.rows.front(); @@ -66,7 +67,6 @@ void WorkbenchNavController::loadProjectsAndStructure() { if (curP.isEmpty()) { lastStructNodes_.clear(); - tmExceptionCache_.clear(); emit structureLoaded(QString(), {}); // 暂无项目 → 空树 return; } @@ -186,12 +186,12 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { } it = tmExceptionCache_.emplace(tm, ex.value).first; } - const auto grouped = data::dto::groupExceptionsByConsortium(it->second); + auto grouped = data::dto::groupExceptionsByConsortium(it->second); data::ObjectExceptionGroup g; g.objectId = tm; g.objectName = nameOf(tm); - g.consortia = grouped.consortia; - g.looseExceptions = grouped.loose; + g.consortia = std::move(grouped.consortia); + g.looseExceptions = std::move(grouped.loose); total += static_cast(it->second.size()); groups.push_back(std::move(g)); } diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index 52616ba..2f28103 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -40,7 +40,7 @@ signals: void filesLoaded(const QString& tmObjectId, const std::vector& rows, int total, bool append); void objectDetailLoaded(const QString& title, const geopro::data::DynamicForm& form); - void exceptionTreeLoaded(const std::vector& groups, int tmCount); + void exceptionTreeLoaded(const std::vector& groups, int exceptionCount); void datasetDetailLoaded(const geopro::data::DynamicForm& form); void loadFailed(const QString& stage, const QString& message); -- 2.40.1 From 5686155faa4061255f42c3d19ebc4d34cdb92b5f Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 20:36:03 +0800 Subject: [PATCH 10/18] =?UTF-8?q?feat(ui):=20DynamicFormView=20=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E8=A1=A8=E5=8D=95=E5=88=86=E7=BB=84=E9=94=AE=E5=80=BC?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 1 + src/app/panels/DynamicFormView.cpp | 81 ++++++++++++++++++++++++++++++ src/app/panels/DynamicFormView.hpp | 21 ++++++++ 3 files changed, 103 insertions(+) create mode 100644 src/app/panels/DynamicFormView.cpp create mode 100644 src/app/panels/DynamicFormView.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index c7b9579..9dbc572 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -24,6 +24,7 @@ add_executable(geopro_desktop WIN32 panels/AnomalyListPanel.cpp panels/DatasetListPanel.cpp panels/ObjectTreePanel.cpp + panels/DynamicFormView.cpp CentralScene.cpp ProjectListDialog.cpp SettingsDialog.cpp) diff --git a/src/app/panels/DynamicFormView.cpp b/src/app/panels/DynamicFormView.cpp new file mode 100644 index 0000000..c5af31d --- /dev/null +++ b/src/app/panels/DynamicFormView.cpp @@ -0,0 +1,81 @@ +#include "panels/DynamicFormView.hpp" + +#include +#include +#include +#include + +#include "Theme.hpp" + +namespace geopro::app { + +DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) { + auto* outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + outer->setSpacing(0); + + auto* scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + auto* host = new QWidget(); + content_ = new QVBoxLayout(host); + content_->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, + geopro::app::space::kLg, geopro::app::space::kMd); + content_->setSpacing(geopro::app::space::kSm); + content_->addStretch(); + scroll->setWidget(host); + outer->addWidget(scroll); + + showMessage(QStringLiteral("(选中后显示属性详情)")); +} + +void DynamicFormView::clear() { + while (content_->count() > 0) { + QLayoutItem* it = content_->takeAt(0); + if (it->widget()) it->widget()->deleteLater(); + delete it; + } +} + +void DynamicFormView::showMessage(const QString& message) { + clear(); + auto* hint = new QLabel(message); + hint->setAlignment(Qt::AlignCenter); + geopro::app::applyTokenizedStyleSheet(hint, + QStringLiteral("color:{{text/disabled}}; padding:16px;")); + content_->addWidget(hint); + content_->addStretch(); +} + +void DynamicFormView::setForm(const geopro::data::DynamicForm& form) { + clear(); + if (form.groups.empty()) { + showMessage(QStringLiteral("(暂无属性)")); + return; + } + for (const auto& group : form.groups) { + auto* title = new QLabel(QString::fromStdString(group.name)); + geopro::app::applyTokenizedStyleSheet( + title, QStringLiteral("color:{{text/secondary}}; font-weight:%1; padding-top:6px;") + .arg(geopro::app::type::kWeightSemibold)); + content_->addWidget(title); + + auto* form_w = new QWidget(); + auto* fl = new QFormLayout(form_w); + fl->setContentsMargins(0, 0, 0, 0); + fl->setLabelAlignment(Qt::AlignLeft | Qt::AlignTop); + fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + for (const auto& f : group.fields) { + auto* k = new QLabel(QString::fromStdString(f.name)); + geopro::app::applyTokenizedStyleSheet(k, QStringLiteral("color:{{text/secondary}};")); + auto* v = new QLabel(QString::fromStdString(f.value)); + v->setWordWrap(true); + geopro::app::applyTokenizedStyleSheet(v, QStringLiteral("color:{{text/primary}};")); + fl->addRow(k, v); + } + content_->addWidget(form_w); + } + content_->addStretch(); +} + +} // namespace geopro::app diff --git a/src/app/panels/DynamicFormView.hpp b/src/app/panels/DynamicFormView.hpp new file mode 100644 index 0000000..c3c6cd3 --- /dev/null +++ b/src/app/panels/DynamicFormView.hpp @@ -0,0 +1,21 @@ +#pragma once +#include +#include "repo/RepoTypes.hpp" + +class QVBoxLayout; + +namespace geopro::app { + +// 被动:渲染 DynamicForm(分组键值)。对象属性 / 数据集属性两面板共用。 +class DynamicFormView : public QWidget { +public: + explicit DynamicFormView(QWidget* parent = nullptr); + void setForm(const geopro::data::DynamicForm& form); + void showMessage(const QString& message); // 空/错占位 + +private: + void clear(); + QVBoxLayout* content_ = nullptr; // 滚动区内容布局 +}; + +} // namespace geopro::app -- 2.40.1 From 595d65cd3b9f1f5591fd273ecc11a4e8328695ac Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 20:44:25 +0800 Subject: [PATCH 11/18] =?UTF-8?q?feat(ui):=20ObjectExceptionPanel=20?= =?UTF-8?q?=E5=BC=82=E5=B8=B8+=E5=BC=82=E5=B8=B8=E4=BD=93=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=A0=91=EF=BC=88=E5=90=AB=E8=AF=A6=E6=83=85=E5=B1=95?= =?UTF-8?q?=E5=BC=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/CMakeLists.txt | 1 + src/app/panels/ObjectExceptionPanel.cpp | 85 +++++++++++++++++++++++++ src/app/panels/ObjectExceptionPanel.hpp | 23 +++++++ 3 files changed, 109 insertions(+) create mode 100644 src/app/panels/ObjectExceptionPanel.cpp create mode 100644 src/app/panels/ObjectExceptionPanel.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 9dbc572..db8ac21 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -25,6 +25,7 @@ add_executable(geopro_desktop WIN32 panels/DatasetListPanel.cpp panels/ObjectTreePanel.cpp panels/DynamicFormView.cpp + panels/ObjectExceptionPanel.cpp CentralScene.cpp ProjectListDialog.cpp SettingsDialog.cpp) diff --git a/src/app/panels/ObjectExceptionPanel.cpp b/src/app/panels/ObjectExceptionPanel.cpp new file mode 100644 index 0000000..1dd112b --- /dev/null +++ b/src/app/panels/ObjectExceptionPanel.cpp @@ -0,0 +1,85 @@ +#include "panels/ObjectExceptionPanel.hpp" + +#include +#include +#include +#include + +#include "Theme.hpp" + +namespace geopro::app { + +namespace { +QTreeWidgetItem* addException(QTreeWidgetItem* parent, const geopro::data::ExceptionRow& e) { + auto* item = new QTreeWidgetItem(parent); + const QString type = + e.typeName.empty() ? QString() : QStringLiteral("(%1)").arg(QString::fromStdString(e.typeName)); + item->setText(0, QString::fromStdString(e.name) + type); + // 详情展开(D6):异常下挂一个详情子项,显示已加载摘要(多行)。 + if (!e.detailSummary.empty()) { + auto* detail = new QTreeWidgetItem(item); + detail->setText(0, QString::fromStdString(e.detailSummary)); + detail->setFirstColumnSpanned(true); + detail->setForeground(0, geopro::app::tokenColor("text/tertiary")); + } + return item; +} +} // namespace + +ObjectExceptionPanel::ObjectExceptionPanel(QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); + + tree_ = new QTreeWidget(this); + tree_->setHeaderHidden(true); + tree_->setIndentation(14); + lay->addWidget(tree_, 1); + + hint_ = new QLabel(QStringLiteral("(勾选对象后显示其异常 / 异常体)"), this); + hint_->setAlignment(Qt::AlignCenter); + geopro::app::applyTokenizedStyleSheet(hint_, QStringLiteral("color:{{text/disabled}}; padding:16px;")); + lay->addWidget(hint_); + tree_->setVisible(false); +} + +void ObjectExceptionPanel::setGroups( + const std::vector& groups) { + tree_->clear(); + bool any = false; + for (const auto& g : groups) { + auto* objItem = new QTreeWidgetItem(tree_); + objItem->setText(0, QString::fromStdString(g.objectName)); + for (const auto& c : g.consortia) { + auto* cItem = new QTreeWidgetItem(objItem); + const QString ctype = c.typeName.empty() + ? QString() + : QStringLiteral("(%1)").arg(QString::fromStdString(c.typeName)); + cItem->setText(0, QStringLiteral("异常体 %1%2") + .arg(QString::fromStdString(c.name.empty() ? c.id : c.name)) + .arg(ctype)); + for (const auto& e : c.exceptions) { addException(cItem, e); any = true; } + } + if (!g.looseExceptions.empty()) { + auto* looseItem = new QTreeWidgetItem(objItem); + looseItem->setText(0, QStringLiteral("独立异常(未合并)")); + for (const auto& e : g.looseExceptions) { addException(looseItem, e); any = true; } + } + } + if (!any) { + showMessage(QStringLiteral("(所选对象暂无异常)")); + return; + } + hint_->setVisible(false); + tree_->setVisible(true); + tree_->expandAll(); +} + +void ObjectExceptionPanel::showMessage(const QString& message) { + tree_->clear(); + tree_->setVisible(false); + hint_->setText(message); + hint_->setVisible(true); +} + +} // namespace geopro::app diff --git a/src/app/panels/ObjectExceptionPanel.hpp b/src/app/panels/ObjectExceptionPanel.hpp new file mode 100644 index 0000000..473575f --- /dev/null +++ b/src/app/panels/ObjectExceptionPanel.hpp @@ -0,0 +1,23 @@ +#pragma once +#include +#include +#include "repo/RepoTypes.hpp" + +class QTreeWidget; +class QLabel; + +namespace geopro::app { + +// 被动:异常 + 异常体 只读树(对象→异常体→异常→详情 + 独立异常)。数据由控制器推送。 +class ObjectExceptionPanel : public QWidget { +public: + explicit ObjectExceptionPanel(QWidget* parent = nullptr); + void setGroups(const std::vector& groups); + void showMessage(const QString& message); + +private: + QTreeWidget* tree_ = nullptr; + QLabel* hint_ = nullptr; +}; + +} // namespace geopro::app -- 2.40.1 From 68296a5d3607dda055ceb1b2d668200c511a0c39 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 20:50:51 +0800 Subject: [PATCH 12/18] =?UTF-8?q?feat(ui):=20ObjectTreePanel=20GS=E4=B8=89?= =?UTF-8?q?=E6=80=81=E5=8B=BE=E9=80=89=20+=20objectClicked/checkedTmsChang?= =?UTF-8?q?ed=20=E5=90=88=E5=B9=B6=E5=8F=91=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/ObjectTreePanel.cpp | 42 +++++++++++++++++++++++------- src/app/panels/ObjectTreePanel.hpp | 9 ++++--- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/app/panels/ObjectTreePanel.cpp b/src/app/panels/ObjectTreePanel.cpp index 074d785..0e48af4 100644 --- a/src/app/panels/ObjectTreePanel.cpp +++ b/src/app/panels/ObjectTreePanel.cpp @@ -2,9 +2,12 @@ #include #include +#include +#include #include #include #include +#include #include "Glyphs.hpp" #include "Theme.hpp" @@ -13,17 +16,22 @@ namespace geopro::app { namespace { -// TM 节点把 tmObjectId 存在该角色;GS/项目根节点为空。 -constexpr int kRoleTmId = Qt::UserRole + 2; +constexpr int kRoleObjId = Qt::UserRole + 2; // 节点对象 id(GS/TM 都存) +constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM void addNodes(QTreeWidgetItem* parent, const std::vector& nodes) { for (const auto& n : nodes) { auto* item = new QTreeWidgetItem(parent); item->setText(0, QString::fromStdString(n.node.name)); + item->setData(0, kRoleObjId, QString::fromStdString(n.node.id)); if (n.isTm) { - item->setData(0, kRoleTmId, QString::fromStdString(n.node.id)); + item->setData(0, kRoleConfType, 2); item->setFlags(item->flags() | Qt::ItemIsUserCheckable); - item->setCheckState(0, Qt::Unchecked); // 真实数据渲染下一轮接入,默认不勾 + item->setCheckState(0, Qt::Unchecked); + } else { + item->setData(0, kRoleConfType, 1); // GS + item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); + item->setCheckState(0, Qt::Unchecked); } addNodes(item, n.children); } @@ -49,12 +57,28 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { lay->addWidget(hint_); QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) { - const QString tmId = item->data(0, kRoleTmId).toString(); - if (!tmId.isEmpty()) emit tmClicked(tmId); + const QString id = item->data(0, kRoleObjId).toString(); + const int confType = item->data(0, kRoleConfType).toInt(); + if (!id.isEmpty() && confType != 0) emit objectClicked(id, confType); }); - QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem* item, int) { - const QString tmId = item->data(0, kRoleTmId).toString(); - if (!tmId.isEmpty()) emit tmCheckToggled(tmId, item->checkState(0) == Qt::Checked); + // 勾选变化:GS 级联会触发多次 itemChanged,用 0ms 单发合并成一次「收集勾选叶子并发射」。 + QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) { + if (checkPending_) return; + checkPending_ = true; + QTimer::singleShot(0, this, [this]() { + checkPending_ = false; + QStringList tmIds; + std::function walk = [&](QTreeWidgetItem* node) { + for (int i = 0; i < node->childCount(); ++i) { + QTreeWidgetItem* c = node->child(i); + if (c->data(0, kRoleConfType).toInt() == 2 && c->checkState(0) == Qt::Checked) + tmIds << c->data(0, kRoleObjId).toString(); + walk(c); + } + }; + walk(tree_->invisibleRootItem()); + emit checkedTmsChanged(tmIds); + }); }); } diff --git a/src/app/panels/ObjectTreePanel.hpp b/src/app/panels/ObjectTreePanel.hpp index 1ba37e8..2fb454d 100644 --- a/src/app/panels/ObjectTreePanel.hpp +++ b/src/app/panels/ObjectTreePanel.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include "repo/RepoTypes.hpp" @@ -19,13 +20,15 @@ public: void showMessage(const QString& message); // 错误/空状态占位 signals: - void tmClicked(const QString& tmObjectId); - // 前瞻钩子:勾选驱动中央渲染留待下一轮接真实 DS(本轮暂无消费者)。 - void tmCheckToggled(const QString& tmObjectId, bool checked); + // confType: 1=GS 2=TM。单击行(驱动数据列表 + 对象属性)。 + void objectClicked(const QString& objectId, int confType); + // 当前全部被勾选的 TM 叶子 id(已合并发射)。 + void checkedTmsChanged(const QStringList& tmObjectIds); private: QTreeWidget* tree_ = nullptr; // Qt 原生标准树(复选框/箭头由 Fusion 绘制,清晰可控) QLabel* hint_ = nullptr; + bool checkPending_ = false; // 勾选合并发射防重入 }; } // namespace geopro::app -- 2.40.1 From 0d17a009319ba5bab9161f14da079b35c95a741f Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 20:58:09 +0800 Subject: [PATCH 13/18] =?UTF-8?q?fix(ui):=20=E5=AF=B9=E8=B1=A1=E6=A0=91?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E6=A0=B9=E8=AE=BE=E4=B8=BA=E9=9D=9E=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E5=AE=B9=E5=99=A8=EF=BC=88=E4=BB=85=20GS/TM=20?= =?UTF-8?q?=E5=8F=AF=E5=8B=BE=E9=80=89/=E5=8D=95=E5=87=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/ObjectTreePanel.cpp | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/app/panels/ObjectTreePanel.cpp b/src/app/panels/ObjectTreePanel.cpp index 0e48af4..1b20b29 100644 --- a/src/app/panels/ObjectTreePanel.cpp +++ b/src/app/panels/ObjectTreePanel.cpp @@ -18,22 +18,30 @@ namespace geopro::app { namespace { constexpr int kRoleObjId = Qt::UserRole + 2; // 节点对象 id(GS/TM 都存) constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM +constexpr int kConfTypeGs = 1; // GS(工区) +constexpr int kConfTypeTm = 2; // TM 叶子 -void addNodes(QTreeWidgetItem* parent, const std::vector& nodes) { +// topLevel=true 仅用于项目根:渲染为非交互容器(既不可勾选,也不发 objectClicked)。 +void addNodes(QTreeWidgetItem* parent, const std::vector& nodes, + bool topLevel) { for (const auto& n : nodes) { auto* item = new QTreeWidgetItem(parent); item->setText(0, QString::fromStdString(n.node.name)); - item->setData(0, kRoleObjId, QString::fromStdString(n.node.id)); - if (n.isTm) { - item->setData(0, kRoleConfType, 2); - item->setFlags(item->flags() | Qt::ItemIsUserCheckable); - item->setCheckState(0, Qt::Unchecked); + if (topLevel) { + // 项目根:非交互容器(不设 kRoleObjId/kRoleConfType,不可勾选)。 } else { - item->setData(0, kRoleConfType, 1); // GS - item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); - item->setCheckState(0, Qt::Unchecked); + item->setData(0, kRoleObjId, QString::fromStdString(n.node.id)); + if (n.isTm) { + item->setData(0, kRoleConfType, kConfTypeTm); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(0, Qt::Unchecked); + } else { + item->setData(0, kRoleConfType, kConfTypeGs); // GS + item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); + item->setCheckState(0, Qt::Unchecked); + } } - addNodes(item, n.children); + addNodes(item, n.children, false); // 子层永远非顶层 } } } // namespace @@ -94,7 +102,7 @@ void ObjectTreePanel::setStructure(const QString& projectName, } hint_->setVisible(false); tree_->setVisible(true); - addNodes(tree_->invisibleRootItem(), roots); // 结构已含项目根节点,直接渲染 + addNodes(tree_->invisibleRootItem(), roots, true); // 结构已含项目根节点,根为非交互容器 tree_->expandAll(); } -- 2.40.1 From 7a56e495847d74cd69a5130cea818b678d6dea39 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 21:04:38 +0800 Subject: [PATCH 14/18] =?UTF-8?q?feat(ui):=20=E6=8E=A5=E7=BA=BF=20?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E5=8D=95=E5=87=BB/=E5=8B=BE=E9=80=89/?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E9=9B=86=E5=8D=95=E5=87=BB=20=E2=86=92=20?= =?UTF-8?q?=E4=B8=89=E9=9D=A2=E6=9D=BF=EF=BC=88=E7=A7=BB=E9=99=A4=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main.cpp | 116 +++++++++++++++++------------------------------ 1 file changed, 41 insertions(+), 75 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index d88b1fe..bbafbf4 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -44,7 +44,6 @@ #include #include #include -#include #include #include #include @@ -84,8 +83,9 @@ #include "api/ApiProjectRepository.hpp" #include "panels/ObjectTreePanel.hpp" #include "login/LoginWindow.hpp" -#include "panels/AnomalyListPanel.hpp" #include "panels/DatasetListPanel.hpp" +#include "panels/DynamicFormView.hpp" +#include "panels/ObjectExceptionPanel.hpp" #include "CameraPreset.hpp" #include "ColorLutBuilder.hpp" @@ -549,23 +549,18 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re dockManager->addDockWidget(ads::BottomDockWidgetArea, datasetDock, leftArea); // 右上 dock:异常列表 / 对象属性 合并为带 Tab 表头的面板(对齐原型上半)。 - auto* anomalyList = new QListWidget(); - geopro::app::applyAnomalyCardDelegate(anomalyList); - auto* objAttrLabel = new QLabel(QStringLiteral("(选中对象后显示其属性)")); - objAttrLabel->setWordWrap(true); - objAttrLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); - objAttrLabel->setMargin(8); + auto* exceptionPanel = new geopro::app::ObjectExceptionPanel(); + auto* objAttrView = new geopro::app::DynamicFormView(); auto anomalyPanel = geopro::app::buildTabbedPanel( - {{geopro::app::Glyph::Anomaly, QStringLiteral("异常"), anomalyList, true}, - {geopro::app::Glyph::Property, QStringLiteral("对象属性"), objAttrLabel, false}}, + {{geopro::app::Glyph::Anomaly, QStringLiteral("对象异常"), exceptionPanel, true}, + {geopro::app::Glyph::Property, QStringLiteral("对象属性"), objAttrView, false}}, {{geopro::app::Glyph::Filter, QStringLiteral("筛选")}, {geopro::app::Glyph::Plus, QStringLiteral("添加异常")}}); auto* anomalyBadge = anomalyPanel.badges.value(0); // 异常列表 Tab 的数量徽标 // colorize(C):异常计数用语义 warning“需注意”变体(区别于普通中性计数徽标), // 提示“这些异常点待复查”。改 objectName 后重新 polish 以应用 #panelBadgeWarn 样式。 - // 注:徽标的填充/显隐在 loadDataset 内(当前被 park),故此色与徽标本身同属休眠态, - // 接 dd 详情渲染那轮一并可见。 + // 注:徽标的填充/显隐由 exceptionTreeLoaded 连接驱动(勾选对象后按异常计数更新)。 if (anomalyBadge) { anomalyBadge->setObjectName(QStringLiteral("panelBadgeWarn")); anomalyBadge->style()->unpolish(anomalyBadge); @@ -577,13 +572,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re auto* rightArea = dockManager->addDockWidget(ads::RightDockWidgetArea, rightDock); // 右下 dock:属性(数据集属性,键值;对齐原型下半,独立面板)。 - auto* propLabel = new QLabel(QStringLiteral("(单击左侧数据集查看属性与平面剖面)")); - propLabel->setWordWrap(true); - propLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft); - propLabel->setMargin(8); + auto* propView = new geopro::app::DynamicFormView(); auto* propDock = new ads::CDockWidget(QStringLiteral("数据集属性")); propDock->setWidget( - wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propLabel)); + wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propView)); dockManager->addDockWidget(ads::BottomDockWidgetArea, propDock, rightArea); // 固定全部面板(对齐原型):移除 关闭/浮动/拖动/钉住 等子窗口操作,仅保留分隔条调整边界。 @@ -709,66 +701,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re } }; - // 加载某数据集到「数据详情 + 异常列表 + 属性」(数据列表单击与启动默认共用)。 - auto loadDataset = [&repo, propLabel, currentDsId, rebuildDetail, anomalyList, hiddenAnoms, - anomalyBadge](const QString& dsId, const QString& name) { - if (dsId.isEmpty()) return; - *currentDsId = dsId; - - // 右上异常列表:按该数据集异常重填(默认全显);先清隐藏集再填,避免重建时阻塞信号回灌。 - const auto anomalies = repo.loadAnomalies(dsId.toStdString()); - hiddenAnoms->clear(); - { - const QSignalBlocker block(anomalyList); // 重填触发 itemChanged,先屏蔽 - geopro::app::populateAnomalyList(anomalyList, anomalies); - } - // 异常列表 Tab 数量徽标。 - if (anomalyBadge) { - anomalyBadge->setText(QString::number(anomalies.size())); - anomalyBadge->setVisible(!anomalies.empty()); - } - - rebuildDetail(); - - // 右下属性(数据集级,与详情模式无关)。 - const auto g = repo.loadGrid(dsId.toStdString()); - propLabel->setText( - QStringLiteral("数据集: %1\n类型: 剖面网格 (dd_section)\n网格: %2 x %3\n" - "vmin / vmax: %4 / %5\n异常: %6 个") - .arg(name).arg(g.nx()).arg(g.ny()).arg(g.vmin).arg(g.vmax) - .arg(anomalies.size())); - }; - // 暂未触发:保留待下一轮真实 DS 详情渲染复用。 - // TODO(overdrive-A 依赖):把下面数据集单击处理改调 loadDataset(dsId, name) 接通真实详情 - // 渲染后,rebuildDetail 里已就绪的“相机补间 + actor 淡入”揭示动画会在切换数据集时自动激活 - // (见 rebuildDetail 的 animate 分支与 animateReveal)。在此之前该动画为休眠态、不可见。 - (void)loadDataset; - - // ── 单击左下数据列表的采集批次(DS) → 占位(真实剖面/反演渲染下一阶段接 dd 接口)── - // 接 dd 那轮:把本处占位改为 loadDataset(id, name) 即接通详情渲染,并自动激活 overdrive-A 揭示动画。 + // ── 单击左下数据列表的采集批次(DS) → 加载数据集动态表单(数据集属性面板)── QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, - [propLabel, detailRendererPtr, detailRenderWindowPtr, &nav](QListWidgetItem* item) { + [&nav](QListWidgetItem* item) { if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { nav.loadMoreData(); return; } - const QString name = - item->data(Qt::DisplayRole).toString().section('\n', 0, 0); - detailRendererPtr->RemoveAllViewProps(); - detailRenderWindowPtr->Render(); - propLabel->setText(QStringLiteral( - "数据集: %1\n(该数据集的剖面/反演渲染将在下一阶段接入 dd 接口)").arg(name)); - }); - - // ── 异常列表勾选(显隐) → 更新隐藏集 → 重建数据详情 ── - QObject::connect(anomalyList, &QListWidget::itemChanged, anomalyList, - [hiddenAnoms, rebuildDetail](QListWidgetItem* item) { - const int idx = item->data(geopro::app::kAnomalyIndexRole).toInt(); - if (item->checkState() == Qt::Checked) - hiddenAnoms->erase(idx); - else - hiddenAnoms->insert(idx); - rebuildDetail(); + const QString dsId = item->data(geopro::app::kDsIdRole).toString(); + if (!dsId.isEmpty()) nav.selectDataset(dsId); }); // ── 数据详情工具条「反演剖面/原数据」:切模式 → 重建数据详情 ── @@ -916,8 +857,28 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }); dlg->exec(); }); - QObject::connect(objectTree, &geopro::app::ObjectTreePanel::tmClicked, &nav, - &geopro::controller::WorkbenchNavController::selectTm); + QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectClicked, &nav, + &geopro::controller::WorkbenchNavController::selectObject); + QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &nav, + &geopro::controller::WorkbenchNavController::setCheckedTms); + + // 控制器详情/异常/数据集表单 → 三个被动面板。 + QObject::connect(&nav, &geopro::controller::WorkbenchNavController::objectDetailLoaded, objAttrView, + [objAttrView](const QString&, const geopro::data::DynamicForm& form) { + objAttrView->setForm(form); + }); + QObject::connect(&nav, &geopro::controller::WorkbenchNavController::exceptionTreeLoaded, + exceptionPanel, + [exceptionPanel, anomalyBadge]( + const std::vector& groups, int total) { + exceptionPanel->setGroups(groups); + if (anomalyBadge) { + anomalyBadge->setText(QString::number(total)); + anomalyBadge->setVisible(total > 0); + } + }); + QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetDetailLoaded, propView, + [propView](const geopro::data::DynamicForm& form) { propView->setForm(form); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::workspacesLoaded, topBar, [topBar](const std::vector& list, const QString& cur) { @@ -929,12 +890,17 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re topBar->setProjects(list, cur, total > static_cast(list.size())); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objectTree, - [objectTree, datasetList, fileList, datasetTitle, datasetTabs]( + [objectTree, datasetList, fileList, datasetTitle, datasetTabs, exceptionPanel, + objAttrView, propView, anomalyBadge]( const QString& projectName, const std::vector& nodes) { objectTree->setStructure(projectName, nodes); datasetList->clear(); fileList->clear(); + exceptionPanel->showMessage(QStringLiteral("(勾选对象后显示其异常 / 异常体)")); + objAttrView->showMessage(QStringLiteral("(选中对象后显示其属性)")); + propView->showMessage(QStringLiteral("(单击数据集查看属性)")); + if (anomalyBadge) anomalyBadge->setVisible(false); if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集")); datasetTabs->setTabText(0, QStringLiteral("数据")); datasetTabs->setTabText(1, QStringLiteral("文件")); -- 2.40.1 From d435fca32d05828d709af4b0105ebf130b86dd0c Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 21:18:12 +0800 Subject: [PATCH 15/18] =?UTF-8?q?feat(ui):=20=E5=B1=9E=E6=80=A7=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E6=94=B9=E4=B8=A4=E5=88=97=E5=8D=A1=E7=89=87=E5=BC=8F?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=EF=BC=88=E8=BE=B9=E6=A1=86/=E5=BA=95?= =?UTF-8?q?=E8=89=B2/=E5=88=86=E9=9A=94=E7=BA=BF=EF=BC=8C=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=8D=8F=E8=B0=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/panels/DynamicFormView.cpp | 191 +++++++++++++++++++++++------ src/app/panels/DynamicFormView.hpp | 12 +- 2 files changed, 162 insertions(+), 41 deletions(-) diff --git a/src/app/panels/DynamicFormView.cpp b/src/app/panels/DynamicFormView.cpp index c5af31d..b2dd154 100644 --- a/src/app/panels/DynamicFormView.cpp +++ b/src/app/panels/DynamicFormView.cpp @@ -1,6 +1,7 @@ #include "panels/DynamicFormView.hpp" -#include +#include +#include #include #include #include @@ -9,7 +10,67 @@ namespace geopro::app { -DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) { +namespace { + +// 两列字段网格的逻辑列:label/value 各两份,value 列吸收伸展、label 列贴合内容。 +constexpr int kColLabelA = 0; +constexpr int kColValueA = 1; +constexpr int kColLabelB = 2; +constexpr int kColValueB = 3; +constexpr int kColSpanAll = 4; // 分组标题带横跨全部 4 列 + +// 字段标签(次要色,右侧留点呼吸,顶对齐以配合值换行)。 +QLabel* makeLabel(const QString& text) +{ + auto* k = new QLabel(text); + k->setAlignment(Qt::AlignLeft | Qt::AlignTop); + geopro::app::applyTokenizedStyleSheet( + k, QStringLiteral("color:{{text/secondary}}; background:transparent; padding:2px 0;")); + return k; +} + +// 字段值(主色、可换行、可选中复制)。 +QLabel* makeValue(const QString& text) +{ + auto* v = new QLabel(text); + v->setWordWrap(true); + v->setAlignment(Qt::AlignLeft | Qt::AlignTop); + v->setTextInteractionFlags(Qt::TextSelectableByMouse); + geopro::app::applyTokenizedStyleSheet( + v, QStringLiteral("color:{{text/primary}}; background:transparent; padding:2px 0;")); + return v; +} + +// 行间横向分隔线(1px,divider 令牌,随主题重着色)。 +QFrame* makeRowDivider() +{ + auto* line = new QFrame(); + line->setFrameShape(QFrame::HLine); + line->setFrameShadow(QFrame::Plain); + line->setFixedHeight(1); + geopro::app::applyTokenizedStyleSheet( + line, QStringLiteral("background:{{divider}}; border:none;")); + return line; +} + +// 分组标题带:横跨整行的淡底强调条,半粗次要色,给表单清晰的层级。 +QLabel* makeGroupHeader(const QString& name) +{ + auto* title = new QLabel(name); + geopro::app::applyTokenizedStyleSheet( + title, QStringLiteral("color:{{text/secondary}}; background:{{bg/hover}};" + "font-weight:%1; font-size:%2px;" + "border-radius:%3px; padding:5px 10px;") + .arg(geopro::app::type::kWeightSemibold) + .arg(geopro::app::scaledPx(geopro::app::type::kBody)) + .arg(geopro::app::radius::kSm)); + return title; +} + +} // namespace + +DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) +{ auto* outer = new QVBoxLayout(this); outer->setContentsMargins(0, 0, 0, 0); outer->setSpacing(0); @@ -17,65 +78,119 @@ DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) { auto* scroll = new QScrollArea(this); scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); + + // 滚动内容宿主:仅承载表单卡片,四周留出与面板一致的内边距,让卡片浮于面板底上。 auto* host = new QWidget(); - content_ = new QVBoxLayout(host); - content_->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, - geopro::app::space::kLg, geopro::app::space::kMd); - content_->setSpacing(geopro::app::space::kSm); - content_->addStretch(); + auto* hostLayout = new QVBoxLayout(host); + hostLayout->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, + geopro::app::space::kLg, geopro::app::space::kLg); + hostLayout->setSpacing(0); + + // 表单卡片:浅一档底色 + 1px 边框 + 中圆角,从面板底上读出独立「表单」面。 + card_ = new QFrame(); + card_->setObjectName(QStringLiteral("attrForm")); + geopro::app::applyTokenizedStyleSheet( + card_, QStringLiteral("#attrForm { background:{{bg/panel-subtle}};" + "border:1px solid {{border/default}}; border-radius:%1px; }") + .arg(geopro::app::radius::kMd)); + cardLayout_ = new QVBoxLayout(card_); + cardLayout_->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, + geopro::app::space::kLg, geopro::app::space::kLg); + cardLayout_->setSpacing(geopro::app::space::kMd); + + hostLayout->addWidget(card_); + hostLayout->addStretch(); scroll->setWidget(host); outer->addWidget(scroll); showMessage(QStringLiteral("(选中后显示属性详情)")); } -void DynamicFormView::clear() { - while (content_->count() > 0) { - QLayoutItem* it = content_->takeAt(0); +void DynamicFormView::clear() +{ + while (cardLayout_->count() > 0) { + QLayoutItem* it = cardLayout_->takeAt(0); if (it->widget()) it->widget()->deleteLater(); + if (it->layout()) { + // 嵌套网格:先回收其子控件,再删布局,避免残留控件泄漏/重叠。 + QLayout* sub = it->layout(); + while (sub->count() > 0) { + QLayoutItem* sit = sub->takeAt(0); + if (sit->widget()) sit->widget()->deleteLater(); + delete sit; + } + delete sub; + } delete it; } } -void DynamicFormView::showMessage(const QString& message) { - clear(); +void DynamicFormView::showCardMessage(const QString& message) +{ auto* hint = new QLabel(message); hint->setAlignment(Qt::AlignCenter); - geopro::app::applyTokenizedStyleSheet(hint, - QStringLiteral("color:{{text/disabled}}; padding:16px;")); - content_->addWidget(hint); - content_->addStretch(); + geopro::app::applyTokenizedStyleSheet( + hint, QStringLiteral("color:{{text/disabled}}; background:transparent; padding:%1px;") + .arg(geopro::app::space::kXl)); + cardLayout_->addWidget(hint); } -void DynamicFormView::setForm(const geopro::data::DynamicForm& form) { +void DynamicFormView::showMessage(const QString& message) +{ + clear(); + showCardMessage(message); + cardLayout_->addStretch(); +} + +void DynamicFormView::setForm(const geopro::data::DynamicForm& form) +{ clear(); if (form.groups.empty()) { - showMessage(QStringLiteral("(暂无属性)")); + showCardMessage(QStringLiteral("(暂无属性)")); + cardLayout_->addStretch(); return; } - for (const auto& group : form.groups) { - auto* title = new QLabel(QString::fromStdString(group.name)); - geopro::app::applyTokenizedStyleSheet( - title, QStringLiteral("color:{{text/secondary}}; font-weight:%1; padding-top:6px;") - .arg(geopro::app::type::kWeightSemibold)); - content_->addWidget(title); - auto* form_w = new QWidget(); - auto* fl = new QFormLayout(form_w); - fl->setContentsMargins(0, 0, 0, 0); - fl->setLabelAlignment(Qt::AlignLeft | Qt::AlignTop); - fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - for (const auto& f : group.fields) { - auto* k = new QLabel(QString::fromStdString(f.name)); - geopro::app::applyTokenizedStyleSheet(k, QStringLiteral("color:{{text/secondary}};")); - auto* v = new QLabel(QString::fromStdString(f.value)); - v->setWordWrap(true); - geopro::app::applyTokenizedStyleSheet(v, QStringLiteral("color:{{text/primary}};")); - fl->addRow(k, v); + for (const auto& group : form.groups) { + // 每组一个独立网格:分组标题带横跨 4 列,字段两列自上而下、自左而右铺排。 + auto* grid = new QGridLayout(); + grid->setContentsMargins(0, 0, 0, 0); + grid->setHorizontalSpacing(geopro::app::space::kLg); + grid->setVerticalSpacing(geopro::app::space::kXs); + grid->setColumnStretch(kColValueA, 1); + grid->setColumnStretch(kColValueB, 1); + + // gridRow 线性递增:标题占一行,之后「分隔线行 + 字段对行」交替。 + int gridRow = 0; + grid->addWidget(makeGroupHeader(QString::fromStdString(group.name)), gridRow, 0, 1, + kColSpanAll); + ++gridRow; + + const int n = static_cast(group.fields.size()); + if (n == 0) { + grid->addWidget(makeLabel(QStringLiteral("(本组暂无字段)")), gridRow, 0, 1, + kColSpanAll); } - content_->addWidget(form_w); + // 字段两两成对,每对一行:偶数下标落左对(labelA|valueA),奇数落右对(labelB|valueB)。 + for (int i = 0; i < n; ++i) { + const auto& f = group.fields[static_cast(i)]; + const bool left = (i % 2 == 0); + if (left) { + // 每新起一行前先放一条横向分隔线(铺满 4 列),再让字段对落到下一行。 + grid->addWidget(makeRowDivider(), gridRow, 0, 1, kColSpanAll); + ++gridRow; + } + const int labelCol = left ? kColLabelA : kColLabelB; + const int valueCol = left ? kColValueA : kColValueB; + grid->addWidget(makeLabel(QString::fromStdString(f.name)), gridRow, labelCol); + grid->addWidget(makeValue(QString::fromStdString(f.value)), gridRow, valueCol); + if (!left) ++gridRow; // 右对填满,行完结;左对则等右对或循环结束 + } + + cardLayout_->addLayout(grid); } - content_->addStretch(); + + cardLayout_->addStretch(); } } // namespace geopro::app diff --git a/src/app/panels/DynamicFormView.hpp b/src/app/panels/DynamicFormView.hpp index c3c6cd3..31ea617 100644 --- a/src/app/panels/DynamicFormView.hpp +++ b/src/app/panels/DynamicFormView.hpp @@ -3,10 +3,13 @@ #include "repo/RepoTypes.hpp" class QVBoxLayout; +class QFrame; namespace geopro::app { -// 被动:渲染 DynamicForm(分组键值)。对象属性 / 数据集属性两面板共用。 +// 被动:渲染 DynamicForm(分组键值)为「两列卡片式属性表单」。对象属性 / 数据集属性两面板共用。 +// 视觉:外层滚动区内嵌一张带边框/底色/圆角的表单卡片;每组一个分组标题带(横跨整行), +// 组内字段两列排布(labelA|valueA labelB|valueB),行间细分隔线。颜色全走主题令牌。 class DynamicFormView : public QWidget { public: explicit DynamicFormView(QWidget* parent = nullptr); @@ -14,8 +17,11 @@ public: void showMessage(const QString& message); // 空/错占位 private: - void clear(); - QVBoxLayout* content_ = nullptr; // 滚动区内容布局 + void clear(); // 拆掉卡片内全部内容(含分隔线/标题/字段) + void showCardMessage(const QString& message); // 卡片内居中淡色提示 + + QFrame* card_ = nullptr; // 表单卡片(objectName=attrForm) + QVBoxLayout* cardLayout_ = nullptr; // 卡片内纵向布局(容纳各分组网格 / 占位提示) }; } // namespace geopro::app -- 2.40.1 From 3daaad3de3b745813d46da925ccfc21e4c40a600 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 21:22:39 +0800 Subject: [PATCH 16/18] =?UTF-8?q?fix(controller):=20setCheckedTms=20busy?= =?UTF-8?q?=20=E6=97=B6=E6=8C=82=E8=B5=B7=E9=87=8D=E6=94=BE(=E4=B8=8D?= =?UTF-8?q?=E4=B8=A2=E5=BC=83)=20+=20=E5=88=87=E9=A1=B9=E7=9B=AE=E6=B8=85?= =?UTF-8?q?=20currentParentId/=E6=8C=82=E8=B5=B7=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controller/WorkbenchNavController.cpp | 33 ++++++++++++++++++++--- src/controller/WorkbenchNavController.hpp | 4 +++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/controller/WorkbenchNavController.cpp b/src/controller/WorkbenchNavController.cpp index 4739b3b..2fabdcb 100644 --- a/src/controller/WorkbenchNavController.cpp +++ b/src/controller/WorkbenchNavController.cpp @@ -1,5 +1,7 @@ #include "WorkbenchNavController.hpp" +#include + #include "dto/NavDto.hpp" namespace geopro::controller { @@ -10,8 +12,8 @@ using data::Workspace; WorkbenchNavController::WorkbenchNavController(data::IProjectRepository& repo, QObject* parent) : QObject(parent), repo_(repo) {} -namespace { // RAII:进入公共导航操作时置忙(驱动等待光标),任何返回路径都复位——保证 busyChanged 配平。 +// 命名(非匿名)以匹配 controller 的 friend 声明,从而在析构时排空挂起的勾选请求。 struct BusyGuard { WorkbenchNavController* self; bool* busy; @@ -20,11 +22,16 @@ struct BusyGuard { emit self->busyChanged(true); } ~BusyGuard() { + WorkbenchNavController* ctrl = self; // 取本地副本:lambda 不能捕获成员 *busy = false; - emit self->busyChanged(false); + emit ctrl->busyChanged(false); + // 触发源是延迟合并发射,可能落在嵌套事件循环里:用队列调用在调用栈/嵌套循环展开后再排空, + // 那时 busy_ 已可靠为 false,重放才会真正执行(lambda 捕获的 ctrl 生命周期与应用一致,安全)。 + if (ctrl->checkedTmsPending_) + QMetaObject::invokeMethod( + ctrl, [ctrl] { ctrl->drainPendingCheckedTms(); }, Qt::QueuedConnection); } }; -} // namespace void WorkbenchNavController::start() { if (busy_) return; @@ -51,6 +58,10 @@ void WorkbenchNavController::loadProjectsAndStructure() { } lastProjects_ = ps.value.rows; tmExceptionCache_.clear(); + currentParentId_.clear(); // 切项目/工作空间重置选中态(spec §6) + currentParentConfType_ = 0; + checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放 + pendingCheckedTms_.clear(); QString curP; if (!ps.value.rows.empty()) { const auto& first = ps.value.rows.front(); @@ -107,6 +118,10 @@ void WorkbenchNavController::switchProject(const QString& projectId) { } lastStructNodes_ = st.value; tmExceptionCache_.clear(); + currentParentId_.clear(); // 切项目/工作空间重置选中态(spec §6) + currentParentConfType_ = 0; + checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放 + pendingCheckedTms_.clear(); emit structureLoaded(QString::fromStdString(currentProjectName_), st.value); } @@ -166,7 +181,11 @@ void WorkbenchNavController::loadMoreFiles() { } void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { - if (busy_) return; + if (busy_) { // 触发源是延迟合并发射,可能落在别的同步操作的嵌套事件循环里: + pendingCheckedTms_ = tmObjectIds; // 不丢弃,记下最新一次请求,待空闲重放 + checkedTmsPending_ = true; + return; + } BusyGuard guard(this, &busy_); auto nameOf = [this](const std::string& id) -> std::string { for (const auto& n : lastStructNodes_) @@ -198,6 +217,12 @@ void WorkbenchNavController::setCheckedTms(const QStringList& tmObjectIds) { emit exceptionTreeLoaded(groups, total); } +void WorkbenchNavController::drainPendingCheckedTms() { + if (busy_ || !checkedTmsPending_) return; + checkedTmsPending_ = false; // 先清标志再重放,避免重入自旋 + setCheckedTms(pendingCheckedTms_); // 此时 busy_=false,会正常执行 +} + void WorkbenchNavController::selectDataset(const QString& dsObjectId) { if (dsObjectId.isEmpty() || busy_) return; BusyGuard guard(this, &busy_); diff --git a/src/controller/WorkbenchNavController.hpp b/src/controller/WorkbenchNavController.hpp index 2f28103..136fe78 100644 --- a/src/controller/WorkbenchNavController.hpp +++ b/src/controller/WorkbenchNavController.hpp @@ -45,10 +45,14 @@ signals: void loadFailed(const QString& stage, const QString& message); private: + friend struct BusyGuard; // 允许在 guard 析构时排空挂起的勾选请求 void loadProjectsAndStructure(); // start + switchWorkspace 共用 + void drainPendingCheckedTms(); // 空闲后重放最近一次被挂起的勾选集 data::IProjectRepository& repo_; bool busy_ = false; + bool checkedTmsPending_ = false; + QStringList pendingCheckedTms_; std::vector lastProjects_; std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; std::string currentParentId_; -- 2.40.1 From 72abeaa1d8daf11b272ba46f13c3ef4c2ccba6b9 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 21:31:00 +0800 Subject: [PATCH 17/18] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=20DynamicFo?= =?UTF-8?q?rmView::clear()=20=E5=AF=B9=E5=B5=8C=E5=A5=97=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E9=87=8A=E6=94=BE=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit it==it->layout()(QLayout 即 QLayoutItem),原代码 delete sub 后又 delete it 双重释放。 触发:clear 一个已填充表单时(切项目/重选对象→showMessage/setForm)崩溃。 --- src/app/panels/DynamicFormView.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/panels/DynamicFormView.cpp b/src/app/panels/DynamicFormView.cpp index b2dd154..a4e6c5a 100644 --- a/src/app/panels/DynamicFormView.cpp +++ b/src/app/panels/DynamicFormView.cpp @@ -111,15 +111,15 @@ void DynamicFormView::clear() while (cardLayout_->count() > 0) { QLayoutItem* it = cardLayout_->takeAt(0); if (it->widget()) it->widget()->deleteLater(); - if (it->layout()) { - // 嵌套网格:先回收其子控件,再删布局,避免残留控件泄漏/重叠。 - QLayout* sub = it->layout(); + if (QLayout* sub = it->layout()) { + // 嵌套网格:先回收其子控件(控件归属 card_,删布局不会连带删),再让下方 + // delete it 释放该嵌套布局本身——注意 it == it->layout()(QLayout 即 QLayoutItem), + // 故此处不可再 delete sub,否则与 delete it 重复释放导致崩溃。 while (sub->count() > 0) { QLayoutItem* sit = sub->takeAt(0); if (sit->widget()) sit->widget()->deleteLater(); delete sit; } - delete sub; } delete it; } -- 2.40.1 From b7fbb0457c71498e8657bc4f290817d16506c03d Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 10 Jun 2026 21:33:07 +0800 Subject: [PATCH 18/18] =?UTF-8?q?=E6=B8=85=E7=90=86=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/geopro-desktop-m1-design.md | 376 ------------------ 1 file changed, 376 deletions(-) delete mode 100644 docs/superpowers/specs/geopro-desktop-m1-design.md diff --git a/docs/superpowers/specs/geopro-desktop-m1-design.md b/docs/superpowers/specs/geopro-desktop-m1-design.md deleted file mode 100644 index 34643dd..0000000 --- a/docs/superpowers/specs/geopro-desktop-m1-design.md +++ /dev/null @@ -1,376 +0,0 @@ -# Geopro 3.0 桌面客户端 — M1 架构设计 - -**日期**:2026-06-07 -**版本**:v2(已按双专家评审 + 数据核验修订;修订点见 §16) -**状态**:待用户复核 v2 -**范围**:M1 里程碑 = 完整工作台外壳 + 登录功能 + 三维视图(基础渲染 / dd_voxel 体绘制与切片 / DEM 地形) -**上位文档**:`docs/Geopro3.0_技术选型与架构规约.md`(技术基线,本文遵从其全部约束) - ---- - -## 1. 目标与范围 - -### 1.1 M1 交付目标 - -复刻 Geopro 3.0 最核心的「项目分析视图」桌面版,并把登录做实: - -1. **登录功能完全可用**:真实连接生产后端(`pop-api`),走验证码 + RSA 加密密码流程,token 安全存储。登录页样式参考现有 web 系统。 -2. **完整工作台外壳**:ADS 三区停靠布局,还原原型(左:对象树 + 数据集列表;中:2D/3D 视图 + 数据详情;右:异常列表/对象属性 + 属性)。 -3. **三维视图**(M1 核心难点): - - ① 基础渲染:剖面散点、网格等值面/等值线/标注、异常圈定(直接渲染) - - ② dd_voxel 体绘制 + 鼠标交互切片(dd_slice)——C++ 进程内三维插值;**追求可信体**(非演示性),故对输入数据有要求(见 §10、§13 分阶段) - - ④ DEM 地形起伏 + 影像贴图 - - 二维俯视相机预设(验证「单一场景」架构) -4. **业务数据来源**:登录联网;工作台业务数据 M1 用本地样本文件,经 Repository 抽象注入,未来无缝切 API。 - -### 1.2 不在 M1 范围 - -- ③ 雷达单/多通道渲染、⑤ 在线底图瓦片 → M1.5 -- 反演/数据处理算法本体(M1 只做「展示期插值」,不做反演) -- 项目管理、设备连接、在线监测、报告、平台后台、Web 端 -- 完整算法插件架构(进程隔离 + manifest)→ 规约 D-3 推迟;M1 仅以 `IInterpolator` 接口预留 -- 在线更新三通道(规约 §8) -- macOS 构建(M1 先 Windows / MSVC 2022,架构保持跨平台可移植) - ---- - -## 2. 关键决策记录 - -| 编号 | 决策 | 结论 | -|---|---|---| -| K-1 | 2D/3D 视图架构 | **单一 VTK 三维场景 + 相机预设切换**;底图做可插拔 GroundLayer(规约 §5.3、D-5)。被现有 web「Cesium 单 3D 引擎」实践印证。 | -| K-2 | 启动节奏 | 先出设计文档 → **spike 预研门槛** → 再写完整实现计划 | -| K-3 | M1 外壳范围 | 完整工作台(A 方案) | -| K-4 | 业务数据来源 | 登录走真实 API;业务数据本地样本 + Repository 抽象(B 方案) | -| K-5 | M1 三维内容 | ① 基础 + ② dd_voxel(可信体,图分阶段)+ ④ DEM;③ 雷达、⑤ 底图瓦片留 M1.5 | -| K-6 | 三维插值实现 | C++ 进程内(IDW 起步),`IInterpolator` 接口隔离、**返回 core 中立类型**;推迟完整插件架构(D-1/D-3) | -| K-7 | 坐标系 | **每数据源各记源 CRS + 各自 LocalFrame** → 统一 rebase 到唯一「项目世界系(局部米,含 Z 基准)」;GIS/经纬/底图用 PROJ 实时换算(见 §5) | -| K-8 | 构建/部署 | **方案②-修订**(经双专家评审+实机勘验改定):单一 Qt = **官方 MSVC 预编译 Qt**(`D:\Qt\6.11.1\msvc2022_64`);**VTK/ADS/QtKeychain 对接该官方 Qt**(VTK 源码编到 install 前缀、ADS/QtKeychain 走 FetchContent),**绝不走 vcpkg**(否则 vcpkg 再拉一份 Qt = 双份);仅非 Qt 依赖(GDAL/PROJ/OpenSSL/Eigen/...)走 vcpkg。**关键事实**:用户原装的 `D:\Qt\6.11.1` 是 **MinGW 版**,MSVC 下不可链,须补装 MSVC kit;VTK 无 MSVC 预编译、三方案均须源码编;VS18=MSVC 14.51 链官方 Qt(v143)属"新链旧"ABI 安全。 | -| K-9 | 视图 widget | 评估 **`QVTKOpenGLStereoWidget`(QOpenGLWidget 系)** 优先于 native,缓解 ADS reparent 上下文丢失(spike 验证) | -| K-10 | dd_voxel 可信度 | 维持可信体目标;可信度取决于输入数据充分性(≥3 非共线剖面或 3D 网格),列为数据依赖(见 §10、§14) | - ---- - -## 3. 分层架构与目录结构 - -遵循规约 §10.3 清晰分层(core / data / view / controller / app),细分 net、render。 - -``` -geopro/ -├─ CMakeLists.txt / CMakePresets.json / vcpkg.json -├─ .clang-format / .clangd # AI 编码上下文基础设施(规约 §10.1) -├─ cmake/ # Find 模块、打包、dll 部署 -├─ src/ -│ ├─ core/ # 纯业务,零 Qt / 零 VTK 依赖(可独立单测) -│ │ ├─ model/ # Project, GsObject, TmObject, DsObject, Anomaly, ColorScale, Grid, ScatterField, ScalarVolume -│ │ ├─ geo/ # LocalFrame(原点+Z基准+轴向)、CrsTransform(PROJ 封装,多 CRS) -│ │ └─ algo/ # IInterpolator 接口 + IdwInterpolator(返回 core 的 ScalarVolume,绝不含 VTK) -│ ├─ data/ # 数据访问层(异步契约) -│ │ ├─ repo/ # IProjectRepository, IDatasetRepository(QFuture/回调 + 取消 + 分页) -│ │ ├─ local/ # LocalSampleRepository(QtConcurrent 线程池跑解析)+ 各格式解析器 -│ │ ├─ api/ # ApiRepository(M1 骨架,签名对齐) -│ │ └─ dto/ # 后端 JSON DTO + → model 映射 -│ ├─ net/ # ApiClient(QtNetwork)/ AuthService(验证码+RSA+login2)/ Credential(QtKeychain) -│ ├─ render/ # VTK 渲染层(独占 vtkRenderWindow,统一管理所有 actor) -│ │ ├─ Scene # 场景图、世界坐标空间、可见性;持有 RenderWindow -│ │ ├─ actors/ # ScatterActor, GridContourActor, VoxelVolumeActor, AnomalyActor, TerrainActor -│ │ ├─ color/ # ColorLutBuilder(colorBar → vtkLookupTable 离散阶梯), ScalarBar -│ │ ├─ camera/ # CameraPreset(Top2D / Free3D) -│ │ ├─ interact/ # InteractionManager + InteractionTool(MeasureTool/SliceTool/PickSelectTool) -│ │ └─ ground/ # IGroundLayer + DemImageGroundLayer(M1);TileGroundLayer(M1.5 预留) -│ ├─ view/ # QtWidgets 视图(被动;持有 VTK widget 外壳,不 new actor) -│ │ ├─ login/ # LoginWindow(样式参考 web) -│ │ ├─ panels/ # ObjectTreePanel, DatasetListPanel, MapViewPanel(QVTKOpenGLStereoWidget), -│ │ │ # DataDetailPanel, AnomalyPanel, ObjectPropertyPanel, PropertyPanel -│ │ └─ widgets/ # ColorScaleEditor, ToolbarBits -│ ├─ controller/ # 联动编排(按交互闭环拆分,避免 God Object) -│ │ ├─ SelectionController # 勾选/选中状态 -│ │ ├─ RenderSyncController # 状态→Scene 渲染同步 -│ │ └─ DetailSyncController # 列表↔详情↔视图定位三向联动 -│ └─ app/ # main / MainWindow(ADS 布局、主题)/ AppContext(DI 根) -├─ resources/ # QSS 主题、QtAwesome、登录页素材 -├─ tests/ # gtest(core/data/algo)+ Qt Test(view/controller) -└─ docs/ -``` - -**架构铁律(写入 .clangd 供 AI 读取)**: - -- `core` 绝不 `#include` 任何 Qt / VTK 头(含 `IInterpolator`,返回 `core::ScalarVolume`)。 -- VTK actor / RenderWindow 一律由 `render` 层创建与持有;`view` 只持有 `QVTKOpenGLStereoWidget` 外壳,把其 interactor 注入 render,并将**拾取/交互事件回流**给 controller(见 §4.4),禁止直接 new actor。 -- 数据流双向已显式化:`view → render`(交互注入)与 `render → controller`(拾取/选择出站信号)。 -- 信号槽连接集中在各 `*Controller` / `MainWindow` 的 `wireUp()`。 -- 所有落盘路径经 `QStandardPaths`(规约 §7.1)。 - ---- - -## 4. 渲染核心:单一 VTK 场景(K-1) - -> **⚠️ 实现修正(2026-06-07,经离屏 PNG 核对;权威做法以此为准,详见 `plans/2026-06-07-m1-view-redesign.md` + STATUS)** -> 本节 §4.2「2D/3D 仅切相机预设、零数据重建」的理想对**当前 M1 测线数据不成立**:剖面是竖直帘面,俯视只剩一条发丝线 → 俯视图空白。M1 落地做法: -> - **二维地图** 与 **三维视图** 是**两种不同渲染内容**(非同一物体换相机):二维地图 = 测线 `lat/lon` 轨迹**线**(`MapLineActor`,俯视);三维视图 = 沿测线的**竖直帘面**(`CurtainActor`,z 取负、纵向夸张、分段色带)。 -> - **数据详情**(独立 QVTK)才显示单条数据集的 **#18 平面反演剖面**(`GridContourActor`,y 取负、显式 structuredGrid、colorBar 真实分段值、纵向夸张)。 -> - 坐标统一用 `core::GeoLocalFrame`(经纬→局部米)。**dd_voxel/dd_slice 搁置**(散点 projX/Y 真实 CRS 未确认,无法与 lat/lon 配准)。 -> - K-1「单场景 + 相机预设」仍是**长期目标**,但需要从俯视/透视都可读的内容(如带底图的地面 + 测线落地线 + 帘面共存)才成立。 -> - **等值线/体素着色必须用 colorBar 真实非均匀分段值**(均匀分级会一片蓝)。**渲染改动必须用 `tests/spike/render_verify.cpp` 离屏 PNG 核对**。 - -### 4.1 Scene 与 RenderWindow 所有权 - -- `render::Scene` 持有**唯一** `vtkRenderWindow` + `vtkRenderer` + 项目世界坐标空间里的全部 actor,维护当前色阶与坐标系。 -- **单一 `QVTKOpenGLStereoWidget`**(K-9,QOpenGLWidget 系,FBO 合成、reparent 友好)承载渲染窗口,**不放进 Tab**;中央面板的「二维/三维」是工具栏上的模式切换,不是两个 widget。 -- view 仅持有该 widget 外壳;RenderWindow/Interactor 所有权归 render。 - -### 4.2 2D / 3D = 三要素组合 - -| 模式 | CameraPreset | InteractorStyle | 典型可见性 | -|---|---|---|---| -| 二维 | Top2D:正交投影、俯视、Z 锁定 | Locked2D:禁旋转,平移/缩放/正南正北 | 地面 + 平面要素 + 俯视散点/网格 | -| 三维 | Free3D:透视、自由轨道 | Orbit3D:自由旋转/缩放/平移 | 全部 actor(体素、剖面、地形起伏) | - -切换 = 切相机预设 + 交互器样式 + 工具集 + actor 可见性,**零数据重建**。 - -### 4.3 数据 → VTK 管线映射(已按评审修正) - -| 数据类型 | 来源 | VTK 管线(修正后) | 备注 | -|---|---|---|---| -| 剖面散点 | 剖面原数据(2597 点) | `vtkPolyData`(verts) + `vtkLookupTable` 着色 | 图 #17 | -| 网格等值面/线/标注 | 网格数据(规则栅格 x[100]×y[22],v[22][100],z 抬升) | **`vtkImageData`(origin+spacing)→(z 抬升用 `vtkWarpScalar`)→ `vtkDataSetSurfaceFilter`/`vtkGeometryFilter` → `vtkBandedPolyDataContourFilter`(开 `GenerateContourEdgesOn()` 一次产 banded 面+等值线,共用阈值)→ `vtkLabeledDataMapper` 标注** | 图 #18;**不可让 structured/image 直连 banded filter**(B-1) | -| dd_voxel 体绘制 | 多剖面散点 → `IInterpolator` → `core::ScalarVolume` | `ScalarVolume` →(render 转)`vtkImageData` → `vtkGPUVolumeRayCastMapper` + 颜色/不透明度传递函数 | 图 #09;插值域受限(§10) | -| dd_slice 切片 | voxel + 受控切面 | **`vtkResliceCursorWidget` / `vtkImageReslice`**(受控正交/任意切片),随相机模式启停 | 替代 `vtkImagePlaneWidget`(避免与交互器抢事件,M-2) | -| 异常圈定 | 异常数据(markType 1点/2线/3面 + legend + z/elevation) | **按 markType 三条子管线**:点 `vtkGlyph3D`(pointShape)、线 polydata+dashed、面 `vtkPolygon`/`vtkTriangleFilter` 填充+边框;标注屏幕空间 billboard | legend 的 `*NoOpacity` 0–100 → 归一 [0,1];z 取值同剖面 Z 基准(§5) | -| DEM 地形 + 影像 | dem.tif + image.tif + tfw(**可能异源 CRS**) | GDAL 读 → **PROJ/GDAL 重投影到项目世界 CRS** → `vtkImageData` → `vtkWarpScalar` 抬升 + 影像纹理 | 图 #05;影像实测为 EPSG:3857,须重投影(§5、M-1) | -| 色阶 | colorBar:[值, 颜色] 阶梯 | `vtkLookupTable`(离散阶梯,取下界)+ `vtkScalarBarActor` | 见 §7 | - -### 4.4 模态交互与拾取回流(M-2、B-3) - -- `InteractionManager` 管理**模态工具**激活互斥与 VTK observer 优先级:`MeasureTool`、`SliceTool`、`PickSelectTool`。工具激活/退出负责其临时 actor 生命周期。 -- 3D Widget(切片)与自定义 InteractorStyle 共享同一 interactor,须显式管理 `SetEnabled()` 与事件优先级,避免抢事件。 -- **拾取回流通道**:`render` 拾取到对象 → 经 view 中转发出出站信号 → `DetailSyncController` → 列表/详情定位。此箭头在分层图中显式存在(§3)。 - -### 4.5 GroundLayer 可插拔 - -`IGroundLayer { build(Scene&); setVisible(bool); }`:M1 `DemImageGroundLayer`;M1.5 `TileGroundLayer`。若 VTK 贴瓦片体验差(D-5),可仅替换二维为 MapLibre 而不动 data/render 的 actor 体系。 - ---- - -## 5. 坐标系设计(K-7,评审最大短板,已重写) - -数据现实(已核验真实样本): - -- 剖面/网格/异常:带 GIS 投影 `projectX`≈516868=**Easting**、`projectY`≈2494259=**Northing**;另带局部米 `xlist/ylist`(各数据集自原点起算)。 - - ⚠️ **CRS 待确认(Phase 1 用 PROJ 实测纠正)**:`projectX/Y` 的真实 CRS **不是 EPSG:32649**。PROJ 实测 `(516868,2494259)` 在 EPSG:32649 下解出 lon≈**111.16°E**,而网格自带 lat/lon 是 **114.16°E**(docx 标明为**香港** Volia 数据,香港≈114°E)——真实 CRS 的中央经线在 ~114°E(疑为港式/自定义 TM)。**做底图/影像配准(M1.5)前必须向客户确认项目 CRS**。 - - **对 M1 core 无影响**:`LocalFrame` 用相对米(减原点,CRS 无关),网格自带 lat/lon;`CrsTransform` 已实现并单测验证 PROJ 机制本身。 -- 影像 `image.tfw`:原点 (12708343, 2577685) = **EPSG:3857(Web 墨卡托)**,与剖面**不同投影**。 -- 网格另带 `elevation[100]` / `lat/lon`(经纬度,EPSG:4326)。 -- API 几何 `tm/geometry/get` 返回 **EPSG:4326**。 - -**设计**: - -1. **唯一权威系 = 项目世界系**:局部米,含明确 Z 基准;选定一个工作平面 CRS(默认项目 UTM,如 EPSG:32649)+ 双精度原点偏移。 -2. **每数据源各记源 CRS + 源 LocalFrame**:领域模型为每个数据集保存其源 CRS 与(如有)自身局部原点。**不假设全项目单一 CRS**。 -3. **统一 rebase 管线**(显式步骤,非一句话):任何几何进入 Scene 前,`CrsTransform`(PROJ)把 `源局部米 →(源原点)→ 源 CRS GIS → 项目世界 CRS →(减项目原点)→ 项目世界米`。多数据集因此对齐到同一世界系(解决 B-1 多原点冲突)。 -4. **轴向约定钉死**:world.x = Easting = `projectX`,world.y = Northing = `projectY`,world.z 向上为正、单位米。**解析器不信 `eastCoord/northCoord` 字段名**(实测与值颠倒),按 projectX/Y 取值,单测对照(B-2)。 -5. **垂向(Z)基准统一**(M-3):`LocalFrame` 定义高程基准面、向上为正、单位米、可选垂向夸张 z-scale。网格 z(剖面深度/构造面)、DEM `elevation`(地表高程)、体素 Z 在进入 Scene 前统一归算到该基准,避免地形与剖面垂直穿插。 -6. **影像/DEM 重投影**:装载时经 GDAL/PROJ 重投影到项目世界 CRS 再贴地,**不能简单减原点**(M-1)。 -7. **float 精度**:世界=局部米(小数值)从根本规避 VTK float 大坐标抖动。 - ---- - -## 6. 数据层:Repository(K-4,异步契约) - -接口即按 **API 现实形态**定义(异步 + 分页 + 取消),本地实现用 QtConcurrent 满足同一签名(M-1): - -``` -IProjectRepository { - QFuture loadProject(id); - QFuture> loadStructure(projectId); // GS/TM 树 -} -IDatasetRepository { - QFuture> listDatasets(tmObjectId, PageReq); // 分页 - QFuture loadScatter(dsId); - QFuture loadGrid(dsId); - QFuture loadColorScale(dsId); - QFuture> loadAnomalies(dsId); - QFuture loadTerrain(...); - // 大数据(体素/雷达):返回带取消句柄 + 进度回调;M1.5 走 FlatBuffers/Protobuf 流 - RequestHandle loadVolumeStream(dsId, sink, onProgress); // 可 cancel -} -``` - -- 切换 ds 时取消上一个未完成请求;列表类带游标/分页;大数据流式 + 进度 + 取消。 -- **M1**:`LocalSampleRepository` 读样本目录,解析器映射成领域模型(DTO ↔ model 在 `data/dto` 隔离)。 -- **未来**:`ApiRepository` 同签名对接 `pop-api`。 - -### 6.1 样本文件 → 模型解析约定(已核对真实样本) - -| 文件 | 结构 | 解析要点 | -|---|---|---| -| 剖面原数据N.txt | `{data:{min,max,projectXList,projectYList,vlist,xlist,ylist,hlist}}` | 2597 点;local(x,y)+gis(projX=East,projY=North)+value | -| 剖面网格数据N.txt | `{data:{x[100],y[22],v[22][100],z[22][100],elevation[100],lat[100],lon[100],vmin,vmax,overlayCoordinate,overlayElevation}}` | **规则栅格**(dx≈0.709,dy≈0.704 恒定)→ vtkImageData;**v/z 为 [j=y][i=x],灌点序 i 最快**;无顶层 min/max;对未知字段宽容 | -| 剖面网格数据的色阶数据N.txt | `{data:{properties:{colorBar:[[值,rgba]],lineConfig,labelConfig,lvlMinMax}}}` | 17 段阶梯;`lineType` 实测 "solid"(以配置为准,勿硬编码 dashed) | -| 剖面网格数据N——对应的异常圈定数据.txt | `{data:[{exceptionName,exceptionMarkType(1点/2线/3面),legend{point*/polyline*/polygon*},location:{coordinate[{x,y}]},zlist?,elevationList?,geographicalCoordinates{projectX,projectY,...}}]}` | 字段比早期列举多;`eastCoord/northCoord` 名值颠倒,按 projectX/Y 取 | -| dem.tif / image.tif / image.tfw | GeoTIFF + world file | **影像 tfw 为 EPSG:3857**;GDAL 读 + PROJ 重投影到世界 CRS | -| test_001_A*.head/.data/.cor | GPR 原始(462×4100×int16,多通道分文件) | **属 M1.5 雷达,LocalSampleRepository 不解析** | - ---- - -## 7. 色阶(ColorScale) - -`colorBar` 为 `[值, 颜色]` 阶梯数组(颜色支持 `#RRGGBB` 与 `rgba(r,g,b,a)`)。映射:值落相邻两 stop 间取**下界 stop** 颜色(阶梯,非线性插值)。 - -- **实现统一为离散 `vtkLookupTable`**(贴合「取下界阶梯」语义,2D/3D 共用同一可信源),显式定义 under(低于首 stop)/ over(高于末 stop)/ NaN 颜色。 -- **alpha 量纲按色阶来源文件类型判定**(网格色阶 0–255、LVL 色阶 0–1),解析器入口带 source 标记,**不按数值范围猜**(m-2)。 -- `lineConfig`:等值线显隐/颜色/`lineType`(以配置为准)/zmin/zmax;`labelConfig`:标注显隐/颜色;`equalAreaLayerCount`/`logLinesCount`。 -- 视图层 `ColorScaleEditor`:M1 读取与基本调整;命名保存对接后续色阶模板 API。 - ---- - -## 8. 登录与网络层(M1 必做,真实流程已抓取) - -### 8.1 已确认的生产实现细节 - -- **API 基址** `http://tenant.geomative.cn/pop-api`(openresty 反代;OpenAPI 的 `/admin/*`、`/business/*` 加 `/pop-api` 前缀)。 -- **认证头** `geomativeauthorization: Geomative `(不透明会话令牌,非 JWT)。 -- **登录三步**:① `GET /business/system/personalUser/getImageCode`→验证码图+`codeId` → ② `POST /business/system/personalUser/verifyCodeCheck {code,codeId}` → ③ `POST /admin/tenant/auth/login2 {username, password=RSA加密, checkCode}`→token。 -- **密码加密 = JSEncrypt RSA-2048**(前端 vendor 用 JSEncrypt 库;密文 base64 ~344 字符 = 256 字节)。token 取响应 **`data.accessToken`**(值即 `"Geomative "`,存 web localStorage `token`)。 -- 另有 `/email`、`/phone` 登录支线(非 M1)。 -- 登录后:`getInfo` / `list-menus` / `enterprise/info` / `enterprise/joined/list`。 - -### 8.2 实现要点 - -- `AuthService`:取验证码→展示→校验→**OpenSSL RSA** 加密密码→login2→持有 token。 -- `Credential`(**QtKeychain**):token 存平台密钥库,严禁明文(规约 §7.4)。 -- `ApiClient`:注入 `geomativeauthorization`、基址、超时、错误码、401 处理;QtNetwork 原生。 -- **登录窗 UI**:样式参考现有 web 登录页(实现阶段截图复刻)。 - -### 8.3 ⚠️ 前置确认项(与 RSA 同级,M-5) - -抓取的真实流程里**未见 refresh-token 实际使用,login2 只返不透明会话 token**。因此: - -- **RSA 公钥已取得 ✅**(Phase 3,用 Playwright `page.route` 拦截 JS chunk 给 `setPublicKey` 注入 hook + 缓存绕过强制加载补丁版,触发一次真登录捕获)。RSA-2048 SPKI,存于 `resources/rsa_public_key.pem`。加密用 PKCS#1 v1.5(JSEncrypt 默认),`RsaEncryptor`(OpenSSL)已实现+单测。 -- **token 生命周期 / 是否有 refresh 机制**待确认。据此二选一设计: - - (a) 有 refresh token → 标准静默刷新、401 静默续期。 - - (b) 仅会话 token → 「免登录」= 持久化会话 token 至其有效期;**到期/401 引导用户重新登录(含验证码),不声称静默重登**。 -- 本项在 spike/实现前向后端确认;spec 不把「静默刷新」当既定能力。 - ---- - -## 9. UI 外壳:完整工作台(K-3) - -- **停靠框架**:ADS(LGPL,规约 §6.2)。**VTK 面板默认不可浮动**(或浮动时占位、停靠回重建),缓解 reparent 上下文问题(spike 验证,M-4)。 -- **三区布局**(还原原型):左(对象树 + 数据集列表)/ 中(2D-3D 视图 + 数据详情)/ 右(异常-对象属性 + 属性)。 -- **主题**:QSS + QDarkStyleSheet 打底 + QtAwesome 图标。 -- **布局持久化**:ADS 透视图 + 窗口几何存 QSettings(Windows 强制 INI,规约 §7.2)。 -- **联动**(controller 按闭环拆分,§3):勾选 GS/TM→按 dd 类型筛选 ds→勾选 ds→渲染;列表↔详情↔视图定位三向;色阶调整两视角实时更新。 - ---- - -## 10. 算法:展示期三维插值(K-6、K-10) - -- `core/algo/IInterpolator`:`core::ScalarVolume interpolate(const PointSet& pts, const GridSpec& spec)`——**返回 core 中立类型**(dims/spacing/origin/double 数组),绝不含 VTK(M-2)。render 层 `VoxelVolumeActor` 把 `ScalarVolume`→`vtkImageData`。 -- M1 实现 `IdwInterpolator`(反距离加权,Eigen 辅助;2597 点规模**不需要 PCL/KD-tree**,m-1)。 -- **可信度与数据依赖(K-10、B-3)**:可信体素需 **≥3 条非共线剖面或真实 3D 网格(dd_Property3D)/ 反演网格** 输入。仅两条近平行剖面 IDW 会得到「夹层片状」幻影。故: - - 插值**限定在输入包络内 + 最大距离 clamp**,包络外置 blank/透明,避免 ray cast 渲染整盒幻影。 - - M1 体绘制按 §13 分阶段:先在**充分输入**数据上出可信体;输入不足的复杂体后置。 - - **数据依赖**:需客户提供达到可信度的体素级输入数据(≥3 剖面 / 3D 网格)——列入 §14 待办。 -- **不做反演**(上游、Python 生态 ResIPy 等),未来按规约 §8.3 进程隔离接入,M1 仅接口预留。 - ---- - -## 11. 构建与依赖(K-8,方案②-修订:官方 MSVC Qt + 源码 VTK + vcpkg 非 Qt 依赖) - -- **构建**:CMake 3.21+。MSVC 工具集 **VS18 / 14.51**(实机),C++17;生成 `compile_commands.json`。 -- **单一 Qt 纪律(核心)**:全链路只用**一份官方 MSVC 预编译 Qt**(`D:\Qt\6.11.1\msvc2022_64`,经 `CMAKE_PREFIX_PATH`)。**凡依赖 Qt 的组件都不走 vcpkg**(vcpkg 任何 Qt 依赖端口都会再编一份 qtbase = 双份冲突,已核 `ports/vtk/vcpkg.json`:`vtk[qt]`→`qtbase`+`qtdeclarative`)。 -- **VTK**:无 MSVC 预编译,**必须源码编**。预先用官方 Qt 把 VTK 9.3 配置/编译/`install` 到 `external/vtk-install`(`-DVTK_GROUP_ENABLE_Qt=YES -DQt6_DIR=...`),app 经 `VTK_DIR` `find_package(VTK)`。一次编好、隔离于 app 构建。 -- **ADS / QtKeychain**:经 **FetchContent** 对接同一份官方 Qt(体量小,源码编可接受),**不走 vcpkg**。 -- **非 Qt 依赖经 vcpkg**:GDAL/PROJ/OpenSSL/Eigen/spdlog/fmt/nlohmann-json/gtest(这些不拉 Qt)。 -- **M1 依赖矩阵**: - -| 依赖 | 来源 | 用途 | 许可证 | -|---|---|---|---| -| Qt 6.11.1(msvc2022_64,预编译) | 官方安装器(MSVC kit) | UI/网络/SQL/并发 | LGPLv3(动态)⚠️ 商务 D-2 | -| VTK 9.3([qt,opengl] + gdal/proj 可选) | **源码编→install 前缀** | 三维渲染 + QVTK widget | BSD ✅ | -| ADS(Qt-Advanced-Docking-System) | FetchContent(对接官方 Qt) | 停靠布局 | LGPL v2.1 ✅ | -| QtKeychain | FetchContent(对接官方 Qt) | 凭证存储 | BSD ✅ | -| gdal / proj | vcpkg | DEM/影像/坐标重投影 | MIT 类 ✅ | -| openssl | vcpkg | RSA/HTTPS | Apache 2.0 ✅ | -| eigen3 | vcpkg(头文件) | 数值/插值 | MPL2 ✅ | -| spdlog / fmt | vcpkg | 日志 | MIT ✅ | -| nlohmann-json | vcpkg(头文件) | JSON | MIT ✅ | -| gtest | vcpkg | 单测 | BSD ✅ | - -- ~~PCL~~:**M1 移除**(点规模不需要)。 -- **ABI**:官方 Qt 为 MSVC 2022(v143)预编译,本机 VS18(14.51)编 VTK/app/ADS/QtKeychain;"新链旧"在 MSVC v14x 兼容区内**安全**,全程动态 CRT `/MD[d]`、Release 链 Release、Debug 链 Debug,不跨配置混链。 -- 部署:用**官方 Qt 的 `windeployqt`**(`D:\Qt\6.11.1\msvc2022_64\bin\windeployqt.exe`)部署 Qt + 插件;VTK/vcpkg dll 用 `TARGET_RUNTIME_DLLS` 拷贝。确保 exe 目录只有这一份 Qt。 -- **二进制缓存**:vcpkg 实测无缓存,落地前先配 `VCPKG_BINARY_SOURCES`(省 GDAL/PROJ 等重编)。 -- 环境从零搭建:见 `docs/ENV_SETUP_Windows.md`(方案②-修订)。 - ---- - -## 12. 测试策略(规约 §10.2) - -- `core`/`data`/`algo`:gtest(坐标 rebase/轴向、Z 基准归算、colorBar LUT 映射、v[j][i] 灌点序、样本解析、IDW 正确性)。 -- `view`/`controller`:Qt Test(联动、模态工具互斥)。 -- 失败路径必测:登录失败/验证码错/token 失效、样本缺失/损坏、空数据集、异源 CRS 重投影。 -- 渲染以小基准截图人工核对(对照图 #17/#18/#09)。 -- clang-tidy + cppcheck 入 CI;Debug 启用 ASan/UBSan。 - ---- - -## 13. M1 验收标准(图分阶段,K-5/K-10) - -**M1-a(先达成)** -1. 启动→登录窗(近 web)→真连 `pop-api` 登录成功。 -2. 完整工作台 ADS 三区停靠;对象树/数据集来自本地样本。 -3. ① 渲染剖面散点(图 #17 样)、网格 banded 等值面+等值线+标注(图 #18 样)、异常按 markType 圈定。 -4. ④ DEM 地形起伏 + 影像(经重投影对齐)。 -5. 色阶离散 LUT 可调,两视角实时联动;二维俯视相机预设可切。 - -**M1-b(在充分输入数据上达成可信体)** -6. 由 ≥3 剖面/3D 网格经 IDW 生成**可信** dd_voxel 体绘制,插值域受限;`vtkResliceCursorWidget` 交互切片得 dd_slice。 - -**通用**:core 单测通过;clang-tidy 无新增告警;布局/偏好持久化生效;2D/3D 切换零数据重建、坐标对齐正确。 - ---- - -## 14. 风险与待决(承接规约 §11) - -| 风险/待决 | 说明 | 处理 | -|---|---|---| -| D-2 Qt 许可证 | LGPL 动态 vs 商业 | M1 按 LGPL 动态链接;商务并行 | -| RSA 公钥来源 + token 生命周期 | 登录加密公钥、是否有 refresh | spike 前向后端确认(§8.3) | -| 可信体输入数据 | 2 平行剖面不足以出可信体(K-10) | 需客户提供 ≥3 剖面/3D 网格;M1-b 验收依赖此 | -| ADS 端口 + QVTK reparent | vcpkg 端口可用性、浮动黑屏 | spike 门槛验证(§15) | -| 全 vcpkg 首编译耗时 | VTK+Qt 编译久 | 二进制缓存 `VCPKG_BINARY_SOURCES` | -| 异源 CRS 配准 | 影像 3857 vs 剖面 32649 | GDAL/PROJ 重投影(§5) | -| macOS / OpenGL 废弃 | 规约 §3.3 | M1 仅 Windows;保持可移植 | - ---- - -## 15. Spike 预研门槛(K-2,进入完整实现计划前必过) - -第一周先跑通三个高风险点,任一不过则调整方案后再展开计划: - -1. **构建/部署 spike**:全 vcpkg(qtbase + vtk[qt] 共用一份 Qt)配置、编译、出 exe、单一链路部署、无双 Qt 冲突。 -2. **UI 上下文 spike**:ADS(vcpkg 或 FetchContent)+ `QVTKOpenGLStereoWidget`,验证停靠/浮动/重停靠不黑屏、相机预设切换稳定。 -3. **渲染管线 spike**:用真实样本跑通 `vtkImageData(+warp) → geometry filter → vtkBandedPolyDataContourFilter(GenerateContourEdges) → 标注`,目视对照图 #18;散点 + 离散 LUT 色阶对照图 #17。 - ---- - -## 16. v2 修订记录(对应评审) - -- 网格管线改 `vtkImageData(+warp)→geometry filter→banded contour(GenerateContourEdges)`(B-1/B-2 code)。 -- 坐标系重写:多源 CRS + 各自 LocalFrame + 统一 rebase + 轴向钉死 + Z 基准 + 影像重投影(B-1/B-2 arch、M-1/M-3 code、M-3 arch)。 -- dd_voxel 维持可信体但列数据依赖、插值域受限、验收分阶段(B-3 code、K-10)。 -- Repository 改异步契约(分页/取消/流)(M-1 arch)。 -- `IInterpolator` 返回 core `ScalarVolume`,去 VTK(M-2 arch)。 -- 交互:模态工具抽象 + 拾取回流 + 切片改 `vtkResliceCursorWidget`(B-3 arch、M-2 code)。 -- widget 改 `QVTKOpenGLStereoWidget` + VTK 面板不可浮动 + spike(M-4 code)。 -- 构建改全 vcpkg(已核 vtk[qt]→qtbase 依赖)、删 PCL(M-5 code、m-1)。 -- 登录 refresh/token 生命周期降为前置确认(M-5 arch)。 -- 色阶离散 LUT + under/over/NaN + alpha 按来源 + lineType 以配置为准(m-1/m-2/m-4 各)。 -- controller 拆 Selection/RenderSync/DetailSync(m-5 arch)。 -- 新增 §15 spike 门槛(K-2)。 - ---- - -*v2 经双专家评审 + 数据核验修订。下一步:spike 预研 → writing-plans。* -- 2.40.1