feat/object-selection-panels #4
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,262 @@
|
|||
# 对象单击/勾选 驱动 数据列表·异常(含异常体)·属性 面板(接真实 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 真实剖面渲染。
|
||||
- 参考:`D:\Projects\GEOPRO\Geopro3.0 菜单.xlsx`「视图定义」表;`docs/apis/business_OpenAPI.json`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
`real-api-navigation` 轮已把顶层导航壳(工作空间/项目/对象树)接到真实后端并把渲染与导航解耦:对象树已显示真实结构(项目根→GS→TM,叶子=TM 可勾选),单击 TM 已能列出其 DS(数据/文件分页)。但 `tmCheckToggled`、右上「异常」(本地 `core::Anomaly` 占位)、「对象属性」「数据集属性」面板均为**占位**。
|
||||
|
||||
本轮补齐三件交互,全部接**真实业务 API**(不回退本地样本):
|
||||
|
||||
1. **单击对象(GS 或 TM)** → ① 左下数据列表显示其下所有 DS(GS=该工区全部 DS;TM=该测线 DS);② 右上「对象属性」显示该对象动态表单详情。当前单击行高亮(单选)。
|
||||
2. **勾选 / 反勾选 GS/TM** → 右上「对象异常」面板显示**所有勾选对象**下的「**异常 + 异常体**」树(见 §2 决策、§7.3)。
|
||||
3. **单击数据列表某 DS** → 右下「数据集属性」显示该 DS 动态表单详情。
|
||||
|
||||
**非目标(仍占位/推迟)**:中央 2D/3D 与「数据详情」dd/ert 真实剖面渲染;异常项「眼睛(显隐)」(联动 VTK,与 VTK 同轮接);异常体的「拖拽合并/编辑」(写操作);异步仓储;文件下载动作。`render/*`、`LocalSampleRepository`、旧 `core::Anomaly` 渲染路径**保留不删**。
|
||||
|
||||
## 2. 关键决策(需求方已拍板)
|
||||
|
||||
| # | 决策点 | 结论 |
|
||||
|---|---|---|
|
||||
| D1 | 三面板数据来源 | **接真实业务 API**(新增仓储方法 + DTO 映射),不复用本地样本 |
|
||||
| D2 | 勾选 GS 行为 | **联动**:勾 GS 自动勾其下所有 TM;子 TM 可单独取消(GS 转半选态);取消联动取消其异常 |
|
||||
| D3 | 「对象属性」跟随谁 | **跟随当前单击高亮的行**(与勾选集相互独立) |
|
||||
| D4 | 属性展示深度 | **完整动态表单**(分组键值);字段显示名取自 API(`FormItemVO.fieldName`) |
|
||||
| D5 | 异常面板含异常体 | **只读树**:「对象 → 异常体(ID/类型) → 异常(ID/类型)」+ 未合并的「独立异常」单列;**拖拽合并/编辑推迟** |
|
||||
| D6 | 详情展开 / 眼睛 | **详情展开做**(异常叶子可展开看其详情,内联已加载字段);**眼睛(显隐)推迟**(随 VTK 同轮) |
|
||||
|
||||
**D2 的连锁简化**:勾选源只数 **TM 叶子**(GS 仅作批量开关,且无 GS 级异常接口)→ GS→TM 展开由 Qt 原生三态复选框承担、控制器只读叶子集;聚合 = 被勾选 TM 异常/异常体的并集;**无去重问题**(叶子唯一)。
|
||||
|
||||
## 3. 接口映射
|
||||
|
||||
网关与会话沿用现有 `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]`。**异常的「异常体归属」字段需 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}`。渲染:组按 `groupSort`、字段按 `displaySort`,逐项 `fieldName : properties[fieldCode]`。
|
||||
|
||||
**`ExceptionVO` 关键字段**:`id, exceptionName, exceptionTypeName, exceptionMarkType, exceptionMarkTypeName, createTime, remark, location, latitudeLongitude, geographicalCoordinates, elevationList, zlist, parentId, parentConfType, type`。
|
||||
|
||||
## 4. 架构分层
|
||||
|
||||
```
|
||||
UI(app) ObjectTreePanel(三态勾选) DynamicFormView(新) ObjectExceptionPanel(新树面板)
|
||||
│ 信号(单击/勾选) ▲ 槽(模型)
|
||||
controller WorkbenchNavController ← selectObject / setCheckedTms / selectDataset
|
||||
│ IProjectRepository(同步契约)
|
||||
data ApiProjectRepository + NavDto(parseDynamicForm/parseExceptions/groupExceptionsByConsortium)
|
||||
│ ApiClient
|
||||
net ApiClient / AuthService(复用,不改)
|
||||
|
||||
模型(RepoTypes.hpp):+ DynamicForm 系列 / ExceptionRow / ConsortiumGroup / ObjectExceptionGroup
|
||||
```
|
||||
|
||||
## 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<DynamicFormField> fields; };
|
||||
struct DynamicForm { std::string name; std::vector<DynamicFormGroup> groups; }; // 已按 sort 排好
|
||||
|
||||
// 异常(树叶;本轮只读)。detailSummary = 详情展开内联显示的派生摘要(坐标/高程/标记类型/备注)。
|
||||
struct ExceptionRow {
|
||||
std::string id, name, typeName, createTime;
|
||||
std::string consortiumId, consortiumName, consortiumType; // 异常体归属(空=独立异常)
|
||||
std::string detailSummary; // 详情展开用(已加载字段拼接)
|
||||
};
|
||||
// 异常体分组(树中间层)。
|
||||
struct ConsortiumGroup { std::string id, name, typeName; std::vector<ExceptionRow> exceptions; };
|
||||
// 对象分组(树根层,对应一个被勾选 TM)。
|
||||
struct ObjectExceptionGroup {
|
||||
std::string objectId, objectName;
|
||||
std::vector<ConsortiumGroup> consortia; // 该对象下的异常体(含其异常)
|
||||
std::vector<ExceptionRow> looseExceptions; // 未合并进异常体的独立异常
|
||||
};
|
||||
```
|
||||
|
||||
### 5.2 DTO(`src/data/dto/NavDto.{hpp,cpp}`,纯函数,可单测)
|
||||
|
||||
```cpp
|
||||
DynamicForm parseDynamicForm(const QJsonObject& data); // formList(定义)+properties(值) 合并
|
||||
std::vector<ExceptionRow> parseExceptions(const QJsonArray& arr); // ExceptionVO→ExceptionRow(含 consortium* + detailSummary)
|
||||
|
||||
// 把一个对象(TM)的异常行,按 consortiumId 分组成「异常体列表 + 独立异常列表」。纯函数、可单测。
|
||||
// - 同 consortiumId 归一组(组名/类型取首个非空 consortiumName/Type);
|
||||
// - consortiumId 为空 → looseExceptions。
|
||||
struct GroupedExceptions { std::vector<ConsortiumGroup> consortia; std::vector<ExceptionRow> loose; };
|
||||
GroupedExceptions groupExceptionsByConsortium(const std::vector<ExceptionRow>& rows);
|
||||
```
|
||||
|
||||
> `detailSummary` 在 `parseExceptions` 内由 `exceptionMarkTypeName / createTime / 坐标(latitudeLongitude 或 location 质心) / 高程(elevationList/zlist 极值) / remark` 拼成可读多行串(无额外请求)。
|
||||
|
||||
### 5.3 数据访问层 `IProjectRepository` 扩展(`src/data/repo/IProjectRepository.hpp`)
|
||||
|
||||
```cpp
|
||||
// 既有 loadTmRows 泛化:structParentConfType 由硬编码 2 改入参(支持 GS=1)。
|
||||
virtual RepoResult<DsPage> 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<DynamicForm> loadObjectDetail(const std::string& objectId, int confType) = 0;
|
||||
// 数据集详情:dsObject/dynamicForm/{dsObjectId}。
|
||||
virtual RepoResult<DynamicForm> loadDatasetForm(const std::string& dsObjectId) = 0;
|
||||
// 单 TM 异常(含异常体归属字段):exception/queryExceptionByTmObjectId/{tmObjectId}。
|
||||
virtual RepoResult<std::vector<ExceptionRow>> loadExceptionsByTm(const std::string& tmObjectId) = 0;
|
||||
```
|
||||
|
||||
`ApiProjectRepository` 按 §3 实现;id 进 URL 前百分号编码;错误归一为 `RepoResult{ok=false,error=msg}`。
|
||||
|
||||
## 6. 逻辑层 `WorkbenchNavController`
|
||||
|
||||
```cpp
|
||||
public slots:
|
||||
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 exceptionTreeLoaded(const std::vector<geopro::data::ObjectExceptionGroup>& groups, int tmCount);
|
||||
void datasetDetailLoaded(const geopro::data::DynamicForm& form);
|
||||
private:
|
||||
std::string currentParentId_; int currentParentConfType_ = 0; // 加载更多用(替代 currentTmId_)
|
||||
std::vector<data::StructNode> lastStructNodes_; // tmId→name 解析
|
||||
std::map<std::string, std::vector<data::ExceptionRow>> tmExceptionCache_; // per-TM 异常缓存
|
||||
```
|
||||
|
||||
- `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`。
|
||||
- 切项目/工作空间:清 `tmExceptionCache_`、`currentParentId_`;经既有 `structureLoaded` 接线清空各面板。
|
||||
- 沿用 `BusyGuard` 重入保护 + `busyChanged`(同步阻塞 + WaitCursor)。`structureLoaded` 时控制器留存 `lastStructNodes_`。
|
||||
|
||||
## 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`。
|
||||
- **信号**:`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`,`setForm(const DynamicForm&)` / `showMessage(QString)`。
|
||||
- 布局:`QScrollArea` 内每组「组标题 + `QFormLayout`(左字段名/右值,值可换行)」;无数据→居中占位。主题化随 `ThemeManager::changed` 重绘。
|
||||
- 「对象属性」「数据集属性」两面板各持一个实例(取代现有两占位 `QLabel`)。
|
||||
|
||||
### 7.3 `ObjectExceptionPanel`(新增,异常+异常体 只读树)
|
||||
|
||||
替代右上「异常」Tab 内原扁平列表(原 `QListWidget`),改为 `QTreeWidget`:
|
||||
|
||||
```
|
||||
对象A(TM 名)
|
||||
├─ ▸ 异常体X(异常体类型) ← 中间层:ConsortiumGroup
|
||||
│ ├─ 异常1(异常类型) ← 叶子:ExceptionRow
|
||||
│ │ └─ ▸ 详情:标记类型/坐标/高程/创建时间/备注 ← D6 详情展开(内联,懒构建)
|
||||
│ └─ 异常2(异常类型)
|
||||
├─ 独立异常(未合并) ← looseExceptions 分组节点
|
||||
│ └─ 异常3(异常类型)
|
||||
对象B(TM 名)…
|
||||
```
|
||||
|
||||
- `void setGroups(const std::vector<ObjectExceptionGroup>&)`:重建树;`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`。
|
||||
- `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): objectClicked(id, confType)
|
||||
→ selectObject: loadRows×2(数据+文件首页)→datasetsLoaded/filesLoaded→左下;
|
||||
loadObjectDetail(id,confType)→objectDetailLoaded→右上「对象属性」
|
||||
|
||||
勾选/取消(GS三态级联到TM叶子): 合并→checkedTmsChanged([tm...])
|
||||
→ setCheckedTms: 逐TM(缓存)查异常→groupExceptionsByConsortium→[ObjectExceptionGroup]
|
||||
→ exceptionTreeLoaded → 右上「对象异常」树(对象→异常体→异常 + 独立异常)+ 徽标
|
||||
(取消某TM/GS → 叶子集变小 → 重算 → 其异常/异常体自动消失)
|
||||
|
||||
单击数据集: itemClicked(dsId)
|
||||
→ selectDataset: loadDatasetForm(dsId)→datasetDetailLoaded→右下「数据集属性」
|
||||
|
||||
切项目/空间: structureLoaded → 清空各面板 + 勾选集 + 异常缓存
|
||||
```
|
||||
|
||||
## 9. 边界与错误处理
|
||||
|
||||
- **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` 排序、缺失值→空、空 `formList`、`name` 透传。
|
||||
- `parseExceptions`:字段映射、`detailSummary` 拼接、`consortium*` 提取、空数组、缺字段容错。
|
||||
- `groupExceptionsByConsortium`:同 consortiumId 归组、空 consortiumId→loose、组名取首个非空、空输入、全独立、全归属。
|
||||
- UI 三态级联 / 控制器装配:依赖 Qt/live,手动联调验证(无桩)。
|
||||
|
||||
## 11. 文件清单
|
||||
|
||||
**改造**
|
||||
- `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/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/*`、`AnomalyListPanel`(`populateAnomalyList`/`AnomalyCardDelegate`/眼睛/VTK 联动)、中央/详情渲染代码(留未来 dd/ert 渲染 + 眼睛联动轮)。
|
||||
|
||||
## 12. 未决 / 验证点(实现前用 live 接口确认)
|
||||
|
||||
1. GS 级 `data/page` 的 `structParentConfType` 取值与是否递归返回全部 DS(§9 回退)。
|
||||
2. `DynamicFormVO.properties` 的索引键(`fieldCode` 假设)。
|
||||
3. **异常的「异常体归属」字段**(`consortiumId/Name/Type` 来源):单 TM 异常 payload 是否自带,否则走项目级异常体树映射(§9 回退)。
|
||||
4. `exceptionConsortium` 各字段命名与「异常体类型」取值来源(`exceptionTypeId` vs 类型名)。
|
||||
|
||||
## 13. 后续轮(本轮明确不做)
|
||||
|
||||
- 异常体**拖拽合并/编辑**(`exceptionConsortium` 新增/关联/取消关联/删除写操作)。
|
||||
- 异常项**眼睛(显隐)** 联动中央/详情视图——随 dd/ert 真实剖面渲染同轮接入。
|
||||
- 中央 2D/3D 与「数据详情」真实数据渲染、项目 CRS 替换、异步仓储、文件下载。
|
||||
|
|
@ -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<Project> loadProject(id);
|
||||
QFuture<vector<GsObject>> loadStructure(projectId); // GS/TM 树
|
||||
}
|
||||
IDatasetRepository {
|
||||
QFuture<Page<DsObject>> listDatasets(tmObjectId, PageReq); // 分页
|
||||
QFuture<ScatterField> loadScatter(dsId);
|
||||
QFuture<Grid> loadGrid(dsId);
|
||||
QFuture<ColorScale> loadColorScale(dsId);
|
||||
QFuture<vector<Anomaly>> loadAnomalies(dsId);
|
||||
QFuture<TerrainTile> 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 <token>`(不透明会话令牌,非 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 <hash>"`,存 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。*
|
||||
|
|
@ -24,6 +24,8 @@ add_executable(geopro_desktop WIN32
|
|||
panels/AnomalyListPanel.cpp
|
||||
panels/DatasetListPanel.cpp
|
||||
panels/ObjectTreePanel.cpp
|
||||
panels/DynamicFormView.cpp
|
||||
panels/ObjectExceptionPanel.cpp
|
||||
CentralScene.cpp
|
||||
ProjectListDialog.cpp
|
||||
SettingsDialog.cpp)
|
||||
|
|
|
|||
116
src/app/main.cpp
116
src/app/main.cpp
|
|
@ -44,7 +44,6 @@
|
|||
#include <QProcess>
|
||||
#include <QSettings>
|
||||
#include <QShortcut>
|
||||
#include <QSignalBlocker>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QVariantAnimation>
|
||||
#include <QStringList>
|
||||
|
|
@ -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<geopro::data::ObjectExceptionGroup>& 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<geopro::data::Workspace>& list, const QString& cur) {
|
||||
|
|
@ -929,12 +890,17 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
|
|||
topBar->setProjects(list, cur, total > static_cast<int>(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<geopro::data::StructNode>& 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("文件"));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
#include "panels/DynamicFormView.hpp"
|
||||
|
||||
#include <QFrame>
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
#include <QScrollArea>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
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);
|
||||
|
||||
auto* scroll = new QScrollArea(this);
|
||||
scroll->setWidgetResizable(true);
|
||||
scroll->setFrameShape(QFrame::NoFrame);
|
||||
|
||||
// 滚动内容宿主:仅承载表单卡片,四周留出与面板一致的内边距,让卡片浮于面板底上。
|
||||
auto* host = new QWidget();
|
||||
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 (cardLayout_->count() > 0) {
|
||||
QLayoutItem* it = cardLayout_->takeAt(0);
|
||||
if (it->widget()) it->widget()->deleteLater();
|
||||
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 it;
|
||||
}
|
||||
}
|
||||
|
||||
void DynamicFormView::showCardMessage(const QString& message)
|
||||
{
|
||||
auto* hint = new QLabel(message);
|
||||
hint->setAlignment(Qt::AlignCenter);
|
||||
geopro::app::applyTokenizedStyleSheet(
|
||||
hint, QStringLiteral("color:{{text/disabled}}; background:transparent; padding:%1px;")
|
||||
.arg(geopro::app::space::kXl));
|
||||
cardLayout_->addWidget(hint);
|
||||
}
|
||||
|
||||
void DynamicFormView::showMessage(const QString& message)
|
||||
{
|
||||
clear();
|
||||
showCardMessage(message);
|
||||
cardLayout_->addStretch();
|
||||
}
|
||||
|
||||
void DynamicFormView::setForm(const geopro::data::DynamicForm& form)
|
||||
{
|
||||
clear();
|
||||
if (form.groups.empty()) {
|
||||
showCardMessage(QStringLiteral("(暂无属性)"));
|
||||
cardLayout_->addStretch();
|
||||
return;
|
||||
}
|
||||
|
||||
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<int>(group.fields.size());
|
||||
if (n == 0) {
|
||||
grid->addWidget(makeLabel(QStringLiteral("(本组暂无字段)")), gridRow, 0, 1,
|
||||
kColSpanAll);
|
||||
}
|
||||
// 字段两两成对,每对一行:偶数下标落左对(labelA|valueA),奇数落右对(labelB|valueB)。
|
||||
for (int i = 0; i < n; ++i) {
|
||||
const auto& f = group.fields[static_cast<size_t>(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);
|
||||
}
|
||||
|
||||
cardLayout_->addStretch();
|
||||
}
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
#pragma once
|
||||
#include <QWidget>
|
||||
#include "repo/RepoTypes.hpp"
|
||||
|
||||
class QVBoxLayout;
|
||||
class QFrame;
|
||||
|
||||
namespace geopro::app {
|
||||
|
||||
// 被动:渲染 DynamicForm(分组键值)为「两列卡片式属性表单」。对象属性 / 数据集属性两面板共用。
|
||||
// 视觉:外层滚动区内嵌一张带边框/底色/圆角的表单卡片;每组一个分组标题带(横跨整行),
|
||||
// 组内字段两列排布(labelA|valueA labelB|valueB),行间细分隔线。颜色全走主题令牌。
|
||||
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(); // 拆掉卡片内全部内容(含分隔线/标题/字段)
|
||||
void showCardMessage(const QString& message); // 卡片内居中淡色提示
|
||||
|
||||
QFrame* card_ = nullptr; // 表单卡片(objectName=attrForm)
|
||||
QVBoxLayout* cardLayout_ = nullptr; // 卡片内纵向布局(容纳各分组网格 / 占位提示)
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
#include "panels/ObjectExceptionPanel.hpp"
|
||||
|
||||
#include <QLabel>
|
||||
#include <QTreeWidget>
|
||||
#include <QTreeWidgetItem>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#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<geopro::data::ObjectExceptionGroup>& 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
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#pragma once
|
||||
#include <QWidget>
|
||||
#include <vector>
|
||||
#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<geopro::data::ObjectExceptionGroup>& groups);
|
||||
void showMessage(const QString& message);
|
||||
|
||||
private:
|
||||
QTreeWidget* tree_ = nullptr;
|
||||
QLabel* hint_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace geopro::app
|
||||
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
#include <QLabel>
|
||||
#include <QSignalBlocker>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
#include <QTreeWidget>
|
||||
#include <QTreeWidgetItem>
|
||||
#include <QVBoxLayout>
|
||||
#include <functional>
|
||||
|
||||
#include "Glyphs.hpp"
|
||||
#include "Theme.hpp"
|
||||
|
|
@ -13,19 +16,32 @@
|
|||
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
|
||||
constexpr int kConfTypeGs = 1; // GS(工区)
|
||||
constexpr int kConfTypeTm = 2; // TM 叶子
|
||||
|
||||
void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& nodes) {
|
||||
// topLevel=true 仅用于项目根:渲染为非交互容器(既不可勾选,也不发 objectClicked)。
|
||||
void addNodes(QTreeWidgetItem* parent, const std::vector<data::dto::StructTreeNode>& nodes,
|
||||
bool topLevel) {
|
||||
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); // 真实数据渲染下一轮接入,默认不勾
|
||||
if (topLevel) {
|
||||
// 项目根:非交互容器(不设 kRoleObjId/kRoleConfType,不可勾选)。
|
||||
} else {
|
||||
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
|
||||
|
|
@ -49,12 +65,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<void(QTreeWidgetItem*)> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -70,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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#pragma once
|
||||
#include <QStringList>
|
||||
#include <QWidget>
|
||||
#include <vector>
|
||||
#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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
#include "WorkbenchNavController.hpp"
|
||||
|
||||
#include <QMetaObject>
|
||||
|
||||
#include "dto/NavDto.hpp"
|
||||
|
||||
namespace geopro::controller {
|
||||
|
||||
using data::ProjectSummary;
|
||||
|
|
@ -8,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;
|
||||
|
|
@ -18,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;
|
||||
|
|
@ -48,6 +57,11 @@ void WorkbenchNavController::loadProjectsAndStructure() {
|
|||
return;
|
||||
}
|
||||
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();
|
||||
|
|
@ -63,6 +77,7 @@ void WorkbenchNavController::loadProjectsAndStructure() {
|
|||
emit projectsLoaded(ps.value.rows, curP, ps.value.total);
|
||||
|
||||
if (curP.isEmpty()) {
|
||||
lastStructNodes_.clear();
|
||||
emit structureLoaded(QString(), {}); // 暂无项目 → 空树
|
||||
return;
|
||||
}
|
||||
|
|
@ -71,6 +86,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 +116,122 @@ void WorkbenchNavController::switchProject(const QString& projectId) {
|
|||
emit loadFailed(QStringLiteral("structure"), QString::fromStdString(st.error));
|
||||
return;
|
||||
}
|
||||
lastStructNodes_ = st.value;
|
||||
tmExceptionCache_.clear();
|
||||
currentParentId_.clear(); // 切项目/工作空间重置选中态(spec §6)
|
||||
currentParentConfType_ = 0;
|
||||
checkedTmsPending_ = false; // 丢弃跨项目的陈旧挂起重放
|
||||
pendingCheckedTms_.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_) { // 触发源是延迟合并发射,可能落在别的同步操作的嵌套事件循环里:
|
||||
pendingCheckedTms_ = tmObjectIds; // 不丢弃,记下最新一次请求,待空闲重放
|
||||
checkedTmsPending_ = true;
|
||||
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<data::ObjectExceptionGroup> 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;
|
||||
}
|
||||
auto grouped = data::dto::groupExceptionsByConsortium(it->second);
|
||||
data::ObjectExceptionGroup g;
|
||||
g.objectId = tm;
|
||||
g.objectName = nameOf(tm);
|
||||
g.consortia = std::move(grouped.consortia);
|
||||
g.looseExceptions = std::move(grouped.loose);
|
||||
total += static_cast<int>(it->second.size());
|
||||
groups.push_back(std::move(g));
|
||||
}
|
||||
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_);
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
|
|
@ -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,16 +39,26 @@ signals:
|
|||
int total, bool append);
|
||||
void filesLoaded(const QString& tmObjectId, const std::vector<geopro::data::DsRow>& rows,
|
||||
int total, bool append);
|
||||
void objectDetailLoaded(const QString& title, const geopro::data::DynamicForm& form);
|
||||
void exceptionTreeLoaded(const std::vector<geopro::data::ObjectExceptionGroup>& groups, int exceptionCount);
|
||||
void datasetDetailLoaded(const geopro::data::DynamicForm& form);
|
||||
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<data::ProjectSummary> lastProjects_;
|
||||
std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_;
|
||||
std::string currentTmId_;
|
||||
std::string currentParentId_;
|
||||
int currentParentConfType_ = 0;
|
||||
std::vector<data::StructNode> lastStructNodes_; // tmId→name 解析
|
||||
std::map<std::string, std::vector<data::ExceptionRow>> tmExceptionCache_;
|
||||
int dataPageNo_ = 0;
|
||||
int filePageNo_ = 0;
|
||||
int dataTotal_ = 0;
|
||||
|
|
|
|||
|
|
@ -74,21 +74,49 @@ RepoResult<std::vector<StructNode>> ApiProjectRepository::loadStructure(const st
|
|||
return {true, dto::parseStructNodes(r.data.value(QStringLiteral("value")).toArray()), {}};
|
||||
}
|
||||
|
||||
RepoResult<DsPage> ApiProjectRepository::loadTmRows(const std::string& projectId,
|
||||
const std::string& tmObjectId, int classifyType,
|
||||
int pageNo) {
|
||||
RepoResult<DsPage> 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<DynamicForm> 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<DynamicForm> 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<std::vector<ExceptionRow>> 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
|
||||
|
|
|
|||
|
|
@ -16,8 +16,11 @@ public:
|
|||
int pageNo, int pageSize) override;
|
||||
RepoResult<std::vector<ProjectType>> listProjectTypes() override;
|
||||
RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) override;
|
||||
RepoResult<DsPage> loadTmRows(const std::string& projectId, const std::string& tmObjectId,
|
||||
int classifyType, int pageNo) override;
|
||||
RepoResult<DsPage> loadRows(const std::string& projectId, const std::string& parentId,
|
||||
int parentConfType, int classifyType, int pageNo) override;
|
||||
RepoResult<DynamicForm> loadObjectDetail(const std::string& objectId, int confType) override;
|
||||
RepoResult<DynamicForm> loadDatasetForm(const std::string& dsObjectId) override;
|
||||
RepoResult<std::vector<ExceptionRow>> loadExceptionsByTm(const std::string& tmObjectId) override;
|
||||
|
||||
private:
|
||||
net::ApiClient& api_;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
#include "dto/NavDto.hpp"
|
||||
|
||||
#include <QJsonValue>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <limits>
|
||||
#include <set>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace geopro::data::dto {
|
||||
|
||||
|
|
@ -27,6 +32,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<double>::max(), hi = -std::numeric_limits<double>::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<Workspace> parseWorkspaces(const QJsonArray& arr) {
|
||||
|
|
@ -154,4 +172,94 @@ std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>& 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<QJsonObject> gv;
|
||||
gv.reserve(static_cast<size_t>(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<QJsonObject> fv;
|
||||
fv.reserve(static_cast<size_t>(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;
|
||||
}
|
||||
|
||||
std::vector<ExceptionRow> parseExceptions(const QJsonArray& arr) {
|
||||
std::vector<ExceptionRow> out;
|
||||
out.reserve(static_cast<size_t>(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;
|
||||
}
|
||||
|
||||
GroupedExceptions groupExceptionsByConsortium(const std::vector<ExceptionRow>& rows) {
|
||||
GroupedExceptions out;
|
||||
std::unordered_map<std::string, size_t> 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
|
||||
|
|
|
|||
|
|
@ -38,4 +38,18 @@ struct StructTreeNode {
|
|||
};
|
||||
std::vector<StructTreeNode> buildStructTree(const std::vector<StructNode>& flat);
|
||||
|
||||
// DynamicFormVO 对象 → DynamicForm:合并 formList(字段定义) + properties(值)。
|
||||
// 组按 groupSort、字段按 displaySort 排序;值取 properties[fieldCode](缺失→空串)。
|
||||
// 表头 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<ExceptionRow> parseExceptions(const QJsonArray& arr);
|
||||
|
||||
// 把一个对象(TM)的异常行按 consortiumId 分组:同 id 归一组(组名/类型取首个非空);
|
||||
// consortiumId 空 → loose。保持首次出现顺序稳定。纯函数、可单测。
|
||||
GroupedExceptions groupExceptionsByConsortium(const std::vector<ExceptionRow>& rows);
|
||||
|
||||
} // namespace geopro::data::dto
|
||||
|
|
|
|||
|
|
@ -26,10 +26,16 @@ public:
|
|||
// 项目类型列表(弹窗类型过滤下拉)。
|
||||
virtual RepoResult<std::vector<ProjectType>> listProjectTypes() = 0;
|
||||
virtual RepoResult<std::vector<StructNode>> loadStructure(const std::string& projectId) = 0;
|
||||
// 按 TM 分页拉数据/文件行:classifyType 3=数据 1=文件;pageNo 从 1 起,pageSize 固定 5。
|
||||
virtual RepoResult<DsPage> 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<DsPage> 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<DynamicForm> loadObjectDetail(const std::string& objectId, int confType) = 0;
|
||||
// 数据集详情:dsObject/dynamicForm/{dsObjectId} → 动态表单。
|
||||
virtual RepoResult<DynamicForm> loadDatasetForm(const std::string& dsObjectId) = 0;
|
||||
// 单 TM 异常列表(含异常体归属字段)。
|
||||
virtual RepoResult<std::vector<ExceptionRow>> loadExceptionsByTm(const std::string& tmObjectId) = 0;
|
||||
};
|
||||
|
||||
} // namespace geopro::data
|
||||
|
|
|
|||
|
|
@ -32,4 +32,25 @@ struct ProjectListPage { std::vector<ProjectSummary> 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<DynamicFormField> fields; };
|
||||
struct DynamicForm { std::string name; std::vector<DynamicFormGroup> groups; };
|
||||
|
||||
// 异常(树叶,本轮只读)。consortium* 空 = 独立异常;detailSummary = 详情展开内联显示。
|
||||
struct ExceptionRow {
|
||||
std::string id, name, typeName, createTime;
|
||||
std::string consortiumId, consortiumName, consortiumType;
|
||||
std::string detailSummary;
|
||||
};
|
||||
|
||||
// 异常体分组(树中间层)+ 对象分组(树根层,对应一个被勾选 TM)。
|
||||
struct ConsortiumGroup { std::string id, name, typeName; std::vector<ExceptionRow> exceptions; };
|
||||
struct ObjectExceptionGroup {
|
||||
std::string objectId, objectName;
|
||||
std::vector<ConsortiumGroup> consortia;
|
||||
std::vector<ExceptionRow> looseExceptions;
|
||||
};
|
||||
struct GroupedExceptions { std::vector<ConsortiumGroup> consortia; std::vector<ExceptionRow> loose; };
|
||||
} // namespace geopro::data
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "dto/NavDto.hpp"
|
||||
|
||||
using namespace geopro::data;
|
||||
|
|
@ -201,3 +203,90 @@ 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());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
TEST(NavDto, GroupExceptionsByConsortiumSplitsLooseAndGroups) {
|
||||
std::vector<ExceptionRow> 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<ExceptionRow> 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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue