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(