feat(settings): 设置对话框(外观/关于) + 主题持久化 + 界面字号

- 主题持久化(QSettings ui/themeMode: system/light/dark): 启动时 applyPersistedThemeMode 在弹登录窗前
  应用 → 登录页与主页明暗统一(修登录页一直 dark); 设置里改主题热切, Ctrl+Shift+T 仍可用
- 界面字号(ui/fontScale: 90/100/115/130): applyPersistedFontScale 设 qApp 基准字体; scaledPx() 让
  内联 QSS 的 chrome(顶栏/面板表头/工具条/登录/浮层)字号也随之缩放; 字号改动重启后整体统一生效
- SettingsDialog: 左分类(外观/关于)+右页; 外观=主题下拉+字号下拉(+立即重启); 关于=版本+第三方许可
- 顶栏齿轮 → settingsRequested → 打开设置
This commit is contained in:
gaozheng 2026-06-10 14:03:16 +08:00
parent 52bdf054a6
commit a13b58e09f
10 changed files with 273 additions and 19 deletions

View File

@ -25,7 +25,8 @@ add_executable(geopro_desktop WIN32
panels/DatasetListPanel.cpp
panels/ObjectTreePanel.cpp
CentralScene.cpp
ProjectListDialog.cpp)
ProjectListDialog.cpp
SettingsDialog.cpp)
target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
# QtKeychain FetchContent target / export

View File

@ -46,12 +46,12 @@ QString headerQss()
"QToolButton#tabBtn:hover { color:#1F2A3D; }"
"QToolButton#tabBtn:checked { color:#2D6CB5; font-weight:%3;"
" border-bottom:2px solid #2D6CB5; }")
.arg(type::kTitle)
.arg(type::kCaption)
.arg(scaledPx(type::kTitle))
.arg(scaledPx(type::kCaption))
.arg(type::kWeightSemibold)
.arg(QString::fromUtf8(semantic::kWarningFill))
.arg(QString::fromUtf8(semantic::kWarning))
.arg(type::kBody);
.arg(scaledPx(type::kBody));
}
// 数量徽标(默认隐藏,调用方 setText+setVisible 显示)。

156
src/app/SettingsDialog.cpp Normal file
View File

