Compare commits

...

3 Commits

Author SHA1 Message Date
gaozheng 0af33f1952 merge: 合并 feat/m1-finishing(dock持久化+记住登录QtKeychain+纵向夸张统一)到 feat/real-api-navigation
冲突解决:
- main.cpp:保留导航分支的解耦版 rebuildCentral(调 CentralScene),采纳 m1 的 dock 持久化/记住登录/setOrganizationName,常量统一为 kVerticalExaggeration
- spec 文档:保留当前扩展版(m1 初版为其子集)
- CMakeLists/app:QtKeychain + Credential 与 controller/ProjectListDialog 等自动合并共存
2026-06-09 18:57:33 +08:00
gaozheng 3be4cdbdde docs: 接入真实导航(工作空间/项目/对象树) 设计文档 2026-06-09 10:13:46 +08:00
gaozheng 462cfaac9d feat(m1): dock 布局持久化 + 记住登录(QtKeychain) + 纵向夸张统一
- 3 dock 布局/窗口几何 QSettings 持久化(退出保存、启动恢复;ADS 按标题作键)
- 4 记住登录:FetchContent QtKeychain v0.14.0(Qt6,静态) + Credential 同步存取;登录窗加「记住登录(30天)」复选框;启动有有效 token 则免登录
- 5 Z 基准统一:kCurtainZScale(3)/kDetailYScale(1.5) 合并为单一 kVerticalExaggeration(2.0),帘面/体素/切片/剖面/地形一致
2026-06-09 09:42:27 +08:00
7 changed files with 171 additions and 14 deletions

View File

@ -52,6 +52,17 @@ FetchContent_Declare(ads
GIT_TAG 4.3.1) GIT_TAG 4.3.1)
FetchContent_MakeAvailable(ads) 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) add_subdirectory(src)
enable_testing() enable_testing()

View File

