现在做桌面端的同学,十个里有八个第一个像样的界面就是登录页。看起来简单:两个输入框、一个按钮、一个勾选框。可真动手写,你会发现它把 Qt 里最容易踩的几块内容全串起来了——布局、样式表、信号槽、焦点管理、事件过滤、配置文件、甚至连数据库线程都躲不掉。这篇就把我自己做过的几版登录页从零到能打包发布的全过程摊开讲,包含控件怎么摆、QSS 怎么写才能生效、回车登录怎么接、密码怎么存、"记住我"该存什么不该存什么,以及那些文档里不会写但一定会让你卡半天的坑。适合刚学完 Qt 基础、想做一个"能拿得出手"的登录界面的同学,也适合做了几年但界面一直灰扑扑、想补一补样式和交互细节的开发者。下面所有代码基于 Qt 5.15 + C++,Qt 6 下有差异的地方我会单独点出来。
1. 从一张草图到能跑的窗口:登录页要先拆成四层
1.1 别急着拖控件,先把界面拆成"容器—输入—动作—反馈"
很多人拿到登录页的需求,第一反应是打开 Designer 往上拖 QLineEdit 和 QPushButton,拖完发现想加个圆角卡片背景、想在输入框里塞个小图标,就开始一层一层套 QWidget,最后层级乱成一团,改个间距要动五六个地方。问题出在没先分层。
我习惯把登录页固定拆成四层,从外到内:
- 页面层:整个窗口,负责背景(纯色、渐变或者一张大图)、窗口尺寸和居中逻辑。
- 卡片层:一块带圆角和阴影的容器,里面装所有交互控件。它的作用是视觉聚焦,让用户一眼知道"要操作的就是这一块"。
- 输入层:账号框、密码框、以及可选验证码框。它们负责收集和初步过滤用户输入。
- 动作与反馈层:登录按钮、记住我勾选框、忘记密码链接、以及一行错误提示文字。
这样拆的好处是,QSS 写起来是干净的。页面层管背景,卡片层管圆角和边距,输入层管边框和聚焦态,动作层管按钮三态。层级一清楚,样式表基本一次就能写对,不用反复试选择器。
具体尺寸上我用的是 8 像素栅格:卡片内边距 32,控件之间垂直间距 16,输入框高度 44,按钮高度 44,圆角 8。这几个数不是随便定的,44 是手指和鼠标都舒服的最小点击高度,8 的倍数是为了在不同缩放比例下不出现半像素模糊。
1.2 用 Designer 搭骨架,还是纯代码写?我选了混合方案
这个问题吵了很多年。我的结论是:静态结构用 Designer,动态行为和细节用代码。
Designer 的优势是所见即所得,摆四个控件比写四十行布局代码快得多,而且它生成的 .ui 文件在编译期会被 uic 转成 C++,运行时几乎没有额外开销。但它的劣势也很明显:QSS 里引用资源图片要写全路径、动态创建的子控件没法可视化、以及无边框窗口这种需要在构造函数里改窗口标志的东西它管不了。
所以我通常的流程是:
- 在 Designer 里拖出主窗口、卡片容器、两个 QLineEdit、两个 QPushButton(登录、显示密码)、一个 QCheckBox、一个 QLabel(错误提示)。
- 在 Designer 的样式编辑器里先贴一版基础 QSS,保证预览能看到大概样子。
- 编译一次,把真正的样式表挪到独立的 .qss 文件,通过资源系统加载。
- 剩下的逻辑,包括回车触发、密码回显切换、异步校验,全部在 C++ 里写。
这里有个容易被忽略的点:.qss 文件一定要放进 .qrc 资源里,不要用相对路径去读磁盘文件。你用QFile("style.qss")在开发机跑得好好的,打包发布之后路径一变,界面立刻变回系统默认样式,而且因为加载失败通常只是静默返回空字符串,你不会收到任何报错。
// main.cpp 里加载全局样式 QFile qssFile(":/style/app.qss"); if (qssFile.open(QFile::ReadOnly | QFile::Text)) { qApp->setStyleSheet(QString::fromUtf8(qssFile.readAll())); qssFile.close(); }注意QString::fromUtf8这一步别省。QSS 里只要有中文注释或者中文字体名,用默认编码读进来就是乱码,样式表解析器碰到乱码会静默丢弃整条规则,表现就是"我明明写了但没生效"。
还有一个编译期的坑要先说清楚:如果你在 .pro 里没写QT += widgets,或者在 CMake 里漏了Widgets,编译会直接报unknown module(s) in qt: widgets。这个报错和热词里常见的unknown module(s) in qt: serialport是同一类问题——模块没装或者没在工程文件里声明。串口那个还多一种可能:Qt 5.15 的串口模块在安装时是可选组件,离线安装包里默认不勾,需要重新运行安装器补上。
2. 输入框的"手感"决定了登录页好不好用
2.1 密码回显切换:图标按钮和 QAction 的取舍
密码框默认是 QLineEdit 加上setEchoMode(QLineEdit::Password)。要加"小眼睛"切换,有两种常见做法。
做法一:在 QLineEdit 内部右侧嵌一个 QAction。QLineEdit 自带addAction接口,可以指定图标出现在左侧还是右侧,点击后触发triggered信号。好处是图标永远贴着输入框的右边缘,输入框宽度变化时跟着走,不用手动算位置。缺点是图标的绘制样式受 QAction 影响,你想在悬停时换图或者改透明度会比较别扭。
// 在构造函数里 ui->editPassword->setEchoMode(QLineEdit::Password); QAction *actEye = ui->editPassword->addAction( QIcon(":/icons/eye_off.svg"), QLineEdit::TrailingPosition); connect(actEye, &QAction::triggered, this, [this, actEye]{ const bool hidden = (ui->editPassword->echoMode() == QLineEdit::Password); ui->editPassword->setEchoMode(hidden ? QLineEdit::Normal : QLineEdit::Password); actEye->setIcon(QIcon(hidden ? ":/icons/eye_on.svg" : ":/icons/eye_off.svg")); });做法二:独立 QPushButton,用绝对定位或者覆盖布局。控制力更强,能自由写 QSS 的三态样式。代价是要自己处理 resize 事件重算位置,或者用 QHBoxLayout 把按钮塞进一个和输入框同高的容器里。
我一般选做法一,因为它零布局成本。但有个细节必须处理:切换回密码模式后,光标位置要保持不动。QLineEdit 在setEchoMode时部分版本会重置光标到末尾。稳妥的写法是先int pos = edit->cursorPosition();切换完再edit->setCursorPosition(pos);。
2.2 returnPressed:键盘回车直接登录的正确接法
用户输完密码顺手敲回车,这是肌肉记忆。不接这个信号,体验会立刻掉一档。
connect(ui->editPassword, &QLineEdit::returnPressed, this, &LoginDialog::onLoginClicked);两个注意点。第一,如果你用的是 QDialog 而不是 QWidget,不要在 QDialog 里同时接 returnPressed 和给某个按钮设setDefault(true)。QDialog 默认会把回车事件交给 default 按钮,同时又可能触发 QLineEdit 的 returnPressed,结果就是登录逻辑跑两遍,你会看到两次请求或者两次错误提示闪一下。二选一,我通常用 returnPressed,然后ui->btnLogin->setAutoDefault(false);。
第二,如果你在输入框上装了事件过滤器做特殊按键处理,记得在 filter 里对回车返回 false,让它继续往下传,否则你自己把事件吃掉,信号永远不发。
2.3 占位符、输入限制与中文输入法的那些毛病
占位符用setPlaceholderText("请输入手机号或邮箱")。看着简单,但配上 QSS 之后,占位符文字的颜色是通过placeholder-text-color或者color属性间接控制的,不同 Qt 版本表现略有差异,建议在样式表里显式指定一次。
输入长度限制用setMaxLength(32)。这里有个真实的坑:中文输入法在预编辑状态下,输入的字数可能超过 maxLength,导致用户正在拼的字被截断。更麻烦的是,如果同时用了setValidator配合正则做实时校验,预编辑的拼音串会直接触发校验失败,错误提示在用户还没输完就弹出来,非常难受。
我的处理办法是在校验前先判断输入法状态。Qt 提供了QInputMethod,更简单的做法是监听QLineEdit::inputRejected信号,只在真正被拒绝时才报错,而不是每次 textChanged 都校验一遍。
connect(ui->editAccount, &QLineEdit::inputRejected, this, [this]{ showTip("输入内容包含不支持的字符"); });另外,账号框我一般不加 QValidator,只在点登录时统一校验。实时校验看着高级,实际上在中文输入法环境下误报率很高,得不偿失。密码框就更不用加了,几乎所有字符都应该被接受。
3. QSS 把界面拉满:从灰扑扑到能发朋友圈
3.1 为什么你的 QSS 对 QLineEdit 不生效:选择器与继承机制
这是新手问得最多的问题:"我给 QLineEdit 写了样式,为什么没变?"
Qt 的样式表有三个反直觉的地方,必须搞清楚。
第一,子控件要用双冒号。QLineEdit 的边框其实是由内部子控件绘制的,很多效果必须写成QLineEdit:hover这种伪状态,或者对 QComboBox 用QComboBox::drop-down。写QComboBox drop-down是无效的。
第二,一旦给父控件设了带边框的样式,子控件可能被"接管"。最典型的是给 QWidget 设border: 1px solid #ccc;之后,它所有的子控件都会带上边框。原因是样式表会向下传播,而 QLineEdit 默认没有自己绘制边框的能力,就继承了父级的 border。解决办法是给子控件显式写border: none;,或者更干脆——不要给纯容器 QWidget 设边框,用一个 QFrame 包一层。
第三,自定义 QWidget 子类默认不吃样式表。如果你继承 QWidget 自己写了个卡片类,直接setStyleSheet("background: #fff;")是不生效的。必须在类里重写paintEvent,加上 QStyleOption 那几行样板代码:
void CardWidget::paintEvent(QPaintEvent *) { QStyleOption opt; opt.initFrom(this); QPainter p(this); style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this); // 之后再画自己的圆角背景 }这几行没有为什么可讲,不加就是不生效,Qt 官方文档里藏在"Custom Widgets and Painting"那节。
3.2 圆角、悬停、按下三态:按钮状态机怎么写
按钮的质感几乎全靠伪状态。我的模板大概是这样的:
QPushButton#btnLogin { min-height: 44px; border: none; border-radius: 8px; background-color: #2f6fed; color: #ffffff; font-size: 15px; letter-spacing: 2px; } QPushButton#btnLogin:hover { background-color: #3b7bf5; } QPushButton#btnLogin:pressed { background-color: #2559c4; } QPushButton#btnLogin:disabled { background-color: #b9c6dd; color: #eef2f8; }这里的关键是:disabled一定要写。登录按钮在请求发出后应该被禁用防止重复提交,如果不写 disabled 样式,按钮会变得和可点击状态一样,用户会以为没反应然后疯狂点击。
输入框的聚焦态同理:
QLineEdit#editAccount, QLineEdit#editPassword { min-height: 44px; padding: 0 12px; border: 1px solid #dfe3ea; border-radius: 8px; background-color: #fafbfd; selection-background-color: #2f6fed; } QLineEdit:focus { border: 1px solid #2f6fed; background-color: #ffffff; }padding里的水平值要留够,否则文字会贴在边框上,视觉上很挤。圆角半径不要超过高度的一半,44 的高度配 8 到 12 的圆角最稳,超过 22 之后圆角会看起来像胶囊,和输入框的矩形语义冲突。
3.3 高 DPI 与不同分辨率下的排版保命技巧
现在显示器缩放五花八门,125%、150%、175% 都有人用。Qt 5.15 里如果不做处理,你的 44 像素按钮在 150% 缩放下可能只有视觉上的 29 像素,字挤在一起。
在 main.cpp 最开头加上:
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);这必须在QApplication对象构造之前调用,写在后面无效。Qt 6 里AA_EnableHighDpiScaling已经默认开启并被标记废弃,写了会报警告,可以直接删掉,只保留AA_UseHighDpiPixmaps也基本不需要了。
图标资源尽量用 SVG。PNG 在高 DPI 下要么模糊要么要准备多套倍图,SVG 让 Qt 的渲染器自己缩放,省心得多。如果你的图标是彩色的、SVG 里带渐变,记得确认 Qt SVG 模块已安装(QT += svg),否则图标会渲染成空白。
还有一个和分辨率无关但很影响观感的问题:窗口居中。别用固定的move(500, 300),用:
const QRect screen = QApplication::primaryScreen()->availableGeometry(); move(screen.center() - rect().center());注意是availableGeometry不是geometry,前者会排除任务栏区域,否则在多任务栏布局的机器上窗口会有一半被压在任务栏下面。
4. 校验逻辑分层:界面校验、业务校验、数据校验各管一段
4.1 三层校验各管什么,别混在一起写
登录流程里的校验如果全塞在一个槽函数里,很快就会变成两百行的 if-else 泥球。我习惯分三层:
| 层级 | 触发时机 | 负责内容 | 失败表现 |
|---|---|---|---|
| 界面层 | 焦点离开、点击登录 | 非空、长度、格式(邮箱/手机号) | 输入框红框 + 行内提示 |
| 业务层 | 点击登录后 | 账号是否被锁定、是否需要验证码、密码错误次数 | 弹提示条,不清空输入 |
| 数据层 | 查询返回后 | 用户是否存在、密码哈希是否匹配、账号状态 | 统一话术"账号或密码不正确" |
重点说数据层的最后一条:用户不存在和密码错误,对外的提示必须完全一致。如果分开提示,攻击者可以拿一个账号字典把"这个账号存在"筛出来,这是很基础的安全常识,但很多内部工具都忽略了。
4.2 密码不能明文存:加盐哈希的实现
只要涉及自己存账号密码,就绝不能明文。哪怕是个内部小工具,也不要用MD5(password)这种写法——MD5 和 SHA1 早就不适合做口令存储了,显卡跑彩虹表的速度快到离谱。
最低配也得是"随机盐 + 慢哈希"。Qt 自带的QCryptographicHash只有 SHA 系列,属于快哈希,严格来说不够。如果项目允许引第三方库,首选 bcrypt 或 Argon2。如果只能靠 Qt,那就用多轮迭代把成本拉上去:
QString makePasswordHash(const QString &password, const QByteArray &salt) { QByteArray data = salt + password.toUtf8(); QByteArray digest = data; // 迭代 10 万次,拉高暴力破解成本 for (int i = 0; i < 100000; ++i) { QCryptographicHash h(QCryptographicHash::Sha256); h.addData(digest); h.addData(salt); digest = h.result(); } return QString("%1$%2").arg(QString::fromLatin1(salt.toHex()), QString::fromLatin1(digest.toHex())); }盐的生成用QRandomGenerator::system(),不要用qrand(),后者可预测且在 Qt 5.15 里已被标记过时。盐长度 16 字节起步,每个用户一条独立盐,和哈希值一起存。
验证时不能用==比字符串,要用恒定时间比较,虽然在这个场景下时序攻击的可能性很低,但写成习惯没坏处:
bool safeEqual(const QByteArray &a, const QByteArray &b) { if (a.size() != b.size()) return false; unsigned char diff = 0; for (int i = 0; i < a.size(); ++i) diff |= static_cast<unsigned char>(a[i] ^ b[i]); return diff == 0; }4.3 数据库查询放主线程还是子线程:一次卡顿换来的教训
本地 SQLite 查询通常几毫秒就回来了,放主线程看起来毫无问题。但我在一个项目里遇到过这种情况:用户表两万多行,登录时还要联查一次权限表,再加上第一次打开时数据库文件在机械盘上冷启动,一次查询卡了将近 400 毫秒。界面直接白屏 400 毫秒,用户以为程序崩了,紧接着又点了一次。
从那次之后,我把登录校验统一放到工作线程,用信号槽回传结果:
class AuthWorker : public QObject { Q_OBJECT public slots: void doLogin(const QString &account, const QString &password); signals: void finished(bool ok, const QString &message); };doLogin里创建自己的 QSqlDatabase 连接,注意数据库连接不能跨线程复用,每个线程要用不同的连接名:
void AuthWorker::doLogin(const QString &account, const QString &password) { const QString connName = QString("auth_%1") .arg(reinterpret_cast<quintptr>(QThread::currentThreadId())); QSqlDatabase db; if (QSqlDatabase::contains(connName)) { db = QSqlDatabase::database(connName); } else { db = QSqlDatabase::addDatabase("QSQLITE", connName); db.setDatabaseName(m_dbPath); } if (!db.open()) { emit finished(false, "数据库连接失败"); return; } // ... 查询与校验 emit finished(ok, msg); }主线这边接finished信号时必须用Qt::QueuedConnection(跨线程默认就是队列连接,不用手动写),并且在做 UI 更新前先判断窗口是否还存在。用户完全有可能在查询返回之前就把登录窗口关了,这时候再去操作已经析构的控件就是崩溃。
5. "记住我"与自动登录:QSettings 的边界在哪
5.1 存什么、不存什么
"记住我"这个功能,坑最多的不是实现,是存了什么。我的原则很明确:
| 内容 | 是否落盘 | 说明 |
|---|---|---|
| 用户名 | 可以 | 方便下次带出来 |
| 密码明文 | 绝对不行 | 任何情况下都不行 |
| 密码哈希 | 不行 | 哈希是给服务端校验用的,本地存了等于给了离线爆破的样本 |
| 登录令牌 | 可以 | 服务端签发、有有效期、能主动吊销 |
| 勾选状态 | 可以 | 就是个小开关 |
| 上次登录时间 | 可以 | 用来做展示或者过期判断 |
也就是说,"记住我"不等于"记住密码"。如果是纯本地单机工具、没有服务端,那就别做自动登录,只记住用户名和勾选状态,密码每次都手输。这是我踩过坑之后给自己定的规矩:本地能拿到的凭据,等于没有凭据。
真有服务端的话,流程是登录成功后拿到一个短期令牌,存进 QSettings,下次启动时拿令牌去换会话。令牌按天过期,过期后静默失败回到登录页。令牌可以被服务端吊销,安全性完全不在一个层级。
5.2 配置文件落在哪、什么时候读写
QSettings 的默认行为有点绕,值得说清楚。用QSettings("MyCompany", "MyApp")这种双参数形式,Windows 下会写进注册表,macOS 和 Linux 下写进 plist 或 ini。位置跟用户走,重装程序不会丢,但调试时不好找。
我更推荐显式指定 ini 格式:
QSettings settings(QCoreApplication::applicationDirPath() + "/config.ini", QSettings::IniFormat); settings.setIniCodec("UTF-8"); // Qt 5 必须写,否则中文乱码setIniCodec这行在 Qt 6 里已经移除(Qt 6 默认就是 UTF-8),如果你在用 Qt 6,写了会编译不过。Qt 5.15 下如果不写,用户名里带中文的话,下次读出来就是一堆问号。
读写的时机也有讲究。读放在 UI 构造完成之后、show 之前,写放在登录成功之后立刻写。不要在析构函数里写,因为登录窗口的析构不一定会发生——如果它是主窗口之上的模态对话框,用户点 X 关闭的时候可能只是 hide,对象还活着,配置永远不会落盘。
// 启动时读取 QSettings st(cfgPath, QSettings::IniFormat); st.setIniCodec("UTF-8"); ui->editAccount->setText(st.value("login/lastUser").toString()); ui->checkRemember->setChecked(st.value("login/remember", false).toBool()); // 登录成功后写入 st.setValue("login/lastUser", ui->editAccount->text().trimmed()); st.setValue("login/remember", ui->checkRemember->isChecked()); st.sync(); // 显式落盘,别指望析构时自动写sync()值得手动调一次。QSettings 有内部缓存,靠析构写盘在程序异常退出时会丢数据。用户刚登录成功你就把进程崩了,配置没写上,下次还得重新输账号——这种事发生一次就够烦了。
6. 那些没人写在文档里的坑
6.1 登录窗口一闪而过与事件循环的关系
这是每个 Qt 新手都遇到过的:"我写了个登录窗口,编译通过,运行之后窗口闪一下就没了。"
原因通常是两种。第一种,你在main里写成了栈对象:
// 错误:作用域结束就析构,窗口还没来得及显示 int main(int argc, char *argv[]) { QApplication a(argc, argv); LoginDialog dlg; dlg.show(); return 0; // 少了 a.exec() }少了a.exec()就是没有事件循环,程序立刻走完 main 返回,窗口自然一闪而过。正确写法是dlg.show(); return a.exec();。
第二种,登录窗口是模态的,exec()返回之后你没检查返回值就直接用了主窗口:
LoginDialog dlg; if (dlg.exec() == QDialog::Accepted) { MainWindow w; w.show(); return a.exec(); } return 0; // 用户取消登录,正常退出,别返回 -1注意最后那个return 0。如果用户取消了登录,返回 0 是正常结束;有人习惯写return -1,某些启动器或者系统会把它当成异常退出处理,弹一个错误报告框,很尴尬。
6.2 无边框窗口的拖动、圆角失效和阴影错位
想要圆角卡片加投影的完整效果,基本都要走无边框路线:
setWindowFlags(Qt::FramelessWindowHint | Qt::WindowMinimizeButtonHint); setAttribute(Qt::WA_TranslucentBackground);加完之后三个问题立刻出现。
第一个,窗口没法拖了。无边框之后系统标题栏没了,拖动得自己实现。标准的做法是重写 mousePressEvent 记录偏移,mouseMoveEvent 里移动窗口:
void LoginDialog::mousePressEvent(QMouseEvent *e) { if (e->button() == Qt::LeftButton) { m_dragOffset = e->globalPos() - frameGeometry().topLeft(); e->accept(); } } void LoginDialog::mouseMoveEvent(QMouseEvent *e) { if (e->buttons() & Qt::LeftButton) { move(e->globalPos() - m_dragOffset); e->accept(); } }但这样写有个副作用:卡片区域内的输入框选字、按钮点击也被算成拖动区。用户想选中输入框里的文字,结果整个窗口跟着跑。解决办法是只在卡片外的空白区域响应拖动,判断childAt(e->pos())是不是卡片,或者在卡片控件上装事件过滤器把鼠标事件截下来。
第二个,圆角失效。给最外层 QWidget 设了border-radius但窗口还是直角。原因是WA_TranslucentBackground需要配合真正绘制圆角的 paintEvent,光靠 QSS 有时候在 Windows 上会被系统合成器忽略。稳妥方案是让卡片本身留出 12 像素外边距,圆角画在卡片上,最外层窗口保持全透明。投影同理,用QGraphicsDropShadowEffect加在卡片上,效果是模糊半径 20、偏移 (0, 4)、颜色半透明黑。注意QGraphicsDropShadowEffect加在容器上会影响子控件的渲染性能,如果卡片里有动画,建议换成自己画阴影。
第三个,无边框窗口在任务栏上没有最小化动画。这个没太好办法,属于系统层面的行为,能接受就接受。如果客户特别在意,那就别做无边框,保留系统标题栏,最多用setWindowTitle加个好看的图标。
6.3 打包发布之后 QSS 图片丢失、图标变白块
这几乎是我见过最多的"在我电脑上好好的"问题。三种典型表现:
- 样式表整套失效,界面回到系统默认外观。
- 按钮上该有图标的地方变成空白。
- 中文字体变成方框。
第一种,前面说过,多半是 .qss 没进 .qrc。还有一种更隐蔽:QSS 里用url(:/icons/xxx.png)引用资源时,路径大小写必须和 .qrc 里的完全一致。Windows 文件系统大小写不敏感,你在开发机上写:/Icons/xxx.png也能跑,打包到 Linux 上立刻失效。
第二种,图标变白块,通常是 SVG 模块没打包进去。用 windeployqt 的话默认会带上 Qt5Svg.dll,但如果你的 QSS 是在资源里加载的 SVG,而工程文件里没写QT += svg,编译期不会报错,运行期静默失败。补上QT += svg重新构建就行。
第三种,字体方框。你指定了一个系统里没装的字体,Qt 回退到一个不支持中文的字体上。解决办法是要么用系统自带的中文字体(微软雅黑、思源黑体),要么把字体文件打进资源,用QFontDatabase::addApplicationFont运行时加载:
int id = QFontDatabase::addApplicationFont(":/fonts/SourceHanSansCN-Regular.otf"); if (id != -1) { const QString family = QFontDatabase::applicationFontFamilies(id).value(0); qApp->setFont(QFont(family, 10)); }注意addApplicationFont的返回值必须判断,加载失败返回 -1,这时候如果还去取 families 会拿到空列表,后面value(0)就是空字符串,最后整个应用的字体都被设成空名,反而比不设置更糟。
另外提醒一句打包时容易漏的事:QSettings如果用的是applicationDirPath()拼路径,而程序装在Program Files下面,普通用户没有写权限,配置会写失败而且不报错。这种情况要改用QStandardPaths::AppConfigLocation,或者干脆在安装时就把配置文件放到用户目录。
关于登录页这件事,我自己最大的体会是:真正花时间的从来不是"把控件摆上去",而是摆完之后那些不显眼的地方——按钮禁用态、输入框聚焦态、查询放到哪个线程、配置写到哪个目录。这些细节单看都不难,但漏掉任何一个,用户都会在某个瞬间觉得"这个软件有点糙"。我现在的习惯是先写死交互流程再动界面,把"点下去之后会发生什么"列一遍,再回头补样式,顺序反过来的话,样式改十遍也改不完。