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` 是否一致(标记类型字段命名)。