diff --git a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md index a676e02..aa063a2 100644 --- a/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md +++ b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md @@ -140,17 +140,20 @@ public: **`api/ApiProjectRepository.{hpp,cpp}`** — 实现:持有 `net::ApiClient&`, 按 §3 路径发请求,把 `ApiResponse` 交给 `dto/` 映射;网络/业务码错误 → `RepoResult{ok=false, error=msg}`。 -判定成功:`httpStatus==200 && code==<成功码>`(成功码沿用登录约定,实现时核对)。 +判定成功:`code==200`(沿用登录 `AuthService` 的约定,业务码即成功标志)。id 进 URL 路径/查询前 +经 `QUrl::toPercentEncoding` 百分号编码(不可信后端数据:防 `? # & /` 空格 破坏 URL)。 **`dto/NavDto.{hpp,cpp}`** — 纯函数映射(**无网络、可单测**): - `parseWorkspaces(QJsonArray) -> vector`(`isCurTenant==1 → isCurrent`)。 - `parseProjects(QJsonObject) -> {vector, bool hasNextPage}`。 - `parseStructNodes(QJsonArray) -> vector`。 - `parseDatasets(QJsonArray) -> vector`(`ddCode→ddType`)。 -- `buildProjectTree(vector, projectName) -> Project`:扁平→树。 - - 以 `parentId` 归并;`parentId` 为空或不在集合内的节点挂到合成"项目根"。 - - **叶子节点判定为 TM**(进 `TmNode`,携带 `confCode`/真实 id 作 tmObjectId);非叶子为 GS。 - - TM 的 `dss` 本轮留空(DS 懒加载)。 +- `buildStructTree(vector) -> vector`:扁平→**通用树**(不强塞 `Project/Gs/Tm` 刚性模型, + 以适配任意层级 + TM 直挂项目)。`StructTreeNode{StructNode node; bool isTm; vector children}`。 + - 以 `parentId` 归并;`parentId` 为空或不在集合内(孤儿)的节点为根层。 + - **叶子节点判定为 TM**(`isTm=true`,`node.id` 即 tmObjectId);非叶子为 GS。 + - `visited` 集防环:不可信后端数据(多节点环 / 重复 id)也不会无限递归(规约:永不信任外部数据)。 + - 纯函数、可单测;树→QTreeWidget 的填充由 `ObjectTreePanel` 调用本函数完成(见 §5.5)。 ### 5.4 逻辑层 `controller/WorkbenchNavController`(QObject) 唯一持有导航状态;不碰 widget;经信号把模型推给 UI、经槽接收用户意图。 @@ -168,17 +171,23 @@ public slots: signals: void workspacesLoaded(const std::vector&, QString currentId); void projectsLoaded(const std::vector&, QString currentId); - void structureLoaded(const data::Project&); // 已建好的树 + // 发出项目名 + 扁平结构节点;建树(buildStructTree)在 ObjectTreePanel 内完成。 + void structureLoaded(const QString& projectName, const std::vector&); void datasetsLoaded(const QString& tmObjectId, const std::vector&); void loadFailed(const QString& stage, const QString& message); // 出错→UI 空/错状态 void busyChanged(bool busy); // 同步阻塞期间置 WaitCursor private: + void loadProjectsAndStructure(); // start + switchWorkspace 共用 data::IProjectRepository& repo_; - QString currentWorkspaceId_, currentProjectId_, currentCrsCode_; + std::vector lastProjects_; // 供 switchProject 查 name/crsCode + std::string currentWorkspaceId_, currentProjectId_, currentProjectName_, currentCrsCode_; + bool busy_ = false; // 重入保护:同步请求期间拒绝再次进入 }; ``` -编排逻辑:`start()` → `listWorkspaces`(选 isCurrent/首个)→ `listProjects`(选首个)→ `loadStructure`→建树。 +编排逻辑:`start()` → `listWorkspaces`(选 isCurrent/首个)→ `listProjects`(选首个)→ `loadStructure`→发扁平节点。 切空间/项目按 §6 时序。每个阶段失败 emit `loadFailed(stage,msg)` 并停在该阶段。 +**重入保护**:每个公共操作入口 `if (busy_) return;`,并用 RAII guard 在置忙/复位时配平 `busyChanged` +(同步 HTTP 会泵 Qt 事件循环,快速二次点击可能重入并污染状态)。 ### 5.5 UI 层 `app`(被动视图,数据驱动) @@ -188,9 +197,10 @@ private: - 移除硬编码"个人工作空间 / 青海湖项目";用户区暂留静态。 - `buildMenuBar` 不变(静态菜单本轮不接)。 -**`app/panels/ObjectTreePanel`**(新增;或先以构建函数落在 main,二选一见 §11)—— 被动: -`setProject(const data::Project&)` 重建 `QTreeWidget`(项目根→GS→TM,TM 可勾选、存 tmObjectId); -信号 `tmClicked(QString tmObjectId)` / `tmCheckToggled(...)`。空/错状态:树区显示占位 label。 +**`app/panels/ObjectTreePanel`**(新增)—— 被动:`setStructure(projectName, vector)` 内部调 +`dto::buildStructTree` 重建 `QTreeWidget`(项目根→GS→TM,叶子=TM 可勾选、`UserRole` 存 tmObjectId); +`showMessage(msg)` 显示空/错占位。信号 `tmClicked(QString tmObjectId)` / `tmCheckToggled(...)` +(后者为前瞻钩子,本轮无消费者)。 **`app/panels/DatasetListPanel`**(已有)—— `datasetsLoaded` → `populateDatasetList`;空时显示"暂无数据集"。 @@ -205,7 +215,7 @@ private: controller.start(): listWorkspaces → emit workspacesLoaded → TopBar.setWorkspaces listProjects(empty) → emit projectsLoaded → TopBar.setProjects - loadStructure(currentProject) → buildProjectTree → emit structureLoaded → ObjectTreePanel.setProject + loadStructure(currentProject) → emit structureLoaded(name,nodes) → ObjectTreePanel.setStructure(→buildStructTree) 切空间: TopBar.workspaceSwitchRequested(id) → controller.switchWorkspace: switchWorkspace(id) → listProjects → 选首个 → loadStructure @@ -225,7 +235,8 @@ private: - controller 任一阶段失败 → `loadFailed(stage, msg)`;UI 在对应面板显示空/错状态 label + 状态栏提示,**不回退本地样本**。 - 空数据(无空间 / 无项目 / 无结构 / 无 DS)→ 各面板显示"暂无…"占位(识别优于回忆)。 - token 过期(业务码 401 类)→ `loadFailed` 文案提示重新登录(本轮先提示,自动跳登录留后续)。 -- 输入边界:`tmObjectId` / `projectId` 为空时短路不发请求。 +- 输入边界:`tmObjectId` / `projectId` 为空时短路不发请求;URL 中的 id 一律百分号编码(见 §5.3)。 +- 重入:同步请求期间 `busy_` 拒绝再次进入(避免快速点击重入污染状态,见 §5.4)。 ## 8. 渲染解耦 @@ -274,7 +285,7 @@ void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer, 依既有无测试桩 + 依赖 live 服务器的现实,聚焦**纯逻辑单测**(GoogleTest + CTest): - `dto/NavDto` 映射:喂样本 JSON(取自 OpenAPI example / 手造)验证 `parseWorkspaces / parseProjects / parseStructNodes / parseDatasets` 字段与 `ddCode→ddType`、`isCurTenant→isCurrent`。 -- `buildProjectTree` 扁平→树:覆盖 项目根→GS→TM、TM 直挂项目(无 GS)、孤儿 parentId、空列表 等场景。 +- `buildStructTree` 扁平→树:覆盖 项目根→GS→TM、TM 直挂项目(无 GS)、孤儿 parentId、空列表、防环 等场景。 - 不做 live 集成 / E2E(无桩、依赖真实后端)。控制器/UI 信号联动靠手动联调验证。 - 目标:纯逻辑文件(dto + tree builder)覆盖率优先达标;UI/网络 IO 不计入。