用Qt纯代码去模仿WPS登录界面,听起来像是自己给自己找活干,但真把它做完,你会发现自己把Qt的窗口体系、QSS样式、事件循环、自绘控件这几块硬骨头全啃了一遍。WPS登录窗口本身并不复杂:一个居中的圆角卡片,左边品牌区右边表单区,带几个输入框和一颗主按钮,但要还原出那种“简洁但不廉价”的质感,光靠拖几个控件是凑不出来的,必须在代码里自己拼布局、自己画阴影、自己处理焦点和状态切换。这篇文章我按自己实际做过的路线,从窗口骨架到交互细节完整拆一遍,顺便把踩过的坑和解决思路都记录下来,适合想做登录页、想学Qt自绘界面、或者想在老项目里替换皮肤的朋友。
1. 先分析WPS登录界面:看着简单,细节不少
1.1 界面结构拆解
WPS登录窗口通常是一个固定尺寸的居中窗口,宽度在960左右,高度500多,四个角有比较明显的圆角,背景不是单调白,而是带一层从浅蓝到白的渐变或类似的衬托。真正的主体是中间那张“卡片”,卡片左上角是产品Logo和宣传语,右侧是登录表单:账号输入框、密码输入框、登录按钮、注册入口和找回密码链接。从视觉层级上看,卡片内部又分成几层:顶部Logo、中间表单、底部辅助入口。这种左右分栏式布局在现在的办公软件里很常见,好处是左边放品牌或营销图,右边专注登录操作,互不干扰。
从技术还原的角度,我们不需要100%像素级复刻,但要抓住几个关键特征:圆角卡片的投影、输入框下面的细线而不是全边框、主按钮的渐变和圆角、文字之间的留白。抓住这几点,整体观感就八九不离十了。我一开始试图用截图工具把每个像素间距都量出来,后来发现没意义,因为不同系统、不同DPI缩放比例下,同样的数值会呈现完全不同的效果,还不如按原则去推敲:大圆角、细分割线、弱阴影,这三种元素组合起来就是“现代办公软件”的视觉语言。
1.2 为什么要用纯代码实现
很多人拿到这个需求会先想到QML,QML做圆角、阴影、动画简直是天生优势,用Qt Quick写一个登录页可能半个小时就搞定了。但纯C++与QSS这条路也有不可替代的场景:一是老项目维护,很多Windows桌面应用还是Qt Widgets体系,没法引入QML;二是代码可读性和可复用性,ui文件一多就乱,纯代码可以直接把布局和样式类封装起来,后续换主题只要改QSS;三是如果你要做一个皮肤引擎,纯代码是绕不开的地基。
纯代码最大的代价是调试布局比较费眼睛,控件多了以后对齐是噩梦。我的处理办法是:把固定尺寸、边距、间距提前定义成常量,尽量少用绝对坐标setGeometry,用布局器加弹性因子组合,界面一改就能自适应。这个项目里的登录窗口是固定尺寸的,因为它是模态窗口,用户不需要拉伸,这时候用QHBoxLayout+QVBoxLayout嵌套,比用绝对坐标写死要清晰得多,后面想调整左侧品牌区宽度时,只改一个stretch系数就行。
2. 搭建窗口骨架:无边框、圆角、阴影
2.1 开发环境与工程初始化
我用的是Qt 5.15.2加MinGW 64位,Qt 6也完全兼容,只是QSS里个别伪状态写法有点差异。工程只需要widgets模块,可以顺便把network模块加上,后面模拟登录请求会用到。main函数里记得开高分屏:
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps); QApplication app(argc, argv);这两个属性必须在创建QApplication之前设置。如果不想麻烦,可以直接用Qt 6,默认就是高分屏感知。字体方面我用“Microsoft YaHei UI”,在Windows下这套字体的中英文搭配比较干净,行距也舒服。工程管理文件里,我用了一个最简单的方式:
QT += widgets network CONFIG += c++17 SOURCES += main.cpp LoginWindow.cpp HEADERS += LoginWindow.h RESOURCES += resources.qrc注意:如果你的Qt是5.15老版本,编译时出现QString::fromStdString相关的错误,那多半是编译器C++标准没切对,Qt 5.15默认还是C++98风格的头文件,加了c++17之后要重新跑一遍qmake,不要只点构建。
2.2 定义登录窗口类与无边框设置
我这里的登录窗口是一个QDialog,因为登录页天然是模态窗口,调用方直接用exec()就能拿到Accepted状态,非常合适。窗口类定义如下:
class LoginWindow : public QDialog { Q_OBJECT public: explicit LoginWindow(QWidget *parent = nullptr); protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; private slots: void updateLoginState(); void onLoginClicked(); void startCountdown(); private: QWidget *centralWidget; QLineEdit *accountEdit; QLineEdit *passwordEdit; QPushButton *loginBtn; QPushButton *codeBtn; QAction *m_pwdAction; bool m_passwordVisible = false; int m_leftSeconds = 60; QPoint m_dragPos; };模仿WPS登录界面的第一步是去掉系统标题栏。做法是:
setWindowFlags(Qt::Window | Qt::FramelessWindowHint);这里有个细节:设置窗口标志时不要去掉Qt::Window,否则窗口不会出现在任务栏;如果用Qt::Dialog又会有默认的边框行为。去掉系统边框之后,窗口默认是直角矩形,需要自己画圆角。接着设置透明背景:
setAttribute(Qt::WA_TranslucentBackground);这样整个窗口的背景是透明的,我可以在paintEvent里自由绘制阴影和圆角。这个属性在Windows上和老显卡驱动偶尔会出兼容问题,如果你看到窗口背景花屏,可以在初始化之后强制调用update()刷新一次。
2.3 绘制圆角与阴影
核心就在paintEvent。窗口本身相当于一张透明画布,先画一圈半透明阴影,再画里面的圆角矩形。为了不遮挡内部内容,我给窗口外围留出20像素阴影区:
void LoginWindow::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); QRect shadowRect = rect().marginsRemoved(QMargins(10, 10, 10, 10)); for (int i = 0; i < 10; ++i) { QColor c(0, 0, 0, 6 * (i + 1)); p.setPen(c); p.drawRoundedRect(shadowRect.adjusted(-i, -i, i, i), 12 + i, 12 + i); } QRect cardRect = rect().adjusted(10, 10, -10, -10); p.setPen(Qt::NoPen); p.setBrush(QColor("#ffffff")); p.drawRoundedRect(cardRect, 10, 10); }阴影用一层层叠加画出来的效果比较“软”,性能也没有问题。如果想要更真实的模糊阴影,也可以先把窗口截图绘到一张半透明位图里,用QGraphicsBlurEffect做模糊,再合成,但代码量会变大,我这里就没用。
圆角卡片画好之后,需要把内部控件都放到一个中心容器里:
centralWidget = new QWidget(this); centralWidget->setGeometry(10, 10, width() - 20, height() - 20);这样内容的左上角正好落在卡片圆角的起点,不会把阴影盖住。后续所有布局都建立在centralWidget上,窗口本身只负责画背景和阴影。
3. 登录卡片布局:用QSS还原视觉细节
3.1 左右分区布局设计
整体布局我用QHBoxLayout分成两侧:左侧品牌区占40%,右侧表单区占60%,中间留18像素间距。为了布局灵活,我直接把它写成一个内部函数buildUI()。左侧放一个QLabel显示Logo和一句话,右侧用一个QVBoxLayout放表单控件,底部加stretch来控制垂直位置,让表单大体居中偏上,这样视觉会跟WPS的透气感接近。
固定窗口尺寸:resize(960, 560)。登录页面本来就不该被随意拉伸,所以我在外层容器上setFixedSize,四个方向的自适应都不需要。这样也能最大程度保证QSS排版效果一致,避免按钮被拉伸变形。左侧品牌区的文字可以这样布局:
QHBoxLayout *mainLayout = new QHBoxLayout(centralWidget); mainLayout->setContentsMargins(40, 30, 40, 30); mainLayout->setSpacing(0); QLabel *brandLabel = new QLabel(centralWidget); brandLabel->setObjectName("brandLabel"); brandLabel->setText(QStringLiteral("让登录像呼吸一样自然")); brandLabel->setAlignment(Qt::AlignCenter); mainLayout->addWidget(brandLabel, 2); QWidget *formWidget = new QWidget(centralWidget); QVBoxLayout *formLayout = new QVBoxLayout(formWidget); formLayout->setContentsMargins(20, 0, 20, 0); formLayout->setSpacing(18); mainLayout->addWidget(formWidget, 3);这里左侧品牌区和右侧表单区按2:3分配宽度,WPS实际更接近1:1,但2:3会让表单区更舒展,输入框足够宽,视觉重心稳定。
3.2 输入框QSS细节:下边框线方案
WPS登录框的输入框没有全边框,而是只有一条1像素的下画线。QSS写成这样:
QLineEdit#accountEdit, QLineEdit#passwordEdit { background: transparent; border: none; border-bottom: 1px solid #d9d9d9; padding: 8px 2px; font-size: 14px; color: #333333; selection-background-color: #3d7eff; selection-color: white; } QLineEdit#accountEdit:focus, QLineEdit#passwordEdit:focus { border-bottom-color: #3d7eff; }这里有两个常见坑。第一个是必须先写border: none,否则不同平台上QLineEdit默认边框可能会叠加出奇怪的双线。第二个是圆角:如果只给输入框加border-bottom,又想底边是圆角,光靠border-bottom-radius是不行的,必须同时设border: none,然后手动设置border-bottom-left-radius和border-bottom-right-radius,浏览器里那套逻辑在QSS里同样适用。
输入框之间的间距我控制在24像素,太高会割裂表单整体感,太低又显得拥挤。占位符颜色可以通过QSS的placeholder-text-color控制,不过我试过不同Qt版本对这个属性的解析不太一致,稳妥的做法是直接setPlaceholderText,再在QSS里用color控制,也能起到类似效果。
3.3 密码可见性切换
QLineEdit本身没有显示密码明文的功能,但可以用addAction方法:
m_pwdAction = passwordEdit->addAction(QIcon(":/icons/eye_off.png"), QLineEdit::TrailingPosition); connect(m_pwdAction, &QAction::triggered, this, [this]() { m_passwordVisible = !m_passwordVisible; passwordEdit->setEchoMode(m_passwordVisible ? QLineEdit::Normal : QLineEdit::Password); });需要注意:addAction生成的图标是静态的,想模拟“点击后眼睛变成斜杠”的状态,最好准备两张图标,根据状态用setIcon切换。另外,当EchoMode从Password切到Normal时,密码框的输入字体不会变,但宽度可能因为系统绘制方式的差异而出现一两个像素的跳动,可以把输入框的固定高度稍微设大一点,避免切来切去视觉上闪动。
3.4 主按钮三态与焦点策略
登录按钮我用QPushButton,固定高度44,圆角22,右侧可以加一行“忘记密码”链接。QSS如下:
QPushButton#loginBtn { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #2c7eff, stop:1 #4b9aff); border: none; border-radius: 22px; color: white; font-size: 16px; font-weight: bold; padding: 10px 0; } QPushButton#loginBtn:hover { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #1e6eff, stop:1 #3d8aff); } QPushButton#loginBtn:pressed { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #1860e0, stop:1 #2a76ea); } QPushButton#loginBtn:disabled { background: #a0c6ff; color: #e6f2ff; }这里要说明QSS和传统CSS的一个差异:QSS不支持transition,所以hover渐变变化是瞬间切换,看起来有点生硬。想平滑过渡只能自己写QPropertyAnimation去插值背景色,或者用预先准备好的三张图做background-image。对于登录界面来说,瞬间切换反而有种“干脆”的感觉,我就没做动画。
按钮的disabled状态很重要,我把“登录中”状态下按钮禁用,此时背景变灰蓝,能防止用户重复提交。还要记得给登录按钮设置setDefault(true),这样用户在密码框里按回车键就能触发登录,不需要鼠标点一下。
loginBtn->setDefault(true); loginBtn->setEnabled(false); // 初始状态,输入框为空时不可点4. 交互逻辑与状态切换
4.1 窗口拖拽与鼠标坐标处理
去掉系统边框后,窗口默认是没法拖动的。标准的做法是重写mousePressEvent和mouseMoveEvent,记录窗口左上角与鼠标位置之间的差值:
void LoginWindow::mousePressEvent(QMouseEvent *e) { if (e->button() == Qt::LeftButton && e->pos().y() <= 80) { m_dragPos = e->globalPos() - frameGeometry().topLeft(); e->accept(); } } void LoginWindow::mouseMoveEvent(QMouseEvent *e) { if (e->buttons() & Qt::LeftButton && !m_dragPos.isNull()) { move(e->globalPos() - m_dragPos); e->accept(); } } void LoginWindow::mouseReleaseEvent(QMouseEvent *e) { m_dragPos = QPoint(); }这里有个大坑:如果整个登录窗口都允许拖动,用户拖输入框时也会误移动窗口。我的做法是在标题区域(可以是一块完全透明的QLabel或QWidget)上安装鼠标事件过滤器,只让这块区域触发拖动,表单区完全不受影响。双击标题区域时还可以做最大化/还原的切换,不过WPS登录窗口一般不允许最大化,我最后没有加。
4.2 输入合法性与登录按钮联动
登录表单一般要求输入框不能为空,并且密码不能太短。我用了textChanged信号去更新按钮状态:
connect(accountEdit, &QLineEdit::textChanged, this, &LoginWindow::updateLoginState); connect(passwordEdit, &QLineEdit::textChanged, this, &LoginWindow::updateLoginState); void LoginWindow::updateLoginState() { bool ok = !accountEdit->text().trimmed().isEmpty() && passwordEdit->text().size() >= 6; loginBtn->setEnabled(ok); }一开始我要求密码至少6位,但实际测试发现某些账号可能只有4位密码,最后把规则放宽成“非空即可”,真正的格式校验交给后端。这里用textChanged而不是editingFinished,因为用户在输入过程中就应该看到按钮变为可点,而不是等焦点离开才刷新。
4.3 模拟真实登录状态
纯前端项目没有后端接口,我用一个QTimer模拟请求:
void LoginWindow::onLoginClicked() { loginBtn->setEnabled(false); loginBtn->setText(QStringLiteral("登录中...")); accountEdit->setEnabled(false); passwordEdit->setEnabled(false); QTimer::singleShot(1200, this, [this]() { QMessageBox::information(this, tr("提示"), tr("登录成功")); accept(); }); }这个模拟流程虽然假,但逻辑结构能直接套到真实网络请求上:按钮进入loading状态、输入框只读、等网络返回后恢复状态或关窗。真实项目里记得把QTimer换成继承QNetworkAccessManager的请求类。另外,模拟登录期间如果用户点右上角关闭按钮,窗口关闭但定时器还挂着,有可能在窗口销毁后回调导致崩溃,最好在closeEvent里停掉定时器。
4.4 验证码倒计时与扫码切换
WPS登录界面除账号密码外还有扫码登录入口。如果要模仿得完整,可以加一个Tab切换按钮,点击后右侧表单变成二维码,左侧保持不变。二维码我没有真实图片,就用一个800x800的棋盘格贴图占位,视觉上倒也有模有样。验证码按钮的倒计时是典型的“抢手”逻辑:
void LoginWindow::startCountdown() { m_leftSeconds = 60; codeBtn->setEnabled(false); auto timer = new QTimer(this); connect(timer, &QTimer::timeout, this, [this, timer]() { if (--m_leftSeconds <= 0) { timer->stop(); timer->deleteLater(); codeBtn->setEnabled(true); codeBtn->setText(QStringLiteral("获取验证码")); } else { codeBtn->setText(QString::number(m_leftSeconds) + QStringLiteral("秒后重新获取")); } }); timer->start(1000); }这里有个细节:为了让按钮文字不跳来跳去,我在初始文本里人为给“获取验证码”后面补了一个空格,等于把宽度先占住了,倒计时开始后界面的整体宽度不会变化。
5. 常见问题与踩坑记录
5.1 无边框窗口拖动失灵
这个问题我遇到两次。第一次是只重写了mousePressEvent,忘了在mouseMoveEvent里检查按钮状态,结果只有按下瞬间触发一次移动,松手后不管怎么拖都没反应。第二次是判空逻辑写得不对,把m_dragPos是否有效和鼠标在标题栏内两个条件混在一起,导致在表单区按下时,带着0,0偏移把窗口瞬间移到了左上角。规范写法应当用一个bool变量m_dragging,按下合法时置true并记录偏移,移动时先判断m_dragging再move,释放时置false,这样最不容易漏。
5.2 QSS不生效和边框叠加
不是所有QSS选择器都对所有控件生效。QLineEdit的border需要同时设border:none;QCheckBox的指示器需要明确在setStyleSheet里写::indicator;QPushButton的padding如果没写,文字会跟图标挤在一起。另外,如果发现某些QSS属性在Qt 6下不生效,优先检查是不是控件没有设置objectName,我习惯在每个子控件创建后立刻setObjectName,这是一个不费事却避免大量排查的好习惯。
这里也顺带提一句运行时动态修改QSS:如果你想在登录失败时把输入框边框变成红色,最干净的方法是给输入框动态设置一个属性:
accountEdit->setProperty("error", true); accountEdit->style()->unpolish(accountEdit); accountEdit->style()->polish(accountEdit);然后再在QSS里写:
QLineEdit#accountEdit[error="true"] { border-bottom-color: #e54545; }手动unpolish/polish是必须的,否则样式不会刷新。
5.3 透明窗口的高分屏发虚
如果不开AA_EnableHighDpiScaling,整个登录界面在2K屏幕上会像一张被放大的模糊图。开启以后,如果资源图片还没有二倍图,右侧Logo和二维码图又会发虚。解决办法是:矢量图优先用SVG,Qt的QtSvg模块能直接加载;位图图标则提供2x/3x倍图,在QSS里通过border-image不会自动换图,需要自己根据devicePixelRatio选择资源。我自己偷懒,直接把Logo用字体图标代替,把几个关键字形打包成ttf,这样无论怎么缩放都不会糊。
5.4 输入法状态下窗口跳动
Windows上中文输入法弹出时,部分输入法会调整窗口位置,去掉系统边框后尤其明显。我试过两种方案:一是给输入框设置setAttribute(Qt::WA_InputMethodEnabled);二是重新实现inputMethodEvent,主动控制候选窗口位置。对于登录页这种固定小窗口,最简单的办法是保证窗口不要位于屏幕边缘,并且在登录期间禁止移动窗口,基本能绕过。如果你在macOS上开发,这个现象基本不存在。
5.5 登录成功后窗口淡出
关闭窗口时如果直接accept(),界面瞬间消失,视觉比较生硬。我做了个简单的淡出动画:
void LoginWindow::fadeOutClose() { auto animation = new QPropertyAnimation(this, "windowOpacity"); animation->setDuration(300); animation->setStartValue(1.0); animation->setEndValue(0.0); connect(animation, &QPropertyAnimation::finished, this, &QWidget::close); animation->start(QAbstractAnimation::DeleteWhenStopped); }必须注意的是,这个动画要求窗口本身不是Qt::X11BypassWindowManagerHint,而且窗口样式表的背景不能完全遮挡,否则透明度效果只对系统边框生效,自绘内容不受影响。我在测试时发现登录窗口的阴影部分会跟着一起变淡,视觉效果还挺顺滑的。
6. 这套界面的扩展空间
6.1 从模仿到改造成自有项目
到了这个节点,你已经得到一个可以直接运行的模仿登录页。把它往自己项目里迁移时,只需要改三样东西:主题色、表单字段、左侧品牌图。比如我的一个工具软件里的登录页,就把左侧品牌文字换成了版本号和免责声明,右侧增加了一个“记住密码”的QCheckBox,整体只需要动QSS和两个控件,其余逻辑全部复用。
如果登录页需要支持“记住密码”和“自动登录”,可以用QSettings读写密码文本,注意要加密存储,直接用明文放在配置文件里非常危险。我一般用简单的异或加Base64做混淆,或者直接调用系统级的凭据管理器。还有一点:不要把你的私有密钥硬编码在发布程序里,至少也做成可替换的插件形式。
6.2 可以继续深挖的几个方向
接下来可以做的事还有很多。第一是把这套界面做成可配置皮肤,把颜色、圆角、间距都抽成独立的皮肤文件,换肤就是换一个文件。第二是加入真实网络请求,把onLoginClicked里面的QTimer换成异步网络回调,注意处理失败时的恢复状态。第三是加入多语言,文本全部用tr()包起来,做国际化时只需要加载qm文件。
我在做这个项目的过程中最深的一点体会是,Qt Widgets并不是有些人说的“写界面丑”,它只是需要你多花一点心思去雕刻QSS和事件细节。WPS这种大厂界面并不是用了什么神秘技术,都是最基础的控件与样式组合,关键在布局的取舍和状态的精细管理。如果你也想练手,建议不要从零画像素,先去复刻一个你熟悉的界面,等到做完了再回头看,你对Qt的事件分发和样式表理解会完全不一样。