From 1a9fb72cf07b5f5bd62c2e0c6b82ec1b5a91c09c Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 9 Jun 2026 20:26:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20impeccable=20=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E4=BD=93=E7=B3=BB=20+=20=E7=A9=BA=E7=8A=B6?= =?UTF-8?q?=E6=80=81/=E8=AF=AD=E4=B9=89=E8=89=B2/=E5=8A=A8=E6=95=88=20+=20?= =?UTF-8?q?dock=20=E6=A0=87=E9=A2=98=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - typeset: Theme.hpp 新增排版令牌(type::),统一各处散落字号/字重 - layout: 间距/圆角令牌(space::/radius::),圆角 6 档→2 档,手调奇数余白对称化 - delight: 中央空状态引导浮层、上下文化加载文案、登录错误淡入 - colorize: 语义色令牌(semantic::),项目状态着色、状态栏错误染色、异常徽标警示色(休眠) - overdrive(休眠): 详情视图相机补间+actor淡入(animateReveal),待 dd 详情渲染接通后激活 - fix(dock): restoreState 后重新隐藏 ADS 子窗口标题栏,修复已保存布局下标题栏复现 --- src/app/PanelHeader.cpp | 46 ++++-- src/app/ProjectListDialog.cpp | 22 ++- src/app/Theme.cpp | 37 +++-- src/app/Theme.hpp | 67 +++++++++ src/app/TopBar.cpp | 46 +++--- src/app/login/LoginWindow.cpp | 75 +++++++--- src/app/main.cpp | 226 +++++++++++++++++++++++++++-- src/app/panels/ObjectTreePanel.cpp | 2 +- 8 files changed, 432 insertions(+), 89 deletions(-) diff --git a/src/app/PanelHeader.cpp b/src/app/PanelHeader.cpp index a561de6..30c342d 100644 --- a/src/app/PanelHeader.cpp +++ b/src/app/PanelHeader.cpp @@ -1,5 +1,7 @@ #include "PanelHeader.hpp" +#include "Theme.hpp" + #include #include #include @@ -20,19 +22,33 @@ constexpr int kTitleIcon = 20; // 表头标题图标 constexpr int kActionIcon = 19; // 表头操作按钮图标 constexpr int kTabIcon = 19; // Tab 图标 -// 表头统一样式(标准表头 + Tab 表头共用)。 -const char* kHeaderQss = - "#panelHeader { background:#FFFFFF; border-bottom:1px solid #E6EAF1; }" - "#panelTitle { color:#1F2A3D; font-size:14px; font-weight:600; }" - "#panelBadge { background:#EAEEF5; color:#5A6B85; border-radius:9px;" - " padding:1px 7px; font-size:12px; font-weight:600; }" - "QToolButton#panelAction { border:none; border-radius:7px; padding:5px; }" - "QToolButton#panelAction:hover { background:#EEF3FB; }" - "QToolButton#tabBtn { border:none; border-bottom:2px solid transparent; color:#5A6B85;" - " padding:8px 4px; font-size:14px; }" - "QToolButton#tabBtn:hover { color:#1F2A3D; }" - "QToolButton#tabBtn:checked { color:#1F2A3D; font-weight:600;" - " border-bottom:2px solid #2D6CB5; }"; +// 表头统一样式(标准表头 + Tab 表头共用)。字号/字重引用 Theme 排版令牌: +// 面板标题=title(15)、徽标=caption(12)、Tab 文本=body(13),加粗统一 semibold。 +// #panelBadge 为中性计数徽标;#panelBadgeWarn 为“需注意”变体(语义 warning 色), +// 供异常计数等承载“待复查”含义的徽标使用(调用方改 objectName 即切换)。 +QString headerQss() +{ + return QStringLiteral( + "#panelHeader { background:#FFFFFF; border-bottom:1px solid #E6EAF1; }" + "#panelTitle { color:#1F2A3D; font-size:%1px; font-weight:%4; }" + "#panelBadge { background:#EAEEF5; color:#5A6B85; border-radius:9px;" + " padding:1px 7px; font-size:%2px; font-weight:%4; }" + "#panelBadgeWarn { background:%5; color:%6; border-radius:9px;" + " padding:1px 7px; font-size:%2px; font-weight:%4; }" + "QToolButton#panelAction { border:none; border-radius:7px; padding:5px; }" + "QToolButton#panelAction:hover { background:#EEF3FB; }" + "QToolButton#tabBtn { border:none; border-bottom:2px solid transparent; color:#5A6B85;" + " padding:8px 4px; font-size:%3px; }" + "QToolButton#tabBtn:hover { color:#1F2A3D; }" + "QToolButton#tabBtn:checked { color:#1F2A3D; font-weight:%4;" + " border-bottom:2px solid #2D6CB5; }") + .arg(type::kTitle) + .arg(type::kCaption) + .arg(type::kBody) + .arg(type::kWeightSemibold) + .arg(QString::fromUtf8(semantic::kWarningFill)) + .arg(QString::fromUtf8(semantic::kWarning)); +} // 数量徽标(默认隐藏,调用方 setText+setVisible 显示)。 QLabel* makeBadge(QWidget* parent) @@ -65,7 +81,7 @@ QWidget* buildPanelHeader(Glyph icon, const QString& title, const QVector
setObjectName(QStringLiteral("panelHeader")); header->setFixedHeight(kHeaderHeight); - header->setStyleSheet(QString::fromUtf8(kHeaderQss)); + header->setStyleSheet(headerQss()); auto* lay = new QHBoxLayout(header); lay->setContentsMargins(12, 0, 8, 0); @@ -98,7 +114,7 @@ TabbedPanel buildTabbedPanel(const QVector& tabs, const QVector
setObjectName(QStringLiteral("panelHeader")); header->setFixedHeight(kHeaderHeight); - header->setStyleSheet(QString::fromUtf8(kHeaderQss)); + header->setStyleSheet(headerQss()); auto* hlay = new QHBoxLayout(header); hlay->setContentsMargins(10, 0, 8, 0); hlay->setSpacing(2); diff --git a/src/app/ProjectListDialog.cpp b/src/app/ProjectListDialog.cpp index 02f0574..23c28a6 100644 --- a/src/app/ProjectListDialog.cpp +++ b/src/app/ProjectListDialog.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -13,6 +14,8 @@ #include #include +#include "Theme.hpp" + namespace geopro::app { namespace { QString statusText(int s) { @@ -22,6 +25,15 @@ QString statusText(int s) { default: return QString::number(s); } } + +// 状态语义色(寻路):未开始=弱化中性、进行中=信息蓝(活动中);未知状态用中性灰。 +const char* statusColorHex(int s) { + switch (s) { + case 1: return "#8A93A3"; // 未开始:弱化 + case 2: return semantic::kInfo; // 进行中:活动中 + default: return "#5A6B85"; // 未知:中性 + } +} } // namespace ProjectListDialog::ProjectListDialog(data::IProjectRepository& repo, QWidget* parent) @@ -145,7 +157,15 @@ void ProjectListDialog::query() { nameItem->setForeground(QColor("#2D6CB5")); table_->setItem(i, 1, nameItem); set(2, QString::fromStdString(p.code)); - set(3, statusText(p.status)); + // 状态列语义着色:颜色承载“未开始/进行中”分类,进行中加粗强调(不只靠颜色)。 + auto* statusItem = new QTableWidgetItem(statusText(p.status)); + statusItem->setForeground(QColor(statusColorHex(p.status))); + if (p.status == 2) { + QFont f = statusItem->font(); + f.setBold(true); + statusItem->setFont(f); + } + table_->setItem(i, 3, statusItem); set(4, QString::fromStdString(p.typeName)); set(5, QString::fromStdString(p.ownerCompany)); set(6, QString::fromStdString(p.responsiblePerson)); diff --git a/src/app/Theme.cpp b/src/app/Theme.cpp index 3497124..ede5b4d 100644 --- a/src/app/Theme.cpp +++ b/src/app/Theme.cpp @@ -26,7 +26,7 @@ QToolTip { background: #1F2A3D; color: #F4F6FA; border: 1px solid #2D6CB5; - border-radius: 4px; + border-radius: 6px; padding: 4px 8px; } @@ -42,7 +42,7 @@ QToolBar QToolButton { background: transparent; color: #5A6B85; border: none; - border-radius: 7px; + border-radius: 8px; padding: 6px 14px; font-weight: 500; } @@ -75,7 +75,7 @@ QTreeWidget, QListWidget, QTreeView, QListView { outline: none; } QTreeWidget::item, QListWidget::item, QTreeView::item, QListView::item { - padding: 7px 8px; + padding: 8px 8px; border-radius: 6px; margin: 1px 4px; } @@ -170,7 +170,7 @@ QLineEdit { color: #1F2A3D; border: 1px solid #C7D2E0; border-radius: 6px; - padding: 5px 8px; + padding: 6px 8px; selection-background-color: #2D6CB5; selection-color: #FFFFFF; } @@ -190,7 +190,7 @@ QScrollBar:vertical { } QScrollBar::handle:vertical { background: #C2CCDA; - border-radius: 5px; + border-radius: 6px; min-height: 28px; } QScrollBar::handle:vertical:hover { @@ -203,7 +203,7 @@ QScrollBar:horizontal { } QScrollBar::handle:horizontal { background: #C2CCDA; - border-radius: 5px; + border-radius: 6px; min-width: 28px; } QScrollBar::handle:horizontal:hover { @@ -253,7 +253,7 @@ QMenuBar { } QMenuBar::item { background: transparent; - padding: 5px 12px; + padding: 6px 12px; border-radius: 6px; } QMenuBar::item:selected { @@ -264,11 +264,11 @@ QMenu { color: #1F2A3D; border: 1px solid #D5DBE5; border-radius: 8px; - padding: 5px; + padding: 6px; } QMenu::item { padding: 6px 24px 6px 14px; - border-radius: 5px; + border-radius: 6px; } QMenu::item:selected { background: #DCE9F8; @@ -277,7 +277,7 @@ QMenu::item:selected { QMenu::separator { height: 1px; background: #E1E6EE; - margin: 5px 8px; + margin: 6px 8px; } /* ── 下拉框(按需出现时也与主题一致)──────────────────────── */ @@ -286,7 +286,7 @@ QComboBox { color: #1F2A3D; border: 1px solid #C2CCDA; border-radius: 6px; - padding: 5px 10px; + padding: 6px 10px; min-height: 18px; } QComboBox:hover { @@ -327,14 +327,14 @@ QGroupBox::title { QProgressBar { background: #E6EBF3; border: none; - border-radius: 5px; + border-radius: 6px; height: 8px; text-align: center; color: #5A6B85; } QProgressBar::chunk { background: #2D6CB5; - border-radius: 5px; + border-radius: 6px; } /* ── ADS 停靠:标题栏做成「固定面板表头」(对齐原型)────────────── @@ -416,12 +416,19 @@ void applyTheme(QApplication& app) app.setStyle(QStyleFactory::create(QStringLiteral("Fusion"))); // 基础字体:中文界面用 微软雅黑 UI 渲染最清爽;缺失时 Qt 自动回退。 - // 10pt(≈13px)对齐主流商用客户端基准;9pt 偏小显拥挤。抗锯齿优先,观感更精致。 - QFont base(QStringLiteral("Microsoft YaHei UI"), 10); + // 基准字号取排版令牌 type::kBody(13px)——统一为 px,与 QSS 同单位 + // (旧值 10pt≈13.3px,观感几乎不变);9pt 偏小显拥挤。抗锯齿优先,观感更精致。 + QFont base(QStringLiteral("Microsoft YaHei UI")); + base.setPixelSize(type::kBody); base.setStyleStrategy(QFont::PreferAntialias); app.setFont(base); app.setPalette(buildPalette()); + + // 注意:不要给 ADS 停靠标题(ads--CDockWidgetTab QLabel)追加任何样式—— + // 这些子窗口标题栏在 main.cpp 里被 setVisible(false) 刻意隐藏(表头由各面板 + // 自绘的 PanelHeader 承担)。改写其字号/内边距会变更度量并触发 ADS 重新 + // 评估标题栏可见性,把隐藏的标题又显示出来。字号统一只作用于可见控件。 app.setStyleSheet(QString::fromUtf8(kStyleSheet)); } diff --git a/src/app/Theme.hpp b/src/app/Theme.hpp index d6734a8..71a51d0 100644 --- a/src/app/Theme.hpp +++ b/src/app/Theme.hpp @@ -13,6 +13,73 @@ class QApplication; namespace geopro::app { +// ── 排版令牌(全项目唯一字号阶 + 字重角色)────────────────────────── +// 各处 QSS 的 font-size / font-weight 一律引用这些值,不再散落硬编码 px。 +// 阶比 ~1.18(body→title→heading),刻意拉开层级——避免 11/12/13/14 +// 这类只差 1px 的"糊层级",让标题/正文/说明三档一眼可分。 +// 单位:px(与全局 px 化的 QSS、固定像素行高对齐;后续若做无障碍字号 +// 缩放再统一切 pt)。字重:400 正文 / 500 可交互激活 / +// 600 标签·标题 / 700 仅展示性大标题。 +namespace type { + +inline constexpr int kCaption = 12; // 徽标·提示·角色名·错误·副标题·字段标签 +inline constexpr int kBody = 13; // 树/列表/菜单/正文(= 全局基准字号) +inline constexpr int kLabel = 13; // 表头·用户名等需加粗的同级标签(配 600) +inline constexpr int kTitle = 15; // 面板/停靠区/区段标题·主操作按钮 +inline constexpr int kHeading = 18; // 视图/对话框级标题(预留) +inline constexpr int kDisplay = 24; // 登录品牌名(唯一展示性大字) + +inline constexpr int kWeightRegular = 400; +inline constexpr int kWeightMedium = 500; +inline constexpr int kWeightSemibold = 600; +inline constexpr int kWeightBold = 700; + +} // namespace type + +// ── 间距令牌(全项目唯一间距阶)────────────────────────────────── +// 取代散落的 5/7/9/11/13/15/26 等任意值。这是密集专业工具的实际节奏 +// (非外加的 8pt 网格):相邻档 2px 粒度,足够紧凑又不糊成一片。 +// 用法:布局 setContentsMargins/setSpacing/addSpacing 与 QSS padding/margin +// 一律引用这些档;明显的奇数值就近归档(13/15→lg, 11→ml, 26→xxl)。 +namespace space { + +inline constexpr int kXxs = 2; // 发丝级:下划线偏移、滚动条边距 +inline constexpr int kXs = 4; // 紧凑内边距、最小间隙 +inline constexpr int kSm = 6; // 行内紧凑(控件竖向 padding) +inline constexpr int kMd = 8; // 标准间隔(最常用) +inline constexpr int kMl = 10; // 偏大行距(密集行/验证码行) +inline constexpr int kLg = 12; // 分组间隔、面板内左右边距 +inline constexpr int kXl = 16; // 区块内边距 +inline constexpr int kXxl = 24; // 区块间距、表单纵向边距 +inline constexpr int kXxxl = 32; // 页面级留白(登录窗左右边距) + +} // namespace space + +// ── 圆角令牌(统一原先 4/5/6/7/8/9 共 6 档为 3 档)──────────────── +// 圆形元素(头像等)用 直径/2 单独写字面量,不入档。 +namespace radius { + +inline constexpr int kSm = 6; // 按钮·输入·菜单项·滚动条·进度条 +inline constexpr int kMd = 8; // 卡片·面板·对话框·菜单·分组框 +inline constexpr int kPill = 9; // 数量徽标胶囊 + +} // namespace radius + +// ── 语义色令牌(状态/反馈,产品语境:只在承载含义处用,不作装饰)────────── +// 文字值均针对白底面板(#FFFFFF)选深色,对比度 ≥4.5:1(正文级);与冷调中性 +// 调色板调和。danger 沿用既有红,避免引入第二种红。 +namespace semantic { + +inline constexpr const char* kInfo = "#2D6CB5"; // 信息·进行中(= 品牌蓝) +inline constexpr const char* kSuccess = "#15803D"; // 成功·已完成(深绿) +inline constexpr const char* kWarning = "#B45309"; // 警告·需注意(深琥珀) +inline constexpr const char* kDanger = "#C0392B"; // 危险·错误(沿用既有红) + +// 浅色填充(徽标/标签底色,配同族深色文字使用)。 +inline constexpr const char* kWarningFill = "#FBEAD2"; // 警告底纹(配 kWarning 文字) + +} // namespace semantic + // 应用浅色专业主题(Fusion + 调色板 + 全局样式表)。幂等,启动调用一次即可。 void applyTheme(QApplication& app); diff --git a/src/app/TopBar.cpp b/src/app/TopBar.cpp index 9d20fc7..3a9824f 100644 --- a/src/app/TopBar.cpp +++ b/src/app/TopBar.cpp @@ -14,6 +14,7 @@ #include #include "Glyphs.hpp" +#include "Theme.hpp" namespace geopro::app { @@ -116,10 +117,11 @@ QWidget* buildMenuBar(QWidget* parent) mb->setObjectName(QStringLiteral("appMenuBar")); // 自带样式(覆盖全局),加大字号/内边距,专业观感。 mb->setStyleSheet(QStringLiteral( - "#appMenuBar { background:#FFFFFF; border-bottom:1px solid #EEF1F5; padding:2px 8px; }" - "#appMenuBar::item { padding:7px 14px; border-radius:6px; font-size:14px; color:#1F2A3D; }" - "#appMenuBar::item:selected { background:#EAF1FB; color:#2D6CB5; }" - "#appMenuBar::item:pressed { background:#DCE6F4; }")); + "#appMenuBar { background:#FFFFFF; border-bottom:1px solid #EEF1F5; padding:2px 8px; }" + "#appMenuBar::item { padding:7px 14px; border-radius:6px; font-size:%1px; color:#1F2A3D; }" + "#appMenuBar::item:selected { background:#EAF1FB; color:#2D6CB5; }" + "#appMenuBar::item:pressed { background:#DCE6F4; }") + .arg(type::kBody)); mb->addMenu(buildViewMenu(mb)); mb->addMenu(buildProjectMenu(mb)); mb->addMenu(buildToolsMenu(mb)); @@ -130,19 +132,27 @@ QWidget* buildMenuBar(QWidget* parent) TopBar::TopBar(QWidget* parent) : QWidget(parent) { setObjectName(QStringLiteral("appToolBar")); setFixedHeight(56); + // 字号引用 Theme 排版令牌:工作空间切换器=title(15)、头像/用户名=body·label(13)、 + // 角色名=caption(12)。原 11px 角色名上调到 12,去掉只差 1px 的糊层级。 setStyleSheet(QStringLiteral( - "#appToolBar { background:#FFFFFF; border-bottom:1px solid #E1E6EE; }" - "#topDivider { color:#E1E6EE; }" - "#wsSwitcher { color:#1F2A3D; border:none; border-radius:8px; padding:8px 12px;" - " font-size:14px; font-weight:600; }" - "#wsSwitcher:hover { background:#EEF3FB; }" - "QToolButton#iconBtn { border:none; border-radius:8px; padding:8px; }" - "QToolButton#iconBtn:hover { background:#EEF3FB; }" - "QToolButton::menu-indicator { image:none; }" - "#avatar { background:#2D6CB5; color:#FFFFFF; border-radius:17px; font-weight:700;" - " font-size:13px; }" - "#userName { color:#1F2A3D; font-size:13px; font-weight:600; }" - "#userRole { color:#8A93A3; font-size:11px; }")); + "#appToolBar { background:#FFFFFF; border-bottom:1px solid #E1E6EE; }" + "#topDivider { color:#E1E6EE; }" + "#wsSwitcher { color:#1F2A3D; border:none; border-radius:8px; padding:8px 12px;" + " font-size:%1px; font-weight:%5; }" + "#wsSwitcher:hover { background:#EEF3FB; }" + "QToolButton#iconBtn { border:none; border-radius:8px; padding:8px; }" + "QToolButton#iconBtn:hover { background:#EEF3FB; }" + "QToolButton::menu-indicator { image:none; }" + "#avatar { background:#2D6CB5; color:#FFFFFF; border-radius:17px; font-weight:%6;" + " font-size:%2px; }" + "#userName { color:#1F2A3D; font-size:%3px; font-weight:%5; }" + "#userRole { color:#8A93A3; font-size:%4px; }") + .arg(type::kTitle) + .arg(type::kBody) + .arg(type::kLabel) + .arg(type::kCaption) + .arg(type::kWeightSemibold) + .arg(type::kWeightBold)); auto* lay = new QHBoxLayout(this); lay->setContentsMargins(14, 0, 14, 0); @@ -156,7 +166,7 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) { wsBtn_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); wsBtn_->setPopupMode(QToolButton::InstantPopup); wsBtn_->setCursor(Qt::PointingHandCursor); - wsBtn_->setText(QStringLiteral("(加载中…)")); + wsBtn_->setText(QStringLiteral("正在加载工作空间…")); wsBtn_->setMenu(new QMenu(wsBtn_)); lay->addWidget(wsBtn_); @@ -172,7 +182,7 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) { projBtn_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); projBtn_->setPopupMode(QToolButton::InstantPopup); projBtn_->setCursor(Qt::PointingHandCursor); - projBtn_->setText(QStringLiteral("(加载中…)")); + projBtn_->setText(QStringLiteral("正在加载项目…")); projBtn_->setMenu(new QMenu(projBtn_)); lay->addWidget(projBtn_); diff --git a/src/app/login/LoginWindow.cpp b/src/app/login/LoginWindow.cpp index ff5bdea..10594ef 100644 --- a/src/app/login/LoginWindow.cpp +++ b/src/app/login/LoginWindow.cpp @@ -2,19 +2,23 @@ #include #include +#include #include +#include #include #include #include #include #include #include +#include #include #include #include #include #include "AuthService.hpp" +#include "Theme.hpp" namespace geopro::app { @@ -82,21 +86,27 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent) // 仅外观:登录窗自带样式(沿用全局主题令牌,保证一脉相承)。 // QLineEdit 在所有状态都显式白底深字 + 边框,避免失焦时取调色板默认色与背景相近不可读。 + // 字号引用 Theme 排版令牌:品牌名=display(24)、副标题/字段标签=caption(12)。 setStyleSheet(QStringLiteral( - "QDialog { background: #F4F6FA; }" - "#headerBand {" - " background: qlineargradient(x1:0, y1:0, x2:1, y2:1," - " stop:0 #2D6CB5, stop:1 #234F87); }" - "#brandTitle { color: #FFFFFF; font-size: 23px; font-weight: 700; }" - "#brandSubtitle { color: rgba(255,255,255,0.82); font-size: 12px; }" - "#fieldLabel { color: #5A6B85; font-size: 12px; font-weight: 600; }" - "QLineEdit {" - " background: #FFFFFF; color: #1F2A3D;" - " border: 1px solid #C7D2E0; border-radius: 8px; padding: 0 12px;" - " selection-background-color: #2D6CB5; selection-color: #FFFFFF; }" - "QLineEdit:focus { border: 1px solid #2D6CB5; }" - "QLineEdit:disabled { background: #F0F2F6; color: #8A93A3; }" - "#captchaImg { border: 1px solid #C7D2E0; border-radius: 8px; background: #EEF2FB; }")); + "QDialog { background: #F4F6FA; }" + "#headerBand {" + " background: qlineargradient(x1:0, y1:0, x2:1, y2:1," + " stop:0 #2D6CB5, stop:1 #234F87); }" + "#brandTitle { color: #FFFFFF; font-size: %1px; font-weight: %2; }" + "#brandSubtitle { color: rgba(255,255,255,0.82); font-size: %3px; }" + "#fieldLabel { color: #5A6B85; font-size: %4px; font-weight: %5; }" + "QLineEdit {" + " background: #FFFFFF; color: #1F2A3D;" + " border: 1px solid #C7D2E0; border-radius: 8px; padding: 0 12px;" + " selection-background-color: #2D6CB5; selection-color: #FFFFFF; }" + "QLineEdit:focus { border: 1px solid #2D6CB5; }" + "QLineEdit:disabled { background: #F0F2F6; color: #8A93A3; }" + "#captchaImg { border: 1px solid #C7D2E0; border-radius: 8px; background: #EEF2FB; }") + .arg(type::kDisplay) + .arg(type::kWeightBold) + .arg(type::kCaption) + .arg(type::kCaption) + .arg(type::kWeightSemibold)); auto* root = new QVBoxLayout(this); root->setContentsMargins(0, 0, 0, 0); @@ -122,8 +132,9 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent) // ── 表单主体:标签在上、输入在下的纵向字段(现代、留白充分)── auto* body = new QWidget(this); auto* form = new QVBoxLayout(body); - form->setContentsMargins(32, 24, 32, 26); - form->setSpacing(6); + // 表单边距取间距令牌:左右 xxxl(32)、上下 xxl(24),对称(原底部 26 是手调奇数)。 + form->setContentsMargins(space::kXxxl, space::kXxl, space::kXxxl, space::kXxl); + form->setSpacing(space::kSm); // 统一字段构造:小号muted标签 + 40px 高输入框 + 字段间距。 auto addField = [&](const QString& labelText, QLineEdit* edit) { @@ -178,12 +189,14 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent) // 记住登录:勾选后成功登录将安全存储 token,30 天内免登录。默认不勾(更安全)。 rememberChk_ = new QCheckBox(QStringLiteral("记住登录(30 天内免登录)"), body); rememberChk_->setCursor(Qt::PointingHandCursor); - rememberChk_->setStyleSheet(QStringLiteral("color:#5A6B85; font-size:13px;")); + rememberChk_->setStyleSheet( + QStringLiteral("color:#5A6B85; font-size:%1px;").arg(type::kBody)); form->addWidget(rememberChk_); // 错误提示:固定占位高度,避免出现时整体布局跳动。 errorLabel_ = new QLabel(body); - errorLabel_->setStyleSheet(QStringLiteral("color: #C0392B; font-size: 12px;")); + errorLabel_->setStyleSheet( + QStringLiteral("color: #C0392B; font-size: %1px;").arg(type::kCaption)); errorLabel_->setWordWrap(true); errorLabel_->setMinimumHeight(18); form->addWidget(errorLabel_); @@ -195,11 +208,13 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent) loginBtn_->setMinimumHeight(44); loginBtn_->setCursor(Qt::PointingHandCursor); loginBtn_->setStyleSheet(QStringLiteral( - "QPushButton { background: #2D6CB5; color: #FFFFFF; border: none; border-radius: 8px; " - "font-size: 15px; font-weight: 600; }" - "QPushButton:hover { background: #2862A6; }" - "QPushButton:pressed { background: #234F87; }" - "QPushButton:disabled { background: #9FB4CC; }")); + "QPushButton { background: #2D6CB5; color: #FFFFFF; border: none; border-radius: 8px; " + "font-size: %1px; font-weight: %2; }" + "QPushButton:hover { background: #2862A6; }" + "QPushButton:pressed { background: #234F87; }" + "QPushButton:disabled { background: #9FB4CC; }") + .arg(type::kTitle) + .arg(type::kWeightSemibold)); loginBtn_->setDefault(true); form->addWidget(loginBtn_); @@ -279,6 +294,20 @@ bool LoginWindow::remember() const void LoginWindow::showError(const QString& msg) { errorLabel_->setText(msg); + + // 错误淡入:柔化失败时刻(仅透明度 200ms;errorLabel_ 已预留固定高度, + // 不引发布局跳动)。复用同一 opacity effect,重复报错每次重新淡入。 + auto* fx = qobject_cast(errorLabel_->graphicsEffect()); + if (!fx) { + fx = new QGraphicsOpacityEffect(errorLabel_); + errorLabel_->setGraphicsEffect(fx); + } + auto* anim = new QPropertyAnimation(fx, "opacity", errorLabel_); + anim->setDuration(200); + anim->setStartValue(0.0); + anim->setEndValue(1.0); + anim->setEasingCurve(QEasingCurve::OutQuad); + anim->start(QAbstractAnimation::DeleteWhenStopped); } } // namespace geopro::app diff --git a/src/app/main.cpp b/src/app/main.cpp index b78e0bd..f9296e4 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -28,18 +28,25 @@ #include #include #include +#include +#include #include #include +#include #include #include #include #include #include +#include +#include #include #include #include #include +#include #include +#include #include #include #include @@ -93,15 +100,90 @@ #include #include +#include +#include +#include #include #include #include +#include #include #include #include namespace { +// 居中浮层定位器:监视 host(中央 QVTK)尺寸/显示变化,把 overlay 浮层 +// (与 host 同父的兄弟控件)始终摆到 host 区域正中。用于中央“空状态”引导层。 +// 仅外观,无业务逻辑;无信号槽故不需 Q_OBJECT/moc。 +class CenterOverlay : public QObject { +public: + CenterOverlay(QWidget* overlay, QWidget* host) + : QObject(host), overlay_(overlay), host_(host) + { + host_->installEventFilter(this); + } + void reposition() + { + overlay_->adjustSize(); + const QSize h = host_->size(); + const QSize o = overlay_->size(); + overlay_->move(host_->x() + (h.width() - o.width()) / 2, + host_->y() + (h.height() - o.height()) / 2); + overlay_->raise(); + } + +protected: + bool eventFilter(QObject* obj, QEvent* e) override + { + if (obj == host_ && (e->type() == QEvent::Resize || e->type() == QEvent::Show)) + reposition(); + return QObject::eventFilter(obj, e); + } + +private: + QWidget* overlay_; + QWidget* host_; +}; + +// 相机补间 + actor 淡入:从 from 位姿平滑过渡到 to 位姿,同时 actors 透明度 0→1。 +// vtkCameraInterpolator 两关键帧线性插值(缓动交给 QEasingCurve),单条 QVariantAnimation +// 逐帧驱动并 Render;结束回调锁定到目标态(防插值末值误差/残留半透明)。 +// 渐进增强:动效只是过渡,最终一帧永远是正确的目标态,故即使观感不佳也不破坏功能。 +void animateReveal(vtkRenderer* renderer, vtkGenericOpenGLRenderWindow* rw, + vtkSmartPointer fromCam, vtkSmartPointer toCam, + std::vector> actors, int durationMs, QObject* owner) +{ + auto interp = vtkSmartPointer::New(); + interp->SetInterpolationTypeToLinear(); + interp->AddCamera(0.0, fromCam); + interp->AddCamera(1.0, toCam); + + auto* anim = new QVariantAnimation(owner); + anim->setDuration(durationMs); + anim->setStartValue(0.0); + anim->setEndValue(1.0); + anim->setEasingCurve(QEasingCurve::OutCubic); + QObject::connect(anim, &QVariantAnimation::valueChanged, owner, + [interp, renderer, rw, actors](const QVariant& v) { + const double t = v.toDouble(); + interp->InterpolateCamera(t, renderer->GetActiveCamera()); + for (const auto& a : actors) + if (a) a->GetProperty()->SetOpacity(t); + renderer->ResetCameraClippingRange(); + rw->Render(); + }); + QObject::connect(anim, &QVariantAnimation::finished, owner, + [renderer, rw, actors, toCam]() { + renderer->GetActiveCamera()->DeepCopy(toCam); + for (const auto& a : actors) + if (a) a->GetProperty()->SetOpacity(1.0); + renderer->ResetCameraClippingRange(); + rw->Render(); + }); + anim->start(QAbstractAnimation::DeleteWhenStopped); +} + // 读取 RSA 公钥 PEM 全文(登录时密码加密用)。读不到返回空串,登录将报错。 std::string readPem(const std::string& path) { @@ -247,11 +329,16 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re "border-radius:8px;} QCheckBox{padding:2px 1px;color:#1F2A3D;}" "QCheckBox:disabled{color:#9AA6B6;}")); auto* layerLayout = new QVBoxLayout(layerPanel); - layerLayout->setContentsMargins(13, 10, 15, 11); - layerLayout->setSpacing(6); + // 浮层内边距取间距令牌:左右 lg(12)、上下 ml(10),对称(原 13/10/15/11 是手调奇数)。 + layerLayout->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMl, + geopro::app::space::kLg, geopro::app::space::kMl); + layerLayout->setSpacing(geopro::app::space::kSm); auto* layerTitle = new QLabel(QStringLiteral("视图详情")); layerTitle->setStyleSheet(QStringLiteral( - "font-weight:600;color:#2D6CB5;border:none;background:transparent;padding-bottom:3px;")); + "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)); auto* chkCurtain = new QCheckBox(QStringLiteral("帘面(断面墙)")); chkCurtain->setChecked(true); auto* chkVoxel = new QCheckBox(QStringLiteral("体素(dd_voxel)")); @@ -278,6 +365,56 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re layerLayout->addWidget(chkTerrain); layerPanel->setVisible(false); // 默认二维,不显示图层浮层 + // ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。── + // 透明背景 + 鼠标穿透(不挡 QVTK 交互);CenterOverlay 随视口尺寸保持居中; + // 接入真实中央数据后改成依 sections 是否为空调 setVisible 即可。 + auto* emptyState = new QFrame(centerWidget); + emptyState->setObjectName(QStringLiteral("centralEmpty")); + emptyState->setAttribute(Qt::WA_TransparentForMouseEvents); + emptyState->setStyleSheet(QStringLiteral( + "#centralEmpty { background: transparent; }" + "#centralEmpty QLabel { background: transparent; }")); + auto* esLay = new QVBoxLayout(emptyState); + esLay->setContentsMargins(geopro::app::space::kXl, geopro::app::space::kXl, + geopro::app::space::kXl, geopro::app::space::kXl); + esLay->setSpacing(geopro::app::space::kMd); + esLay->setAlignment(Qt::AlignCenter); + + auto* esIcon = new QLabel(emptyState); + esIcon->setPixmap( + geopro::app::makeGlyph(geopro::app::Glyph::Dataset, QColor("#C2CCDA"), 56).pixmap(56, 56)); + esIcon->setAlignment(Qt::AlignCenter); + + auto* esTitle = new QLabel(QStringLiteral("选择左侧数据集开始分析"), emptyState); + esTitle->setAlignment(Qt::AlignCenter); + esTitle->setStyleSheet(QStringLiteral("color:#5A6B85; font-size:%1px; font-weight:%2;") + .arg(geopro::app::type::kHeading) + .arg(geopro::app::type::kWeightSemibold)); + + auto* esHint = new QLabel(QStringLiteral("单击左侧采集批次,查看反演剖面与异常点\n" + "切到「三维视图」可叠加帘面、体素与地形图层"), + emptyState); + esHint->setAlignment(Qt::AlignCenter); + esHint->setStyleSheet( + QStringLiteral("color:#8A93A3; font-size:%1px;").arg(geopro::app::type::kBody)); + + esLay->addWidget(esIcon); + esLay->addWidget(esTitle); + esLay->addWidget(esHint); + + auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget); + emptyCentering->reposition(); + + // 引导层淡入(350ms,仅透明度,OutCubic):首屏空态出现的克制过渡,不阻塞任务。 + auto* esFx = new QGraphicsOpacityEffect(emptyState); + emptyState->setGraphicsEffect(esFx); + auto* esAnim = new QPropertyAnimation(esFx, "opacity", emptyState); + esAnim->setDuration(350); + esAnim->setStartValue(0.0); + esAnim->setEndValue(1.0); + esAnim->setEasingCurve(QEasingCurve::OutCubic); + esAnim->start(QAbstractAnimation::DeleteWhenStopped); + auto* vtkDock = new ads::CDockWidget(QStringLiteral("二维地图/三维视图")); vtkDock->setWidget(centerWidget); auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock); @@ -376,6 +513,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re {{geopro::app::Glyph::Filter, QStringLiteral("筛选")}, {geopro::app::Glyph::Plus, QStringLiteral("添加异常")}}); auto* anomalyBadge = anomalyPanel.badges.value(0); // 异常列表 Tab 的数量徽标 + // colorize(C):异常计数用语义 warning“需注意”变体(区别于普通中性计数徽标), + // 提示“这些异常点待复查”。改 objectName 后重新 polish 以应用 #panelBadgeWarn 样式。 + // 注:徽标的填充/显隐在 loadDataset 内(当前被 park),故此色与徽标本身同属休眠态, + // 接 dd 详情渲染那轮一并可见。 + if (anomalyBadge) { + anomalyBadge->setObjectName(QStringLiteral("panelBadgeWarn")); + anomalyBadge->style()->unpolish(anomalyBadge); + anomalyBadge->style()->polish(anomalyBadge); + } auto* rightDock = new ads::CDockWidget(QStringLiteral("异常列表/对象属性")); rightDock->setWidget(anomalyPanel.container); @@ -393,12 +539,16 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 固定全部面板(对齐原型):移除 关闭/浮动/拖动/钉住 等子窗口操作,仅保留分隔条调整边界。 // 同时隐藏 ADS 自带标题栏——表头已由各面板内的自绘 PanelHeader 承担,避免“双标题”。 - // 注:AlwaysShowTabs=true 时 ADS 不再自动改写标题栏可见性,手动隐藏可稳定保持。 - for (ads::CDockWidget* d : {vtkDock, detailDock, leftDock, datasetDock, rightDock, propDock}) { - d->setFeatures(ads::CDockWidget::NoDockWidgetFeatures); - if (auto* area = d->dockAreaWidget()) - if (auto* bar = area->titleBar()) bar->setVisible(false); - } + // 抽成 lambda:ADS restoreState() 恢复布局时会重建停靠区并重新显示标题栏, + // 故须在恢复布局之后再调用一次,确保任何已保存布局下标题栏都稳定隐藏。 + const auto hideDockTitleBars = [&]() { + for (ads::CDockWidget* d : {vtkDock, detailDock, leftDock, datasetDock, rightDock, propDock}) { + d->setFeatures(ads::CDockWidget::NoDockWidgetFeatures); + if (auto* area = d->dockAreaWidget()) + if (auto* bar = area->titleBar()) bar->setVisible(false); + } + }; + hideDockTitleBars(); // 中央编排已解耦到 CentralScene::rebuildCentralScene(数据驱动)。本轮空 sections → 空背景占位。 // 下一轮:用真实 DS 数据构建 sections 调同一 helper 即复活。 @@ -416,16 +566,29 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re auto showElectrodes = std::make_shared(true); // 默认显示电极 ▼ auto showContour = std::make_shared(true); // 默认显示等值线 auto hiddenAnoms = std::make_shared>(); // 异常列表中被取消勾选(隐藏)的异常下标 + auto prevDsId = std::make_shared(); // 上次渲染的 DS id:判定“切换数据集”以触发揭示过渡 // 按当前选中 DS + 详情模式重建下方数据详情(平躺俯视正交,纵向夸张填面板)。 // 勾选「显示异常/电极/等值线」控制对应叠加(同纵向夸张对齐)。 - auto rebuildDetail = [&repo, detailRendererPtr, detailRenderWindowPtr, currentDsId, detailMode, - showAnomalies, showElectrodes, showContour, hiddenAnoms]() { + // overdrive(A):仅“切换数据集”这一加载时刻播放相机补间 + actor 淡入揭示;模式/叠加层开关 + // 属同一数据集内微调,直接落定不放动画(特殊时刻才特殊,避免每次交互都动的疲劳)。 + auto rebuildDetail = [&repo, detailRendererPtr, detailRenderWindowPtr, detailWidget, currentDsId, + prevDsId, detailMode, showAnomalies, showElectrodes, showContour, + hiddenAnoms]() { + const bool dsChanged = (*currentDsId != *prevDsId); + const bool animate = dsChanged && !prevDsId->isEmpty() && !currentDsId->isEmpty(); + + // 过渡起点:清场景前先快照当前相机位姿。 + auto fromCam = vtkSmartPointer::New(); + fromCam->DeepCopy(detailRendererPtr->GetActiveCamera()); + detailRendererPtr->RemoveAllViewProps(); if (currentDsId->isEmpty()) { // 未选数据集:清空即可 + *prevDsId = *currentDsId; detailRenderWindowPtr->Render(); return; } + std::vector> added; // 本次加入的 actor,供淡入 const std::string id = currentDsId->toStdString(); if (*detailMode == DetailMode::Section18) { // 网格数据:#18 banded 等值面(+「显示等值线」时叠黑色等值线),纵向夸张 1.5x(沿 y)。 @@ -435,10 +598,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (actors.bands) { actors.bands->SetScale(1.0, kVerticalExaggeration, 1.0); detailRendererPtr->AddViewProp(actors.bands); + added.push_back(actors.bands); } if (actors.edges && *showContour) { actors.edges->SetScale(1.0, kVerticalExaggeration, 1.0); detailRendererPtr->AddViewProp(actors.edges); + added.push_back(actors.edges); } // 顶部电极标记 ▼(仅网格数据;同纵向夸张对齐)。 if (*showElectrodes) { @@ -446,6 +611,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (elec) { elec->SetScale(1.0, kVerticalExaggeration, 1.0); detailRendererPtr->AddViewProp(elec); + added.push_back(elec); } } } else { @@ -456,6 +622,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (a) { a->SetScale(1.0, kVerticalExaggeration, 1.0); detailRendererPtr->AddViewProp(a); + added.push_back(a); } } // 异常叠加(与剖面同坐标系/同纵向夸张)。逐异常构建以按列表显隐(下标=原 vector 序)过滤。 @@ -466,12 +633,26 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re for (auto& act : geopro::render::buildAnomalies({anomalies[i]})) { act->SetScale(1.0, kVerticalExaggeration, 1.0); detailRendererPtr->AddViewProp(act); + added.push_back(act); } } } geopro::render::applyTop2D(detailRendererPtr); detailRendererPtr->ResetCamera(); - detailRenderWindowPtr->Render(); + *prevDsId = *currentDsId; + + if (animate) { + // 目标位姿快照 → 相机回退到旧位姿 + actors 透明 → 补间到目标并淡入。 + auto toCam = vtkSmartPointer::New(); + toCam->DeepCopy(detailRendererPtr->GetActiveCamera()); + for (const auto& a : added) a->GetProperty()->SetOpacity(0.0); + detailRendererPtr->GetActiveCamera()->DeepCopy(fromCam); + detailRendererPtr->ResetCameraClippingRange(); + animateReveal(detailRendererPtr, detailRenderWindowPtr, fromCam, toCam, added, 450, + detailWidget); + } else { + detailRenderWindowPtr->Render(); + } }; // 加载某数据集到「数据详情 + 异常列表 + 属性」(数据列表单击与启动默认共用)。 @@ -503,9 +684,14 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re .arg(name).arg(g.nx()).arg(g.ny()).arg(g.vmin).arg(g.vmax) .arg(anomalies.size())); }; - (void)loadDataset; // 暂未触发:保留待下一轮真实 DS 详情渲染复用 + // 暂未触发:保留待下一轮真实 DS 详情渲染复用。 + // TODO(overdrive-A 依赖):把下面数据集单击处理改调 loadDataset(dsId, name) 接通真实详情 + // 渲染后,rebuildDetail 里已就绪的“相机补间 + actor 淡入”揭示动画会在切换数据集时自动激活 + // (见 rebuildDetail 的 animate 分支与 animateReveal)。在此之前该动画为休眠态、不可见。 + (void)loadDataset; // ── 单击左下数据列表的采集批次(DS) → 占位(真实剖面/反演渲染下一阶段接 dd 接口)── + // 接 dd 那轮:把本处占位改为 loadDataset(id, name) 即接通详情渲染,并自动激活 overdrive-A 揭示动画。 QObject::connect(datasetList, &QListWidget::itemClicked, datasetList, [propLabel, detailRendererPtr, detailRenderWindowPtr, &nav](QListWidgetItem* item) { if (item->data(geopro::app::kDsLoadMoreRole).toBool()) { @@ -713,8 +899,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (stage == QStringLiteral("structure") || stage == QStringLiteral("projects")) objectTree->showMessage(QStringLiteral("加载失败:%1").arg(msg)); - window.statusBar()->showMessage( - QStringLiteral("加载失败(%1):%2").arg(stage, msg), 8000); + // 状态栏错误反馈:临时染 danger 红,8s 后随消息超时还原全局中性色。 + auto* sb = window.statusBar(); + sb->setStyleSheet(QStringLiteral("QStatusBar{color:%1;}") + .arg(QString::fromUtf8(geopro::app::semantic::kDanger))); + sb->showMessage(QStringLiteral("加载失败(%1):%2").arg(stage, msg), 8000); + QTimer::singleShot(8000, sb, [sb]() { sb->setStyleSheet(QString()); }); }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::busyChanged, &window, [](bool busy) { @@ -738,7 +928,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re 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); + if (!dockState.isEmpty()) { + dockManager->restoreState(dockState); + // restoreState 重建停靠区会重新显示 ADS 标题栏,再隐藏一次保持“无双标题”。 + hideDockTitleBars(); + } } // 退出时保存当前布局与几何(aboutToQuit 早于 window 析构,dockManager/window 仍存活)。 QObject::connect(qApp, &QCoreApplication::aboutToQuit, dockManager, [dockManager, &window]() { diff --git a/src/app/panels/ObjectTreePanel.cpp b/src/app/panels/ObjectTreePanel.cpp index 4d14f23..2e330d4 100644 --- a/src/app/panels/ObjectTreePanel.cpp +++ b/src/app/panels/ObjectTreePanel.cpp @@ -50,7 +50,7 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { } lay->addWidget(tree_, 1); - hint_ = new QLabel(QStringLiteral("(加载中…)"), this); + hint_ = new QLabel(QStringLiteral("正在加载对象…"), this); hint_->setAlignment(Qt::AlignCenter); hint_->setStyleSheet(QStringLiteral("color:#9AA6B6; padding:16px;")); hint_->setVisible(false);