From 462cfaac9d5b695926927ef1179f3d2958cb6295 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 09:42:27 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(m1):=20dock=20=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=20+=20=E8=AE=B0=E4=BD=8F=E7=99=BB?= =?UTF-8?q?=E5=BD=95(QtKeychain)=20+=20=E7=BA=B5=E5=90=91=E5=A4=B8?= =?UTF-8?q?=E5=BC=A0=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3 dock 布局/窗口几何 QSettings 持久化(退出保存、启动恢复;ADS 按标题作键) - 4 记住登录:FetchContent QtKeychain v0.14.0(Qt6,静态) + Credential 同步存取;登录窗加「记住登录(30天)」复选框;启动有有效 token 则免登录 - 5 Z 基准统一:kCurtainZScale(3)/kDetailYScale(1.5) 合并为单一 kVerticalExaggeration(2.0),帘面/体素/切片/剖面/地形一致 --- CMakeLists.txt | 11 ++++++ src/app/CMakeLists.txt | 4 ++ src/app/Credential.cpp | 74 +++++++++++++++++++++++++++++++++++ src/app/Credential.hpp | 20 ++++++++++ src/app/login/LoginWindow.cpp | 14 ++++++- src/app/login/LoginWindow.hpp | 5 +++ src/app/main.cpp | 63 +++++++++++++++++++++-------- 7 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 src/app/Credential.cpp create mode 100644 src/app/Credential.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 291067b..da3abd8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,6 +52,17 @@ FetchContent_Declare(ads GIT_TAG 4.3.1) FetchContent_MakeAvailable(ads) +# QtKeychain(凭证安全存取,规约 §7.4:登录"记住一个月"免登录):FetchContent 对接官方 Qt。 +# 必须指定 Qt6(默认走 Qt5 会找不到包);静态链接避免额外 DLL;关测试程序与翻译省依赖。 +set(BUILD_WITH_QT6 ON CACHE BOOL "" FORCE) +set(BUILD_TEST_APPLICATION OFF CACHE BOOL "" FORCE) +set(BUILD_TRANSLATIONS OFF CACHE BOOL "" FORCE) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +FetchContent_Declare(qtkeychain + GIT_REPOSITORY https://github.com/frankosterfeld/qtkeychain.git + GIT_TAG v0.14.0) +FetchContent_MakeAvailable(qtkeychain) + add_subdirectory(src) enable_testing() diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index dff8680..d564cba 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -19,16 +19,20 @@ add_executable(geopro_desktop WIN32 TopBar.cpp Glyphs.cpp PanelHeader.cpp + Credential.cpp login/LoginWindow.cpp panels/AnomalyListPanel.cpp panels/DatasetListPanel.cpp) target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +# QtKeychain 经 FetchContent 接入,头文件不随 target 传播,显式加源/构建目录(含生成的 export 头)。 +target_include_directories(geopro_desktop PRIVATE ${qtkeychain_SOURCE_DIR} ${qtkeychain_BINARY_DIR}) target_link_libraries(geopro_desktop PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Svg ${VTK_LIBRARIES} ads::qt6advanceddocking + qt6keychain nlohmann_json::nlohmann_json geopro_core # Phase 1:ColorScale 上色 geopro_data # Phase 2:本地样本仓储(对象树 / 网格 / 色阶) diff --git a/src/app/Credential.cpp b/src/app/Credential.cpp new file mode 100644 index 0000000..157afc1 --- /dev/null +++ b/src/app/Credential.cpp @@ -0,0 +1,74 @@ +#include "Credential.hpp" + +#include +#include + +#include + +namespace geopro::app { + +namespace { + +const QString kService = QStringLiteral("Geopro3"); +const QString kKey = QStringLiteral("session"); + +// QtKeychain 的 job 是异步的;用局部事件循环跑成同步(启动/登录时一次性调用,可接受)。 +QString readRaw() +{ + QKeychain::ReadPasswordJob job(kService); + job.setAutoDelete(false); + job.setKey(kKey); + QEventLoop loop; + QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); + job.start(); + loop.exec(); + if (job.error() != QKeychain::NoError) return {}; + return job.textData(); +} + +} // namespace + +void rememberSession(const QString& token) +{ + if (token.isEmpty()) return; + const QString payload = + QString::number(QDateTime::currentSecsSinceEpoch()) + QStringLiteral("|") + token; + + QKeychain::WritePasswordJob job(kService); + job.setAutoDelete(false); + job.setKey(kKey); + job.setTextData(payload); + QEventLoop loop; + QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); + job.start(); + loop.exec(); +} + +QString recallValidToken(int maxAgeDays) +{ + const QString raw = readRaw(); + if (raw.isEmpty()) return {}; + const int sep = raw.indexOf(QLatin1Char('|')); + if (sep <= 0) return {}; + + bool ok = false; + const qint64 ts = raw.left(sep).toLongLong(&ok); + if (!ok) return {}; + + const qint64 ageSec = QDateTime::currentSecsSinceEpoch() - ts; + if (ageSec < 0 || ageSec > static_cast(maxAgeDays) * 24 * 3600) return {}; + return raw.mid(sep + 1); +} + +void forgetSession() +{ + QKeychain::DeletePasswordJob job(kService); + job.setAutoDelete(false); + job.setKey(kKey); + QEventLoop loop; + QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); + job.start(); + loop.exec(); +} + +} // namespace geopro::app diff --git a/src/app/Credential.hpp b/src/app/Credential.hpp new file mode 100644 index 0000000..8a95fd9 --- /dev/null +++ b/src/app/Credential.hpp @@ -0,0 +1,20 @@ +#pragma once + +// 登录凭证安全存取(规约 §7.4:严禁明文)。基于 QtKeychain → 系统凭证库 +// (Windows 凭据管理器 / macOS Keychain / Linux Secret Service)。 +// M1 用法:登录成功且勾选「记住」时存 token+时间戳;启动时若未超期则免登录。 + +#include + +namespace geopro::app { + +// 记住会话:把 token + 当前时间戳安全存入系统凭证库。token 为空则忽略。 +void rememberSession(const QString& token); + +// 取回有效 token:已存且未超过 maxAgeDays 天 → 返回 token;否则返回空串。 +QString recallValidToken(int maxAgeDays = 30); + +// 清除已记住的会话(未勾选记住 / 注销时调用)。 +void forgetSession(); + +} // namespace geopro::app diff --git a/src/app/login/LoginWindow.cpp b/src/app/login/LoginWindow.cpp index ac85dd2..ff5bdea 100644 --- a/src/app/login/LoginWindow.cpp +++ b/src/app/login/LoginWindow.cpp @@ -1,5 +1,6 @@ #include "login/LoginWindow.hpp" +#include #include #include #include @@ -77,7 +78,7 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent) : QDialog(parent), auth_(auth) { setWindowTitle(QStringLiteral("Geopro 3.0 登录")); - setFixedSize(400, 500); + setFixedSize(400, 528); // 仅外观:登录窗自带样式(沿用全局主题令牌,保证一脉相承)。 // QLineEdit 在所有状态都显式白底深字 + 边框,避免失焦时取调色板默认色与背景相近不可读。 @@ -174,6 +175,12 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent) refreshRow->addWidget(refreshBtn_); form->addLayout(refreshRow); + // 记住登录:勾选后成功登录将安全存储 token,30 天内免登录。默认不勾(更安全)。 + rememberChk_ = new QCheckBox(QStringLiteral("记住登录(30 天内免登录)"), body); + rememberChk_->setCursor(Qt::PointingHandCursor); + rememberChk_->setStyleSheet(QStringLiteral("color:#5A6B85; font-size:13px;")); + form->addWidget(rememberChk_); + // 错误提示:固定占位高度,避免出现时整体布局跳动。 errorLabel_ = new QLabel(body); errorLabel_->setStyleSheet(QStringLiteral("color: #C0392B; font-size: 12px;")); @@ -264,6 +271,11 @@ void LoginWindow::attemptLogin() refreshCaptcha(); // 失败刷新验证码 } +bool LoginWindow::remember() const +{ + return rememberChk_ && rememberChk_->isChecked(); +} + void LoginWindow::showError(const QString& msg) { errorLabel_->setText(msg); diff --git a/src/app/login/LoginWindow.hpp b/src/app/login/LoginWindow.hpp index a0315ee..7d5ce42 100644 --- a/src/app/login/LoginWindow.hpp +++ b/src/app/login/LoginWindow.hpp @@ -6,6 +6,7 @@ #include #include +class QCheckBox; class QLabel; class QLineEdit; class QPushButton; @@ -25,6 +26,9 @@ public: // 登录成功后的 accessToken(形如 "Geomative ");未登录为空。 QString token() const { return token_; } + // 用户是否勾选「记住登录」(成功后据此决定是否安全存储 token)。 + bool remember() const; + private: void refreshCaptcha(); // 拉新验证码并重绘图片 void attemptLogin(); // 校验输入并发起阻塞登录 @@ -40,6 +44,7 @@ private: QLabel* captchaLabel_ = nullptr; QPushButton* refreshBtn_ = nullptr; QPushButton* loginBtn_ = nullptr; + QCheckBox* rememberChk_ = nullptr; QLabel* errorLabel_ = nullptr; }; diff --git a/src/app/main.cpp b/src/app/main.cpp index e77585c..bbae5bc 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -56,6 +57,7 @@ #include "ApiClient.hpp" #include "AuthService.hpp" +#include "Credential.hpp" #include "Glyphs.hpp" #include "PanelHeader.hpp" #include "Theme.hpp" @@ -158,9 +160,10 @@ enum class DetailMode { Section18, Scatter17 }; // #17 散点屏幕像素方块边长。 constexpr float kScatterPointSize = 4.0F; -// 纵向夸张倍数:三维断面墙沿 z 拉伸成墙;数据详情 #18 沿 y 拉伸填面板。 -constexpr double kCurtainZScale = 3.0; -constexpr double kDetailYScale = 1.5; +// 纵向夸张倍数(Z 基准统一,M-3):全项目共用同一倍数,使 帘面(z) / 体素 / 切片 / +// 数据详情剖面(y) / 地形(relief) 的纵向比例一致——避免「剖面×1.5、帘面×3」不一致。 +// 单一可调常量:要整体调纵向观感改这一处即可。 +constexpr double kVerticalExaggeration = 2.0; // 项目 CRS(实证确定,STATUS §4):散点 projX/Y→经纬→GeoLocalFrame 配准体素到世界系。 constexpr const char* kProjectCrs = "EPSG:4547"; // CGCS2000 / 3-degree GK CM 114E @@ -441,7 +444,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // ── 中央视图重建(核心)───────────────────────────────────────────── // 按勾选的测线(TM)整体重建:scene.clear() → 对每个勾选 TM 的 dd_section 加对应 actor。 // 二维地图 = buildSurveyLine(红线俯视,浅底背景)+ applyTop2D。 - // 三维视图 = buildCurtain(断面墙)SetScale(1,1,kCurtainZScale) + applyFree3D(白底)。 + // 三维视图 = buildCurtain(断面墙)SetScale(1,1,kVerticalExaggeration) + applyFree3D(白底)。 // frame/structure 全局共享;切视图/勾选变化都调用此函数重建当前视图。 auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, &repo, frame, tree, structure, showCurtain, showVoxel, showTerrain, showSlice, slicePlane, @@ -463,7 +466,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re const auto cs = repo.loadColorScale(id); auto curtain = geopro::render::buildCurtain(g, cs, *frame); if (curtain) { - curtain->SetScale(1.0, 1.0, kCurtainZScale); // 纵向夸张成墙 + curtain->SetScale(1.0, 1.0, kVerticalExaggeration); // 纵向夸张成墙 scene->addActor(curtain); } } @@ -492,9 +495,9 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (!is2D && (*showVoxel || *showSlice) && crs) { const auto profs = repo.loadVoxelScatters(); const auto vcs = repo.loadScatterColorScale("grid1"); - // 纵向夸张烤进 image(zDisplayScale=kCurtainZScale),使体绘制/切片/帘面纵向一致。 + // 纵向夸张烤进 image(zDisplayScale=kVerticalExaggeration),使体绘制/切片/帘面纵向一致。 auto vr = geopro::render::buildVoxelFromScatters(profs, vcs, *crs, *frame, 1.0, 0.5, 2.0, - 4.0, kCurtainZScale); + 4.0, kVerticalExaggeration); if (vr.valid()) { if (*showVoxel) { rendererPtr->AddVolume(vr.volume); // 夸张已烤进 image,无需 actor SetScale @@ -585,18 +588,18 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re const auto cs = repo.loadColorScale(id); const auto actors = geopro::render::buildGridContour(g, cs); if (actors.bands) { - actors.bands->SetScale(1.0, kDetailYScale, 1.0); + actors.bands->SetScale(1.0, kVerticalExaggeration, 1.0); detailRendererPtr->AddViewProp(actors.bands); } if (actors.edges && *showContour) { - actors.edges->SetScale(1.0, kDetailYScale, 1.0); + actors.edges->SetScale(1.0, kVerticalExaggeration, 1.0); detailRendererPtr->AddViewProp(actors.edges); } // 顶部电极标记 ▼(仅网格数据;同纵向夸张对齐)。 if (*showElectrodes) { auto elec = geopro::render::buildElectrodes(g); if (elec) { - elec->SetScale(1.0, kDetailYScale, 1.0); + elec->SetScale(1.0, kVerticalExaggeration, 1.0); detailRendererPtr->AddViewProp(elec); } } @@ -606,7 +609,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re const auto scs = repo.loadScatterColorScale(id); auto a = geopro::render::buildScatter(s, scs, kScatterPointSize); if (a) { - a->SetScale(1.0, kDetailYScale, 1.0); + a->SetScale(1.0, kVerticalExaggeration, 1.0); detailRendererPtr->AddViewProp(a); } } @@ -616,7 +619,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re for (int i = 0; i < static_cast(anomalies.size()); ++i) { if (hiddenAnoms->count(i)) continue; // 列表中取消勾选→隐藏 for (auto& act : geopro::render::buildAnomalies({anomalies[i]})) { - act->SetScale(1.0, kDetailYScale, 1.0); + act->SetScale(1.0, kVerticalExaggeration, 1.0); detailRendererPtr->AddViewProp(act); } } @@ -800,6 +803,22 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re .arg(QString::fromUtf8(kProjectCrs)) .arg(lat0, 0, 'f', 5) .arg(lon0, 0, 'f', 5)); + + // ── dock 布局/窗口几何持久化 ────────────────────────────────────────── + // 恢复上次的停靠布局与窗口几何(须在全部 dock 创建后;ADS 按 dock 标题作键匹配)。 + { + const QSettings settings; + const QByteArray geo = settings.value(QStringLiteral("ui/geometry")).toByteArray(); + if (!geo.isEmpty()) window.restoreGeometry(geo); + const QByteArray dockState = settings.value(QStringLiteral("ui/dockState")).toByteArray(); + if (!dockState.isEmpty()) dockManager->restoreState(dockState); + } + // 退出时保存当前布局与几何(aboutToQuit 早于 window 析构,dockManager/window 仍存活)。 + QObject::connect(qApp, &QCoreApplication::aboutToQuit, dockManager, [dockManager, &window]() { + QSettings settings; + settings.setValue(QStringLiteral("ui/geometry"), window.saveGeometry()); + settings.setValue(QStringLiteral("ui/dockState"), dockManager->saveState()); + }); } } // namespace @@ -815,6 +834,10 @@ int main(int argc, char* argv[]) QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); QApplication app(argc, argv); + // 组织/应用名:QSettings 持久化(dock 布局、登录记忆等)按此定位存储位置。 + QCoreApplication::setOrganizationName(QStringLiteral("Geomative")); + QCoreApplication::setApplicationName(QStringLiteral("Geopro3")); + // 浅色专业主题(Fusion + 调色板 + 全局样式表):仅外观,登录窗与工作台共用。 geopro::app::applyTheme(app); @@ -841,11 +864,19 @@ int main(int argc, char* argv[]) const std::string pem = readPem("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem"); geopro::net::AuthService auth(api, pem); - // 先弹登录窗;用户取消/未登录则退出。 - geopro::app::LoginWindow login(auth); - if (login.exec() != QDialog::Accepted) return 0; + // 记住登录:若上次勾选「记住」且未超 30 天,凭证库里有有效 token → 免登录直接进。 + QString token = geopro::app::recallValidToken(30); + if (token.isEmpty()) { + geopro::app::LoginWindow login(auth); + if (login.exec() != QDialog::Accepted) return 0; + token = login.token(); + if (login.remember()) + geopro::app::rememberSession(token); // 安全存 token + 时间戳 + else + geopro::app::forgetSession(); // 未勾选:清除旧记忆 + } - api.setToken(login.token()); // 注入 token 供后续 API 使用 + api.setToken(token); // 注入 token 供后续 API 使用 // 登录成功 → 构建并显示工作台。 geopro::data::LocalSampleRepository repo( From 3be4cdbddeb7d259cb4ad193fbf10b4b3936fa87 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 10:13:46 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=E6=8E=A5=E5=85=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=AF=BC=E8=88=AA(=E5=B7=A5=E4=BD=9C=E7=A9=BA?= =?UTF-8?q?=E9=97=B4/=E9=A1=B9=E7=9B=AE/=E5=AF=B9=E8=B1=A1=E6=A0=91)=20?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-09-real-api-navigation-design.md | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-real-api-navigation-design.md 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 new file mode 100644 index 0000000..790baf3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-real-api-navigation-design.md @@ -0,0 +1,272 @@ +# 接入真实导航(工作空间 / 项目 / 对象树)— 设计文档 + +- 日期:2026-06-09 +- 分支:feat/m1-finishing(建议拉子分支 feat/real-api-navigation) +- 状态:已与需求方确认范围,待 spec 评审 + +--- + +## 1. 背景与目标 + +当前应用已搭好骨架:登录链路(`net::AuthService` + `net::ApiClient`,共享会话、token 注入)可用; +工作台(`src/app/main.cpp::buildWorkbench`)用本地静态样本仓储 `data::LocalSampleRepository` +渲染三维/二维示例;顶部 `app::TopBar` 的"工作空间切换 / 项目"为**静态视觉壳**(硬编码下拉项)。 + +本轮目标:把**顶层导航壳**接到真实后端接口,逐步替换静态数据: + +1. 工作空间(=企业租户/空间)列表与切换; +2. 项目列表与切换; +3. 对象显示栏的树形结构(项目 → GS → TM)+ 选中 TM 后其 DS 列表。 + +中央三维/二维渲染与"数据详情"的**真实剖面/反演数据**走另一批 `dd/ert` 接口,**本轮不接**。 + +## 2. 范围(已确认决策) + +**做(In Scope)** +- 工作空间列表 / 切换(真实接口)。 +- 项目列表 / 切换(真实接口)。 +- 对象树:**按真实结构显示 GS 层**(项目根 → GS → TM);TM 在左下"数据真实显示栏"列出其 DS。 +- 真实接口失败(断网 / token 过期 / 无数据)→ **显示错误 / 空状态**,**不回退本地样本**。 +- 项目 `referenceCRSCode` 存入导航状态,供下一轮替换硬编码 `EPSG:4547`(本轮不改渲染)。 + +**不做(Out of Scope,留下一轮)** +- 中央 2D/3D 视图、数据详情的真实数据渲染(`dd/ert/gpr` 接口)。 + → 点击真实 DS 时中央/详情显示**占位"待接入"**;`render/*` 与 `LocalSampleRepository` 代码**保留不删**。 +- 异步仓储(QFuture/回调)—— 本轮同步阻塞 + WaitCursor(与登录一致),异步留 M1.5。 +- 用户头像 / 姓名接真实 `auth/getUserInfo`(本轮先留静态)。 +- 项目分页"加载更多"的滚动 UI(接口支持 `hasNextPage`,本轮先取首页;翻页留待后续)。 + +## 3. 接口确认结论 + +网关:`http://tenant.geomative.cn/pop-api`(现有 `ApiClient` 基址);business 与 admin 两份 OpenAPI 同一上游。 +token 已由登录注入(`geomativeauthorization` 头),下列接口直接复用现有会话。 + +| 能力 | 方法 | 路径 | 关键返回 | +|---|---|---|---| +| 工作空间列表 | GET | `/business/system/tenant/enterprise/joined/list` | `[{id, name, ownerType(1个人/2企业), isCurTenant(0/1), logoPath}]` | +| 切换工作空间 | POST | `/business/system/tenant/enterprise/switch/{tenantId}` | 信封 code/msg | +| 项目列表 | GET | `/business/project/queryByUser?lastProjectId=` | `{hasNextPage, projectList:[{id, projectName, projectTypeName, referenceCRSCode, referenceCRSName, status, ...}]}`(游标分页,首页传空 lastProjectId) | +| 项目结构 | POST | `/business/projectWorkbench/queryProjectStruct` | body `{projectId}`;data `{projectStructList:[{id, name, parentId, type, typeId, typeName, confCode}]}` | +| TM 下 DS | GET | `/business/projectWorkbench/queryDsByTmObjectId/{tmObjectId}` | `[{id, name, ddCode, typeName}]` | + +**层级确认(修正需求方假设)**:真实结构**不是** `项目→tm→ds`,而是 **`项目 → GS(工区) → TM(测线) → DS`**。 +- `queryProjectStruct` 返回一个**扁平 parent-child 列表**(仅含 GS + TM 两类节点,**不含 DS**),客户端按 `parentId` 自建树。 +- DS 不在结构列表里,按 TM 单独拉取(`queryDsByTmObjectId`)。 +- **项目不能直接挂 DS**;DS 永远挂在 TM 下。但由于是 `parentId` 扁平结构,**TM 可直接挂在项目下(无中间 GS)**——这是"项目直接挂"印象的来源,但叶子仍是 TM→DS。 + +**节点判定**:结构列表只含 GS+TM,故 **TM = 该节点在结构列表中无子节点(叶子)**;非叶子 = GS。 +`type`(integer) / `confCode` 一并保留为辅助信号,待见到 live 数据后可固化判定规则。 + +## 4. 架构分层 + +遵循仓库既有四层(见各层 README);本轮新增组件按层就位,**依赖方向单向向下**,UI 不直接碰 `ApiClient`。 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ UI 层 (src/app, 目标 src/view) — 被动视图,只渲染模型 + 发用户意图信号 │ +│ TopBar(数据驱动) ObjectTreePanel DatasetListPanel(已有) │ +└───────────────▲──────────────────────────┬──────────────────┘ + 信号(用户意图) 槽(模型数据) +┌───────────────┴──────────────────────────▼──────────────────┐ +│ 逻辑层 (src/controller) — 编排状态机,无 widget │ +│ WorkbenchNavController : QObject │ +│ state: 当前 workspaceId / projectId / project.crsCode │ +│ slots: switchWorkspace / switchProject / selectTm │ +│ signals: workspacesLoaded / projectsLoaded / │ +│ structureLoaded / datasetsLoaded / loadFailed │ +└───────────────────────────┬──────────────────────────────────┘ + IProjectRepository(同步契约) +┌───────────────────────────▼──────────────────────────────────┐ +│ 数据访问层 (src/data) │ +│ repo/IProjectRepository.hpp ← 导航仓储抽象(Result) │ +│ api/ApiProjectRepository.{h,cpp} ← 用 ApiClient 实现 │ +│ dto/NavDto.{h,cpp} ← 后端 JSON → 模型 纯映射 + 扁平→树 │ +└───────────────────────────┬──────────────────────────────────┘ + net::ApiClient(原始 HTTP) +┌───────────────────────────▼──────────────────────────────────┐ +│ 接口层 (src/net) — 复用,无改动 │ +│ ApiClient(共享会话/token) ; AuthService(登录) │ +└───────────────────────────────────────────────────────────────┘ + +模型层 (src/data/repo/RepoTypes.hpp & 新增 NavTypes) — 纯结构,被各层共享 + Workspace / ProjectSummary / StructNode / Project,GsNode,TmNode,DsNode(已有) +``` + +依赖规则:`net` 不依赖任何上层;`data` 依赖 `net` + 模型;`controller` 依赖 `data` + 模型(不依赖 UI); +`app/view` 依赖 `controller` + 模型(不依赖 `data/net` 具体类型,只经 controller 信号拿模型)。 + +## 5. 各层组件详细设计 + +### 5.1 接口层 `net`(复用,不改) +`ApiClient::get(path)` / `postJson(path, body)` 返回 `ApiResponse{httpStatus, code, data, msg, rawError}`。 +本轮所有业务接口经它发出,token 已注入,会话已共享。 + +### 5.2 模型层(纯结构,无 Qt / 无 VTK) +`src/data/repo/RepoTypes.hpp` 已有 `Project/GsNode/TmNode/DsNode`。新增导航模型(同文件或 `NavTypes.hpp`): + +```cpp +struct Workspace { std::string id, name; int ownerType = 0; bool isCurrent = false; }; +struct ProjectSummary { + std::string id, name, typeName, crsCode, crsName; + int status = 0; +}; +// 项目结构扁平节点(GS / TM);客户端按 parentId 建树。 +struct StructNode { + std::string id, name, parentId, typeName, confCode; + int type = 0; +}; +``` +`DsNode{id,name,ddType}` 复用;映射时 `ddCode → ddType`。 + +### 5.3 数据访问层 `data` + +**`repo/IProjectRepository.hpp`** — 导航仓储抽象(同步,呼应既有 `IDatasetRepository` 风格; +但网络可失败,故用显式 `Result` 而非抛异常,便于 UI 出错误/空状态): + +```cpp +template +struct RepoResult { bool ok = false; T value{}; std::string error; }; + +class IProjectRepository { +public: + virtual ~IProjectRepository() = default; + virtual RepoResult> listWorkspaces() = 0; + virtual RepoResult switchWorkspace(const std::string& tenantId) = 0; + virtual RepoResult> listProjects(const std::string& lastProjectId) = 0; + virtual RepoResult> loadStructure(const std::string& projectId) = 0; + virtual RepoResult> loadDatasetsOfTm(const std::string& tmObjectId) = 0; +}; +``` + +**`api/ApiProjectRepository.{hpp,cpp}`** — 实现:持有 `net::ApiClient&`, +按 §3 路径发请求,把 `ApiResponse` 交给 `dto/` 映射;网络/业务码错误 → `RepoResult{ok=false, error=msg}`。 +判定成功:`httpStatus==200 && code==<成功码>`(成功码沿用登录约定,实现时核对)。 + +**`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 懒加载)。 + +### 5.4 逻辑层 `controller/WorkbenchNavController`(QObject) +唯一持有导航状态;不碰 widget;经信号把模型推给 UI、经槽接收用户意图。 + +```cpp +class WorkbenchNavController : public QObject { + Q_OBJECT +public: + explicit WorkbenchNavController(data::IProjectRepository& repo, QObject* parent=nullptr); + void start(); // 启动:拉空间→项目→结构 +public slots: + void switchWorkspace(const QString& tenantId); // 切空间→重载项目→重载结构 + void switchProject(const QString& projectId); // 切项目→重载结构(清 DS/详情) + void selectTm(const QString& tmObjectId); // 选 TM→拉其 DS +signals: + void workspacesLoaded(const std::vector&, QString currentId); + void projectsLoaded(const std::vector&, QString currentId); + void structureLoaded(const data::Project&); // 已建好的树 + void datasetsLoaded(const QString& tmObjectId, const std::vector&); + void loadFailed(const QString& stage, const QString& message); // 出错→UI 空/错状态 + void busyChanged(bool busy); // 同步阻塞期间置 WaitCursor +private: + data::IProjectRepository& repo_; + QString currentWorkspaceId_, currentProjectId_, currentCrsCode_; +}; +``` +编排逻辑:`start()` → `listWorkspaces`(选 isCurrent/首个)→ `listProjects`(选首个)→ `loadStructure`→建树。 +切空间/项目按 §6 时序。每个阶段失败 emit `loadFailed(stage,msg)` 并停在该阶段。 + +### 5.5 UI 层 `app`(被动视图,数据驱动) + +**`app/TopBar`** —— 由"自由函数返回静态 QWidget"升级为**数据驱动类**(QWidget 子类): +- `setWorkspaces(list, currentId)` / `setProjects(list, currentId)` 重建下拉项。 +- 信号 `workspaceSwitchRequested(QString id)` / `projectSwitchRequested(QString id)`。 +- 移除硬编码"个人工作空间 / 青海湖项目";用户区暂留静态。 +- `buildMenuBar` 不变(静态菜单本轮不接)。 + +**`app/panels/ObjectTreePanel`**(新增;或先以构建函数落在 main,二选一见 §11)—— 被动: +`setProject(const data::Project&)` 重建 `QTreeWidget`(项目根→GS→TM,TM 可勾选、存 tmObjectId); +信号 `tmClicked(QString tmObjectId)` / `tmCheckToggled(...)`。空/错状态:树区显示占位 label。 + +**`app/panels/DatasetListPanel`**(已有)—— `datasetsLoaded` → `populateDatasetList`;空时显示"暂无数据集"。 + +**中央/详情**:移除"启动自动渲染本地 demo";DS 点击 → 详情面板与中央视图显示占位文案 +"该数据集渲染将在下一阶段接入 dd 接口"。渲染代码保留。 + +## 6. 数据流 / 交互时序 + +``` +启动(登录后): + main 构造 ApiClient → ApiProjectRepository → WorkbenchNavController → TopBar/ObjectTreePanel + controller.start(): + listWorkspaces → emit workspacesLoaded → TopBar.setWorkspaces + listProjects(empty) → emit projectsLoaded → TopBar.setProjects + loadStructure(currentProject) → buildProjectTree → emit structureLoaded → ObjectTreePanel.setProject + +切空间: TopBar.workspaceSwitchRequested(id) + → controller.switchWorkspace: switchWorkspace(id) → listProjects → 选首个 → loadStructure + → emit projectsLoaded + structureLoaded;清空 DS 列表/详情占位 + +切项目: TopBar.projectSwitchRequested(id) + → controller.switchProject: loadStructure(id) → emit structureLoaded;清空 DS 列表/详情占位 + +选 TM: ObjectTreePanel.tmClicked(tmObjectId) + → controller.selectTm: loadDatasetsOfTm → emit datasetsLoaded → DatasetListPanel 填充 + +点 DS: DatasetListPanel → 中央/详情显示占位"待接入"(本轮不渲染真实数据) +``` + +## 7. 错误处理与边界 +- 仓储层捕获网络错误(`rawError`)与业务错误码,归一为 `RepoResult.error`。 +- controller 任一阶段失败 → `loadFailed(stage, msg)`;UI 在对应面板显示空/错状态 label + 状态栏提示,**不回退本地样本**。 +- 空数据(无空间 / 无项目 / 无结构 / 无 DS)→ 各面板显示"暂无…"占位(识别优于回忆)。 +- token 过期(业务码 401 类)→ `loadFailed` 文案提示重新登录(本轮先提示,自动跳登录留后续)。 +- 输入边界:`tmObjectId` / `projectId` 为空时短路不发请求。 + +## 8. 渲染解耦 +现状:对象树(本地 grid1/grid2…)直接驱动中央与数据详情。本轮真实树 id 与本地样本对不上,故: +- 启动不再自动渲染本地 demo。 +- 真实 DS 点击 → 中央/详情显示占位文案。 +- `render/*`、`LocalSampleRepository`、`VoxelFromScatters` 等全部保留,待下轮按 dd/ert 接口复用。 +- 项目 `crsCode` 由 controller 存住,下一轮替换 `main.cpp` 中硬编码 `EPSG:4547`。 + +## 9. 测试策略 +依既有无测试桩 + 依赖 live 服务器的现实,聚焦**纯逻辑单测**(GoogleTest + CTest): +- `dto/NavDto` 映射:喂样本 JSON(取自 OpenAPI example / 手造)验证 + `parseWorkspaces / parseProjects / parseStructNodes / parseDatasets` 字段与 `ddCode→ddType`、`isCurTenant→isCurrent`。 +- `buildProjectTree` 扁平→树:覆盖 项目根→GS→TM、TM 直挂项目(无 GS)、孤儿 parentId、空列表 等场景。 +- 不做 live 集成 / E2E(无桩、依赖真实后端)。控制器/UI 信号联动靠手动联调验证。 +- 目标:纯逻辑文件(dto + tree builder)覆盖率优先达标;UI/网络 IO 不计入。 + +## 10. 线程 / 性能 +- 同步阻塞 UI 线程(`ApiClient` 用 QEventLoop)+ `busyChanged` 置 `Qt::WaitCursor`,与现有登录一致。 +- 切空间/项目可能稍慢但可接受(MVP)。异步(QFuture/取消)留 M1.5,届时 `IProjectRepository` 契约可平滑改造。 + +## 11. 文件清单 + +**新增** +- `src/data/repo/IProjectRepository.hpp`(含 `RepoResult`、导航模型 `Workspace/ProjectSummary/StructNode`,或拆 `NavTypes.hpp`) +- `src/data/api/ApiProjectRepository.{hpp,cpp}` +- `src/data/dto/NavDto.{hpp,cpp}` +- `src/controller/WorkbenchNavController.{hpp,cpp}` +- `src/app/panels/ObjectTreePanel.{hpp,cpp}`(若不抽,则树构建函数留在 main,但 TopBar 必抽) +- 测试:`tests/`(或既有测试目录)`NavDtoTest.cpp`、`BuildProjectTreeTest.cpp` + +**改造** +- `src/app/TopBar.{hpp,cpp}` — 升级为数据驱动类 + 信号 +- `src/app/main.cpp` — 构造 repo/controller、接线信号;移除启动自动渲染 demo;DS 点击改占位 +- 各层 `CMakeLists.txt` — 新增源文件 + `controller` 目标接入构建;controller 需 `Q_OBJECT`(AUTOMOC ON) + +**保留不删**:`LocalSampleRepository`、`render/*`、`VoxelFromScatters`、现有详情/中央渲染代码。 + +## 12. 未决 / 下一轮 +- dd/ert/gpr 真实剖面/反演/雷达数据渲染(替换占位)。 +- 项目 `crsCode` 替换硬编码 `EPSG:4547`,重建 `GeoLocalFrame`。 +- 异步仓储(QFuture + 取消 + 分页"加载更多")。 +- 用户头像/姓名接 `auth/getUserInfo`;token 过期自动跳登录。 +- 顶部菜单(视图/项目管理/业务工具/设备)接真实页面。 +```