@ -19,6 +19,7 @@ add_executable(geopro_desktop WIN32
TopBar.cpp TopBar.cpp
Glyphs.cpp Glyphs.cpp
PanelHeader.cpp PanelHeader.cpp
Credential.cpp
login/LoginWindow.cpp login/LoginWindow.cpp
panels/AnomalyListPanel.cpp panels/AnomalyListPanel.cpp
panels/DatasetListPanel.cpp panels/DatasetListPanel.cpp
@ -27,11 +28,14 @@ add_executable(geopro_desktop WIN32
ProjectListDialog.cpp) ProjectListDialog.cpp)
target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) 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 target_link_libraries(geopro_desktop PRIVATE
Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Svg Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Svg
${VTK_LIBRARIES} ${VTK_LIBRARIES}
ads::qt6advanceddocking ads::qt6advanceddocking
qt6keychain
nlohmann_json::nlohmann_json nlohmann_json::nlohmann_json
geopro_core # Phase 1ColorScale geopro_core # Phase 1ColorScale
geopro_data # Phase 2 / / geopro_data # Phase 2 / /

74
src/app/Credential.cpp Normal file
View File

@ -0,0 +1,74 @@
#include "Credential.hpp"
#include <QDateTime>
#include <QEventLoop>
#include <keychain.h>
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<qint64>(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

20
src/app/Credential.hpp Normal file
View File

@ -0,0 +1,20 @@
#pragma once
// 登录凭证安全存取(规约 §7.4:严禁明文)。基于 QtKeychain → 系统凭证库
// Windows 凭据管理器 / macOS Keychain / Linux Secret Service
// M1 用法:登录成功且勾选「记住」时存 token+时间戳;启动时若未超期则免登录。
#include <QString>
namespace geopro::app {
// 记住会话:把 token + 当前时间戳安全存入系统凭证库。token 为空则忽略。
void rememberSession(const QString& token);
// 取回有效 token已存且未超过 maxAgeDays 天 → 返回 token否则返回空串。
QString recallValidToken(int maxAgeDays = 30);
// 清除已记住的会话(未勾选记住 / 注销时调用)。
void forgetSession();
} // namespace geopro::app

View File

@ -1,5 +1,6 @@
#include "login/LoginWindow.hpp" #include "login/LoginWindow.hpp"
#include <QCheckBox>
#include <QColor> #include <QColor>
#include <QFont> #include <QFont>
#include <QHBoxLayout> #include <QHBoxLayout>
@ -77,7 +78,7 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
: QDialog(parent), auth_(auth) : QDialog(parent), auth_(auth)
{ {
setWindowTitle(QStringLiteral("Geopro 3.0 登录")); setWindowTitle(QStringLiteral("Geopro 3.0 登录"));
setFixedSize(400, 500); setFixedSize(400, 528);
// 仅外观:登录窗自带样式(沿用全局主题令牌,保证一脉相承)。 // 仅外观:登录窗自带样式(沿用全局主题令牌,保证一脉相承)。
// QLineEdit 在所有状态都显式白底深字 + 边框,避免失焦时取调色板默认色与背景相近不可读。 // QLineEdit 在所有状态都显式白底深字 + 边框,避免失焦时取调色板默认色与背景相近不可读。
@ -174,6 +175,12 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
refreshRow->addWidget(refreshBtn_); refreshRow->addWidget(refreshBtn_);
form->addLayout(refreshRow); form->addLayout(refreshRow);
// 记住登录:勾选后成功登录将安全存储 token30 天内免登录。默认不勾(更安全)。
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_ = new QLabel(body);
errorLabel_->setStyleSheet(QStringLiteral("color: #C0392B; font-size: 12px;")); errorLabel_->setStyleSheet(QStringLiteral("color: #C0392B; font-size: 12px;"));
@ -264,6 +271,11 @@ void LoginWindow::attemptLogin()
refreshCaptcha(); // 失败刷新验证码 refreshCaptcha(); // 失败刷新验证码
} }
bool LoginWindow::remember() const
{
return rememberChk_ && rememberChk_->isChecked();
}
void LoginWindow::showError(const QString& msg) void LoginWindow::showError(const QString& msg)
{ {
errorLabel_->setText(msg); errorLabel_->setText(msg);

View File

@ -6,6 +6,7 @@
#include <QDialog> #include <QDialog>
#include <QString> #include <QString>
class QCheckBox;
class QLabel; class QLabel;
class QLineEdit; class QLineEdit;
class QPushButton; class QPushButton;
@ -25,6 +26,9 @@ public:
// 登录成功后的 accessToken形如 "Geomative <hash>");未登录为空。 // 登录成功后的 accessToken形如 "Geomative <hash>");未登录为空。
QString token() const { return token_; } QString token() const { return token_; }
// 用户是否勾选「记住登录」(成功后据此决定是否安全存储 token
bool remember() const;
private: private:
void refreshCaptcha(); // 拉新验证码并重绘图片 void refreshCaptcha(); // 拉新验证码并重绘图片
void attemptLogin(); // 校验输入并发起阻塞登录 void attemptLogin(); // 校验输入并发起阻塞登录
@ -40,6 +44,7 @@ private:
QLabel* captchaLabel_ = nullptr; QLabel* captchaLabel_ = nullptr;
QPushButton* refreshBtn_ = nullptr; QPushButton* refreshBtn_ = nullptr;
QPushButton* loginBtn_ = nullptr; QPushButton* loginBtn_ = nullptr;
QCheckBox* rememberChk_ = nullptr;
QLabel* errorLabel_ = nullptr; QLabel* errorLabel_ = nullptr;
}; };

View File

@ -33,6 +33,7 @@
#include <QLabel> #include <QLabel>
#include <QListWidget> #include <QListWidget>
#include <QListWidgetItem> #include <QListWidgetItem>
#include <QSettings>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QStringList> #include <QStringList>
#include <QTabWidget> #include <QTabWidget>
@ -56,6 +57,7 @@
#include "ApiClient.hpp" #include "ApiClient.hpp"
#include "AuthService.hpp" #include "AuthService.hpp"
#include "Credential.hpp"
#include "Glyphs.hpp" #include "Glyphs.hpp"
#include "PanelHeader.hpp" #include "PanelHeader.hpp"
#include "Theme.hpp" #include "Theme.hpp"
@ -128,9 +130,10 @@ enum class DetailMode { Section18, Scatter17 };
// #17 散点屏幕像素方块边长。 // #17 散点屏幕像素方块边长。
constexpr float kScatterPointSize = 4.0F; constexpr float kScatterPointSize = 4.0F;
// 纵向夸张倍数:三维断面墙沿 z 拉伸成墙;数据详情 #18 沿 y 拉伸填面板。 // 纵向夸张倍数Z 基准统一M-3全项目共用同一倍数使 帘面(z) / 体素 / 切片 /
constexpr double kCurtainZScale = 3.0; // 数据详情剖面(y) / 地形(relief) 的纵向比例一致——避免「剖面×1.5、帘面×3」不一致。
constexpr double kDetailYScale = 1.5; // 单一可调常量:要整体调纵向观感改这一处即可。
constexpr double kVerticalExaggeration = 2.0;
// 项目 CRS(实证确定,STATUS §4):散点 projX/Y→经纬→GeoLocalFrame 配准体素到世界系。 // 项目 CRS(实证确定,STATUS §4):散点 projX/Y→经纬→GeoLocalFrame 配准体素到世界系。
constexpr const char* kProjectCrs = "EPSG:4547"; // CGCS2000 / 3-degree GK CM 114E constexpr const char* kProjectCrs = "EPSG:4547"; // CGCS2000 / 3-degree GK CM 114E
@ -402,7 +405,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, showCurtain, frame]() { auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, showCurtain, frame]() {
geopro::app::rebuildCentralScene(*scene, rendererPtr, renderWindowPtr, *viewMode, geopro::app::rebuildCentralScene(*scene, rendererPtr, renderWindowPtr, *viewMode,
std::vector<geopro::app::SectionInput>{}, *showCurtain, std::vector<geopro::app::SectionInput>{}, *showCurtain,
*frame, kCurtainZScale); *frame, kVerticalExaggeration);
}; };
// ── 数据详情共享状态 + 重建 ────────────────────────────────────────── // ── 数据详情共享状态 + 重建 ──────────────────────────────────────────
@ -430,18 +433,18 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
const auto cs = repo.loadColorScale(id); const auto cs = repo.loadColorScale(id);
const auto actors = geopro::render::buildGridContour(g, cs); const auto actors = geopro::render::buildGridContour(g, cs);
if (actors.bands) { if (actors.bands) {
actors.bands->SetScale(1.0, kDetailYScale, 1.0); actors.bands->SetScale(1.0, kVerticalExaggeration, 1.0);
detailRendererPtr->AddViewProp(actors.bands); detailRendererPtr->AddViewProp(actors.bands);
} }
if (actors.edges && *showContour) { if (actors.edges && *showContour) {
actors.edges->SetScale(1.0, kDetailYScale, 1.0); actors.edges->SetScale(1.0, kVerticalExaggeration, 1.0);
detailRendererPtr->AddViewProp(actors.edges); detailRendererPtr->AddViewProp(actors.edges);
} }
// 顶部电极标记 ▼(仅网格数据;同纵向夸张对齐)。 // 顶部电极标记 ▼(仅网格数据;同纵向夸张对齐)。
if (*showElectrodes) { if (*showElectrodes) {
auto elec = geopro::render::buildElectrodes(g); auto elec = geopro::render::buildElectrodes(g);
if (elec) { if (elec) {
elec->SetScale(1.0, kDetailYScale, 1.0); elec->SetScale(1.0, kVerticalExaggeration, 1.0);
detailRendererPtr->AddViewProp(elec); detailRendererPtr->AddViewProp(elec);
} }
} }
@ -451,7 +454,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
const auto scs = repo.loadScatterColorScale(id); const auto scs = repo.loadScatterColorScale(id);
auto a = geopro::render::buildScatter(s, scs, kScatterPointSize); auto a = geopro::render::buildScatter(s, scs, kScatterPointSize);
if (a) { if (a) {
a->SetScale(1.0, kDetailYScale, 1.0); a->SetScale(1.0, kVerticalExaggeration, 1.0);
detailRendererPtr->AddViewProp(a); detailRendererPtr->AddViewProp(a);
} }
} }
@ -461,7 +464,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
for (int i = 0; i < static_cast<int>(anomalies.size()); ++i) { for (int i = 0; i < static_cast<int>(anomalies.size()); ++i) {
if (hiddenAnoms->count(i)) continue; // 列表中取消勾选→隐藏 if (hiddenAnoms->count(i)) continue; // 列表中取消勾选→隐藏
for (auto& act : geopro::render::buildAnomalies({anomalies[i]})) { 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); detailRendererPtr->AddViewProp(act);
} }
} }
@ -727,6 +730,22 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
.arg(QString::fromUtf8(kProjectCrs)) .arg(QString::fromUtf8(kProjectCrs))
.arg(lat0, 0, 'f', 5) .arg(lat0, 0, 'f', 5)
.arg(lon0, 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 } // namespace
@ -742,6 +761,10 @@ int main(int argc, char* argv[])
QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat());
QApplication app(argc, argv); QApplication app(argc, argv);
// 组织/应用名QSettings 持久化dock 布局、登录记忆等)按此定位存储位置。
QCoreApplication::setOrganizationName(QStringLiteral("Geomative"));
QCoreApplication::setApplicationName(QStringLiteral("Geopro3"));
// 浅色专业主题Fusion + 调色板 + 全局样式表):仅外观,登录窗与工作台共用。 // 浅色专业主题Fusion + 调色板 + 全局样式表):仅外观,登录窗与工作台共用。
geopro::app::applyTheme(app); geopro::app::applyTheme(app);
@ -768,11 +791,19 @@ int main(int argc, char* argv[])
const std::string pem = readPem("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem"); const std::string pem = readPem("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem");
geopro::net::AuthService auth(api, pem); geopro::net::AuthService auth(api, pem);
// 先弹登录窗;用户取消/未登录则退出。 // 记住登录:若上次勾选「记住」且未超 30 天,凭证库里有有效 token → 免登录直接进。
geopro::app::LoginWindow login(auth); QString token = geopro::app::recallValidToken(30);
if (login.exec() != QDialog::Accepted) return 0; 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( geopro::data::LocalSampleRepository repo(