@ -0,0 +1,156 @@
#include "SettingsDialog.hpp"
#include <QCoreApplication>
#include <QHBoxLayout>
#include <QLabel>
#include <QListWidget>
#include <QProcess>
#include <QStackedWidget>
#include <QTextBrowser>
#include <QVBoxLayout>
#include <QWidget>
#include <ElaComboBox.h>
#include <ElaPushButton.h>
#include <ElaText.h>
#include "Theme.hpp"
namespace geopro::app {
namespace {
// 「标签 + 控件」一行(标签定宽左对齐,控件右随)。
QWidget* makeRow(const QString& label, QWidget* control) {
auto* row = new QWidget();
auto* lay = new QHBoxLayout(row);
lay->setContentsMargins(0, 0, 0, 0);
lay->setSpacing(12);
auto* lbl = new QLabel(label, row);
lbl->setMinimumWidth(96);
lay->addWidget(lbl);
lay->addWidget(control, 1);
return row;
}
// 区段标题。
ElaText* sectionTitle(const QString& text, QWidget* parent) {
auto* t = new ElaText(text, parent);
t->setTextPixelSize(geopro::app::scaledPx(geopro::app::type::kHeading));
return t;
}
QWidget* buildAppearancePage() {
auto* page = new QWidget();
auto* v = new QVBoxLayout(page);
v->setContentsMargins(24, 20, 24, 20);
v->setSpacing(16);
v->addWidget(sectionTitle(QStringLiteral("外观"), page));
// 主题:跟随系统 / 浅色 / 深色(热切)。
auto* themeCombo = new ElaComboBox(page);
themeCombo->addItem(QStringLiteral("跟随系统"), QStringLiteral("system"));
themeCombo->addItem(QStringLiteral("浅色"), QStringLiteral("light"));
themeCombo->addItem(QStringLiteral("深色"), QStringLiteral("dark"));
const QString curTheme = geopro::app::themeModePreference();
themeCombo->setCurrentIndex(themeCombo->findData(curTheme) >= 0 ? themeCombo->findData(curTheme) : 0);
QObject::connect(themeCombo, &QComboBox::activated, page, [themeCombo](int) {
geopro::app::setThemeModePreference(themeCombo->currentData().toString());
});
v->addWidget(makeRow(QStringLiteral("主题"), themeCombo));
// 界面字号:小/标准/大/特大(重启生效)。
auto* fontCombo = new ElaComboBox(page);
fontCombo->addItem(QStringLiteral(""), 90);
fontCombo->addItem(QStringLiteral("标准"), 100);
fontCombo->addItem(QStringLiteral(""), 115);
fontCombo->addItem(QStringLiteral("特大"), 130);
const int curScale = geopro::app::fontScalePreference();
fontCombo->setCurrentIndex(fontCombo->findData(curScale) >= 0 ? fontCombo->findData(curScale) : 1);
v->addWidget(makeRow(QStringLiteral("界面字号"), fontCombo));
// 字号改动:持久化 + 提示重启(提供立即重启)。
auto* restartRow = new QWidget(page);
auto* rlay = new QHBoxLayout(restartRow);
rlay->setContentsMargins(96 + 12, 0, 0, 0); // 与控件列对齐
rlay->setSpacing(10);
auto* hint = new ElaText(QStringLiteral("界面字号将在重启后生效"), restartRow);
hint->setTextPixelSize(geopro::app::scaledPx(geopro::app::type::kCaption));
auto* restartBtn = new ElaPushButton(QStringLiteral("立即重启"), restartRow);
rlay->addWidget(hint);
rlay->addWidget(restartBtn);
rlay->addStretch();
restartRow->setVisible(false);
v->addWidget(restartRow);
QObject::connect(fontCombo, &QComboBox::activated, page, [fontCombo, restartRow](int) {
geopro::app::setFontScalePreference(fontCombo->currentData().toInt());
restartRow->setVisible(true);
});
QObject::connect(restartBtn, &QPushButton::clicked, restartBtn, [] {
QProcess::startDetached(QCoreApplication::applicationFilePath(),
QCoreApplication::arguments().mid(1));
qApp->quit();
});
v->addStretch();
return page;
}
QWidget* buildAboutPage() {
auto* page = new QWidget();
auto* v = new QVBoxLayout(page);
v->setContentsMargins(24, 20, 24, 20);
v->setSpacing(12);
v->addWidget(sectionTitle(QStringLiteral("关于"), page));
auto* ver = new ElaText(QStringLiteral("Geopro 3.0 — 项目分析视图 (M1)"), page);
ver->setTextPixelSize(geopro::app::scaledPx(geopro::app::type::kTitle));
v->addWidget(ver);
auto* license = new QTextBrowser(page);
license->setOpenExternalLinks(true);
license->setHtml(QStringLiteral(
"<b>第三方组件与许可证</b>"
"<table cellpadding='3' style='margin-top:6px'>"
"<tr><td>Qt 6</td><td>GUI 框架</td><td>LGPL-3.0</td></tr>"
"<tr><td>VTK 9</td><td>二维/三维渲染</td><td>BSD-3-Clause</td></tr>"
"<tr><td>Qt-Advanced-Docking-System</td><td>停靠布局</td><td>LGPL-2.1</td></tr>"
"<tr><td>ElaWidgetTools</td><td>Fluent 控件</td><td>MIT</td></tr>"
"<tr><td>QtKeychain</td><td>凭证安全存取</td><td>BSD-3-Clause</td></tr>"
"</table>"
"<p style='margin-top:8px'>完整声明见随附 <i>NOTICE.md</i>。</p>"));
v->addWidget(license, 1);
return page;
}
} // namespace
SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) {
setWindowTitle(QStringLiteral("设置"));
resize(720, 480);
auto* root = new QHBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
// 左:分类列表。
auto* sidebar = new QListWidget(this);
sidebar->setObjectName(QStringLiteral("settingsSidebar"));
sidebar->setFixedWidth(150);
sidebar->addItem(QStringLiteral("外观"));
sidebar->addItem(QStringLiteral("关于"));
root->addWidget(sidebar);
// 右:分页。
auto* stack = new QStackedWidget(this);
stack->addWidget(buildAppearancePage());
stack->addWidget(buildAboutPage());
root->addWidget(stack, 1);
QObject::connect(sidebar, &QListWidget::currentRowChanged, stack,
&QStackedWidget::setCurrentIndex);
sidebar->setCurrentRow(0);
}
} // namespace geopro::app

