feat(ui): impeccable 设计令牌体系 + 空状态/语义色/动效 + dock 标题修复

- typeset: Theme.hpp 新增排版令牌(type::),统一各处散落字号/字重
- layout: 间距/圆角令牌(space::/radius::),圆角 6 档→2 档,手调奇数余白对称化
- delight: 中央空状态引导浮层、上下文化加载文案、登录错误淡入
- colorize: 语义色令牌(semantic::),项目状态着色、状态栏错误染色、异常徽标警示色(休眠)
- overdrive(休眠): 详情视图相机补间+actor淡入(animateReveal),待 dd 详情渲染接通后激活
- fix(dock): restoreState 后重新隐藏 ADS 子窗口标题栏,修复已保存布局下标题栏复现
This commit is contained in:
gaozheng 2026-06-09 20:26:00 +08:00
parent caf6f9ebd0
commit 1a9fb72cf0
8 changed files with 432 additions and 89 deletions

View File

@ -1,5 +1,7 @@
#include "PanelHeader.hpp"
#include "Theme.hpp"
#include <QButtonGroup>
#include <QColor>
#include <QHBoxLayout>
@ -20,19 +22,33 @@ constexpr int kTitleIcon = 20; // 表头标题图标
constexpr int kActionIcon = 19; // 表头操作按钮图标
constexpr int kTabIcon = 19; // Tab 图标
// 表头统一样式(标准表头 + Tab 表头共用)。
const char* kHeaderQss =
// 表头统一样式(标准表头 + 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:14px; font-weight:600; }"
"#panelTitle { color:#1F2A3D; font-size:%1px; font-weight:%4; }"
"#panelBadge { background:#EAEEF5; color:#5A6B85; border-radius:9px;"
" padding:1px 7px; font-size:12px; font-weight:600; }"
" 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:14px; }"
" padding:8px 4px; font-size:%3px; }"
"QToolButton#tabBtn:hover { color:#1F2A3D; }"
"QToolButton#tabBtn:checked { color:#1F2A3D; font-weight:600;"
" border-bottom:2px solid #2D6CB5; }";
"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<Header
auto* header = new QWidget();
header->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<PanelTab>& tabs, const QVector<Header
auto* header = new QWidget(box);
header->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);

View File

@ -3,6 +3,7 @@
#include <QAbstractItemView>
#include <QColor>
#include <QComboBox>
#include <QFont>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
@ -13,6 +14,8 @@
#include <QTableWidgetItem>
#include <QVBoxLayout>
#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));

View File

@ -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));
}

View File

@ -13,6 +13,73 @@ class QApplication;
namespace geopro::app {
// ── 排版令牌(全项目唯一字号阶 + 字重角色)──────────────────────────
// 各处 QSS 的 font-size / font-weight 一律引用这些值,不再散落硬编码 px。
// 阶比 ~1.18body→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);

View File

@ -14,6 +14,7 @@
#include <QWidget>
#include "Glyphs.hpp"
#include "Theme.hpp"
namespace geopro::app {
@ -117,9 +118,10 @@ QWidget* buildMenuBar(QWidget* parent)
// 自带样式(覆盖全局),加大字号/内边距,专业观感。
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 { padding:7px 14px; border-radius:6px; font-size:%1px; color:#1F2A3D; }"
"#appMenuBar::item:selected { background:#EAF1FB; color:#2D6CB5; }"
"#appMenuBar::item:pressed { background:#DCE6F4; }"));
"#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; }"
" 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:700;"
" font-size:13px; }"
"#userName { color:#1F2A3D; font-size:13px; font-weight:600; }"
"#userRole { color:#8A93A3; font-size:11px; }"));
"#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_);

View File

@ -2,19 +2,23 @@
#include <QCheckBox>
#include <QColor>
#include <QEasingCurve>
#include <QFont>
#include <QGraphicsOpacityEffect>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPainter>
#include <QPen>
#include <QPixmap>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QRandomGenerator>
#include <QVBoxLayout>
#include <QWidget>
#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; }"
"#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; }"));
"#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)
// 记住登录:勾选后成功登录将安全存储 token30 天内免登录。默认不勾(更安全)。
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_);
@ -196,10 +209,12 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
loginBtn_->setCursor(Qt::PointingHandCursor);
loginBtn_->setStyleSheet(QStringLiteral(
"QPushButton { background: #2D6CB5; color: #FFFFFF; border: none; border-radius: 8px; "
"font-size: 15px; font-weight: 600; }"
"font-size: %1px; font-weight: %2; }"
"QPushButton:hover { background: #2862A6; }"
"QPushButton:pressed { background: #234F87; }"
"QPushButton:disabled { background: #9FB4CC; }"));
"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);
// 错误淡入:柔化失败时刻(仅透明度 200mserrorLabel_ 已预留固定高度,
// 不引发布局跳动)。复用同一 opacity effect重复报错每次重新淡入。
auto* fx = qobject_cast<QGraphicsOpacityEffect*>(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

View File

@ -28,18 +28,25 @@
#include <QCheckBox>
#include <QColor>
#include <QDialog>
#include <QEasingCurve>
#include <QEvent>
#include <QFile>
#include <QFrame>
#include <QGraphicsOpacityEffect>
#include <QLabel>
#include <QListWidget>
#include <QListWidgetItem>
#include <QSettings>
#include <QSignalBlocker>
#include <QPropertyAnimation>
#include <QVariantAnimation>
#include <QStringList>
#include <QTabWidget>
#include <QMainWindow>
#include <QStatusBar>
#include <QStyle>
#include <QSurfaceFormat>
#include <QTimer>
#include <QToolBar>
#include <QTreeWidget>
#include <QTreeWidgetItem>
@ -93,15 +100,90 @@
#include <vector>
#include <QVTKOpenGLStereoWidget.h>
#include <vtkActor.h>
#include <vtkCamera.h>
#include <vtkCameraInterpolator.h>
#include <vtkGenericOpenGLRenderWindow.h>
#include <vtkImagePlaneWidget.h>
#include <vtkLookupTable.h>
#include <vtkProperty.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h>
#include <vtkSmartPointer.h>
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<vtkCamera> fromCam, vtkSmartPointer<vtkCamera> toCam,
std::vector<vtkSmartPointer<vtkActor>> actors, int durationMs, QObject* owner)
{
auto interp = vtkSmartPointer<vtkCameraInterpolator>::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 不再自动改写标题栏可见性,手动隐藏可稳定保持。
// 抽成 lambdaADS 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<bool>(true); // 默认显示电极 ▼
auto showContour = std::make_shared<bool>(true); // 默认显示等值线
auto hiddenAnoms = std::make_shared<std::set<int>>(); // 异常列表中被取消勾选(隐藏)的异常下标
auto prevDsId = std::make_shared<QString>(); // 上次渲染的 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<vtkCamera>::New();
fromCam->DeepCopy(detailRendererPtr->GetActiveCamera());
detailRendererPtr->RemoveAllViewProps();
if (currentDsId->isEmpty()) { // 未选数据集:清空即可
*prevDsId = *currentDsId;
detailRenderWindowPtr->Render();
return;
}
std::vector<vtkSmartPointer<vtkActor>> 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();
*prevDsId = *currentDsId;
if (animate) {
// 目标位姿快照 → 相机回退到旧位姿 + actors 透明 → 补间到目标并淡入。
auto toCam = vtkSmartPointer<vtkCamera>::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]() {

View File

@ -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);