View File

@ -0,0 +1,15 @@
#pragma once
#include <QDialog>
namespace geopro::app {
// 设置对话框(左分类 + 右内容页,参考常见客户端布局):
// 外观 —— 主题(跟随系统/浅色/深色,热切) + 界面字号(小/标准/大/特大,重启生效)
// 关于 —— 版本号 + 第三方组件与许可证
class SettingsDialog : public QDialog {
Q_OBJECT
public:
explicit SettingsDialog(QWidget* parent = nullptr);
};
} // namespace geopro::app

View File

@ -5,6 +5,7 @@
#include <QFont>
#include <QObject>
#include <QPalette>
#include <QSettings>
#include <QStyleFactory>
#include <QWidget>
@ -473,6 +474,60 @@ void applyBrandAccent()
eTheme->setThemeColor(ElaThemeType::Dark, ElaThemeType::BasicSelectedHoverAlpha, QColor(0x3C, 0x5D, 0x87));
}
// ── 设置:主题 / 字号 偏好 ──────────────────────────────────────────────
namespace {
constexpr int kBaseFontPx = 13; // 基准字号(与 eApp 默认一致)
int g_fontScale = 100; // 当前字号缩放百分比
} // namespace
QString themeModePreference()
{
return QSettings().value(QStringLiteral("ui/themeMode"), QStringLiteral("system")).toString();
}
void applyPersistedThemeMode()
{
const QString m = themeModePreference();
if (m == QStringLiteral("light")) {
eTheme->setIsFollowSystemTheme(false);
eTheme->setThemeMode(ElaThemeType::Light);
} else if (m == QStringLiteral("dark")) {
eTheme->setIsFollowSystemTheme(false);
eTheme->setThemeMode(ElaThemeType::Dark);
} else { // system跟随系统明暗
eTheme->setIsFollowSystemTheme(true);
}
}
void setThemeModePreference(const QString& mode)
{
QSettings().setValue(QStringLiteral("ui/themeMode"), mode);
applyPersistedThemeMode(); // 主题可热切,立即应用
}
int fontScalePreference()
{
return QSettings().value(QStringLiteral("ui/fontScale"), 100).toInt();
}
void setFontScalePreference(int percent)
{
QSettings().setValue(QStringLiteral("ui/fontScale"), percent); // 重启后生效
}
void applyPersistedFontScale()
{
g_fontScale = fontScalePreference();
QFont f = qApp->font();
f.setPixelSize(kBaseFontPx * g_fontScale / 100);
qApp->setFont(f);
}
int scaledPx(int basePx)
{
return basePx * g_fontScale / 100;
}
bool isDarkTheme()
{
return eTheme->getThemeMode() == ElaThemeType::Dark;

View File

@ -94,6 +94,19 @@ void applyTheme(QApplication& app);
// (选中/激活/标题栏强调) 与本项目 QSS 共用同一套蓝,消除"多种蓝打架"。在 eApp->init() 后调一次。
void applyBrandAccent();
// ── 设置:主题 / 界面字号 偏好QSettings 持久化)────────────────────────
// 启动时eApp->init + applyBrandAccent 之后、弹登录窗之前)各调一次,使登录页与主页统一。
void applyPersistedThemeMode(); // 应用持久化主题:跟随系统 / 浅色 / 深色 → ElaTheme
void applyPersistedFontScale(); // 应用持久化字号:设 qApp 基准字体 + 记录缩放(供 scaledPx)
QString themeModePreference(); // "system" | "light" | "dark"(默认 system
void setThemeModePreference(const QString& mode); // 持久化 + 立即应用(主题可热切)
int fontScalePreference(); // 缩放百分比 90/100/115/130默认 100
void setFontScalePreference(int percent); // 仅持久化(字号改动重启后生效)
int scaledPx(int basePx); // basePx × 当前字号% / 100内联 QSS 字号用,使自定义 chrome 也随字号缩放)
// 当前 ElaTheme 是否暗色(供内联样式判断)。
bool isDarkTheme();

View File

@ -1,5 +1,6 @@
#include "TopBar.hpp"
#include <QAbstractButton>
#include <QActionGroup>
#include <QColor>
#include <QFrame>
@ -145,12 +146,12 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) {
" font-size:%1px; }"
"#userName { color:#1F2A3D; font-size:%3px; font-weight:%4; }"
"#userRole { color:#8A93A3; font-size:%5px; }")
.arg(type::kBody)
.arg(scaledPx(type::kBody))
.arg(type::kWeightBold)
.arg(type::kLabel)
.arg(scaledPx(type::kLabel))
.arg(type::kWeightSemibold)
.arg(type::kCaption)
.arg(type::kTitle));
.arg(scaledPx(type::kCaption))
.arg(scaledPx(type::kTitle)));
auto* lay = new QHBoxLayout(this);
lay->setContentsMargins(14, 0, 14, 0);
@ -188,7 +189,10 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) {
lay->addWidget(makeIconButton(this, ElaIconType::CircleQuestion, QStringLiteral("帮助")));
lay->addWidget(makeIconButton(this, ElaIconType::Bell, QStringLiteral("通知")));
lay->addWidget(makeIconButton(this, ElaIconType::Gear, QStringLiteral("设置")));
auto* gearBtn = makeIconButton(this, ElaIconType::Gear, QStringLiteral("设置"));
if (auto* gb = qobject_cast<QAbstractButton*>(gearBtn))
QObject::connect(gb, &QAbstractButton::clicked, this, [this] { emit settingsRequested(); });
lay->addWidget(gearBtn);
lay->addSpacing(10);
lay->addWidget(makeDivider(this));
lay->addSpacing(12);

View File

@ -26,6 +26,7 @@ signals:
void projectSwitchRequested(const QString& projectId);
void allProjectsRequested(); // 点击"全部项目…"
void logoutRequested(); // 头像菜单「退出登录」
void settingsRequested(); // 点击齿轮图标 → 打开设置
private:
QToolButton* wsBtn_ = nullptr;

View File

@ -104,10 +104,10 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
"#fieldLabel { color: #5A6B85; font-size: %4px; font-weight: %5; }"
// 输入框已 Ela 化(ElaLineEdit 自绘 Fluent + 自动明暗),不再写 QLineEdit QSS。
"#captchaImg { border: 1px solid #C7D2E0; border-radius: 8px; background: #EEF2FB; }")
.arg(type::kDisplay)
.arg(scaledPx(type::kDisplay))
.arg(type::kWeightBold)
.arg(type::kCaption)
.arg(type::kCaption)
.arg(scaledPx(type::kCaption))
.arg(scaledPx(type::kCaption))
.arg(type::kWeightSemibold));
auto* root = new QVBoxLayout(this);
@ -198,7 +198,7 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
// 错误提示:固定占位高度,避免出现时整体布局跳动。
errorLabel_ = new QLabel(body);
geopro::app::applyThemedStyleSheet(
errorLabel_, QStringLiteral("color: #C0392B; font-size: %1px;").arg(type::kCaption));
errorLabel_, QStringLiteral("color: #C0392B; font-size: %1px;").arg(scaledPx(type::kCaption)));
errorLabel_->setWordWrap(true);
errorLabel_->setMinimumHeight(18);
form->addWidget(errorLabel_);

View File

@ -81,6 +81,7 @@
#include "Glyphs.hpp"
#include "PanelHeader.hpp"
#include "Theme.hpp"
#include "SettingsDialog.hpp"
#include "TopBar.hpp"
#include "CentralScene.hpp"
#include "ProjectListDialog.hpp"
@ -335,7 +336,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
"QToolButton:hover{ background:#EEF3FB; }"
"QToolButton:checked{ color:#2D6CB5; font-weight:%2;"
" border-bottom:2px solid #2D6CB5; }")
.arg(geopro::app::type::kBody)
.arg(geopro::app::scaledPx(geopro::app::type::kBody))
.arg(geopro::app::type::kWeightSemibold);
// 工具条:「二维地图/三维视图」两个互斥可勾选按钮。默认二维地图。
@ -380,7 +381,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
layerTitle, QStringLiteral("font-weight:%1;color:#2D6CB5;border:none;background:transparent;"
"padding-bottom:3px;font-size:%2px;")
.arg(geopro::app::type::kWeightSemibold)
.arg(geopro::app::type::kTitle));
.arg(geopro::app::scaledPx(geopro::app::type::kTitle)));
auto* chkCurtain = new ElaCheckBox(QStringLiteral("帘面(断面墙)"));
chkCurtain->setChecked(true);
auto* chkVoxel = new ElaCheckBox(QStringLiteral("体素dd_voxel"));
@ -431,7 +432,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
esTitle->setAlignment(Qt::AlignCenter);
geopro::app::applyThemedStyleSheet(
esTitle, QStringLiteral("color:#5A6B85; font-size:%1px; font-weight:%2;")
.arg(geopro::app::type::kHeading)
.arg(geopro::app::scaledPx(geopro::app::type::kHeading))
.arg(geopro::app::type::kWeightSemibold));
auto* esHint = new QLabel(QStringLiteral("单击左侧采集批次,查看反演剖面与异常点\n"
@ -439,7 +440,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
emptyState);
esHint->setAlignment(Qt::AlignCenter);
geopro::app::applyThemedStyleSheet(
esHint, QStringLiteral("color:#8A93A3; font-size:%1px;").arg(geopro::app::type::kBody));
esHint,
QStringLiteral("color:#8A93A3; font-size:%1px;").arg(geopro::app::scaledPx(geopro::app::type::kBody)));
esLay->addWidget(esIcon);
esLay->addWidget(esTitle);
@ -930,6 +932,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re
QCoreApplication::arguments().mid(1));
qApp->quit();
});
// 设置:点齿轮 → 打开设置对话框(外观/关于)。
QObject::connect(topBar, &geopro::app::TopBar::settingsRequested, &window, [&window]() {
geopro::app::SettingsDialog dlg(&window);
dlg.exec();
});
QObject::connect(topBar, &geopro::app::TopBar::allProjectsRequested, &window,
[&projectRepo, &nav, topBar, &window]() {
auto* dlg = new geopro::app::ProjectListDialog(projectRepo, &window);
@ -1060,9 +1067,11 @@ int main(int argc, char* argv[])
// 两种壳都需要它登录发生在选壳之前。Ela 控件跟随 ElaTheme标准控件仍由下面 QSS 接管。
eApp->init();
geopro::app::applyBrandAccent(); // 统一品牌强调色(Ela Primary),全 UI 选中/激活一套蓝
geopro::app::applyPersistedThemeMode(); // 应用持久化主题(跟随系统/浅/深)——登录页与主页统一
geopro::app::applyPersistedFontScale(); // 应用持久化字号缩放(登录页也随之缩放)
// 专业主题Fusion + 调色板 + 全局样式表):标准控件外观,登录窗与工作台共用。
// 跟随 ElaTheme 初始模式(可能随系统为暗),使登录窗与标准控件明暗一致(review M2)
// 跟随最终 ElaTheme 模式(持久化偏好已生效),使登录窗与标准控件明暗一致
geopro::app::applyThemeMode(app, eTheme->getThemeMode() == ElaThemeType::Dark);
// PROJ 数据(proj.db)定位:体素配准的 CrsTransform 需要。优先已设环境变量;