geopro/src/app/login/LoginWindow.cpp

304 lines
12 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "login/LoginWindow.hpp"
#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 {
namespace {
// 验证码图尺寸(约 120x40
constexpr int kCaptchaWidth = 120;
constexpr int kCaptchaHeight = 40;
constexpr int kNoiseLines = 6;
QColor randomDark(QRandomGenerator* rng)
{
return QColor(rng->bounded(30, 160), rng->bounded(30, 160), rng->bounded(30, 160));
}
// 把验证码字符串画成一张模拟验证码图:随机颜色字符 + 干扰线。
QPixmap renderCaptchaPixmap(const QString& code)
{
QPixmap pix(kCaptchaWidth, kCaptchaHeight);
pix.fill(QColor("#EEF2FB"));
QPainter p(&pix);
p.setRenderHint(QPainter::Antialiasing, true);
auto* rng = QRandomGenerator::global();
// 干扰线
for (int i = 0; i < kNoiseLines; ++i) {
QColor c = randomDark(rng);
c.setAlpha(120);
p.setPen(QPen(c, 1));
p.drawLine(rng->bounded(kCaptchaWidth), rng->bounded(kCaptchaHeight),
rng->bounded(kCaptchaWidth), rng->bounded(kCaptchaHeight));
}
// 逐字符绘制,轻微旋转 + 随机颜色
const int n = code.isEmpty() ? 0 : code.size();
const int slot = n > 0 ? kCaptchaWidth / (n + 1) : kCaptchaWidth;
QFont font;
font.setBold(true);
font.setPixelSize(26);
for (int i = 0; i < n; ++i) {
p.save();
const int x = slot * (i + 1) - 6;
const int y = kCaptchaHeight / 2 + 4;
p.translate(x, y);
p.rotate(rng->bounded(-25, 25));
font.setPixelSize(rng->bounded(22, 30));
p.setFont(font);
p.setPen(randomDark(rng));
p.drawText(0, 0, QString(code.at(i)));
p.restore();
}
p.end();
return pix;
}
} // namespace
LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
: QDialog(parent), auth_(auth)
{
setWindowTitle(QStringLiteral("Geopro 3.0 登录"));
setFixedSize(400, 528);
// 仅外观:登录窗自带样式(沿用全局主题令牌,保证一脉相承)。
// QLineEdit 在所有状态都显式白底深字 + 边框,避免失焦时取调色板默认色与背景相近不可读。
// 字号引用 Theme 排版令牌:品牌名=display(24)、副标题/字段标签=caption(12)。
// 登录窗整体随 ElaTheme 着色(与 Ela 化的输入/按钮一致,避免暗系统下浅窗+暗控件割裂)。
// 品牌带文字用 white 关键字(不入角色映射→恒为白),保证落在蓝色横幅上始终可读。
geopro::app::applyTokenizedStyleSheet(
this, QStringLiteral(
"QDialog { background: {{bg/app}}; }"
"#headerBand {"
" background: qlineargradient(x1:0, y1:0, x2:1, y2:1,"
" stop:0 {{accent/primary}}, stop:1 {{accent/primary-pressed}}); }"
"#brandTitle { color: {{text/on-primary}}; font-size: %1px; font-weight: %2; }"
"#brandSubtitle { color: rgba(255,255,255,0.82); font-size: %3px; }"
"#fieldLabel { color: {{text/secondary}}; font-size: %4px; font-weight: %5; }"
// 输入框已 Ela 化(ElaLineEdit 自绘 Fluent + 自动明暗),不再写 QLineEdit QSS。
"#captchaImg { border: 1px solid {{border/strong}}; border-radius: 8px; background: {{bg/hover}}; }")
.arg(scaledPx(type::kDisplay))
.arg(type::kWeightBold)
.arg(scaledPx(type::kCaption))
.arg(scaledPx(type::kCaption))
.arg(type::kWeightSemibold));
auto* root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
// ── 品牌头部:强调色横幅 + 产品名 + 副标题(建立产品身份与视觉锚点)──
auto* headerBand = new QWidget(this);
headerBand->setObjectName(QStringLiteral("headerBand"));
headerBand->setFixedHeight(108);
auto* headerLayout = new QVBoxLayout(headerBand);
headerLayout->setContentsMargins(32, 0, 32, 0);
headerLayout->setSpacing(4);
headerLayout->addStretch();
auto* brandTitle = new QLabel(QStringLiteral("Geopro 3.0"), headerBand);
brandTitle->setObjectName(QStringLiteral("brandTitle"));
auto* brandSubtitle = new QLabel(QStringLiteral("地球物理数据分析平台"), headerBand);
brandSubtitle->setObjectName(QStringLiteral("brandSubtitle"));
headerLayout->addWidget(brandTitle);
headerLayout->addWidget(brandSubtitle);
headerLayout->addStretch();
root->addWidget(headerBand);
// ── 表单主体:标签在上、输入在下的纵向字段(现代、留白充分)──
auto* body = new QWidget(this);
auto* form = new QVBoxLayout(body);
// 表单边距取间距令牌:左右 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) {
auto* lbl = new QLabel(labelText, body);
lbl->setObjectName(QStringLiteral("fieldLabel"));
edit->setMinimumHeight(40);
form->addWidget(lbl);
form->addWidget(edit);
form->addSpacing(6);
};
userEdit_ = new QLineEdit(body);
userEdit_->setPlaceholderText(QStringLiteral("请输入用户名"));
userEdit_->setClearButtonEnabled(true);
addField(QStringLiteral("用户名"), userEdit_);
pwdEdit_ = new QLineEdit(body);
pwdEdit_->setEchoMode(QLineEdit::Password);
pwdEdit_->setPlaceholderText(QStringLiteral("请输入密码"));
addField(QStringLiteral("密码"), pwdEdit_);
// 验证码:标签 + 一行(输入框占主,验证码图固定在右)。
auto* codeLbl = new QLabel(QStringLiteral("验证码"), body);
codeLbl->setObjectName(QStringLiteral("fieldLabel"));
form->addWidget(codeLbl);
auto* captchaRow = new QHBoxLayout();
captchaRow->setSpacing(10);
codeEdit_ = new QLineEdit(body);
codeEdit_->setMinimumHeight(40);
codeEdit_->setPlaceholderText(QStringLiteral("请输入验证码"));
captchaLabel_ = new QLabel(body);
captchaLabel_->setObjectName(QStringLiteral("captchaImg"));
captchaLabel_->setFixedSize(kCaptchaWidth, kCaptchaHeight);
captchaLabel_->setAlignment(Qt::AlignCenter);
captchaRow->addWidget(codeEdit_, 1);
captchaRow->addWidget(captchaLabel_);
form->addLayout(captchaRow);
// 刷新链接(右对齐,次要操作弱化为文字链接)。
auto* refreshRow = new QHBoxLayout();
refreshRow->addStretch();
refreshBtn_ = new QPushButton(QStringLiteral("看不清?换一张"), body);
refreshBtn_->setFlat(true);
refreshBtn_->setCursor(Qt::PointingHandCursor);
geopro::app::applyTokenizedStyleSheet(
refreshBtn_,
QStringLiteral(
"QPushButton { color: {{accent/primary}}; border: none; background: transparent; padding: 2px 0; }"
"QPushButton:hover { color: {{accent/primary-pressed}}; text-decoration: underline; }"));
refreshRow->addWidget(refreshBtn_);
form->addLayout(refreshRow);
// 记住登录:勾选后成功登录将安全存储 token30 天内免登录。默认不勾(更安全)。
rememberChk_ = new QCheckBox(QStringLiteral("记住登录30 天内免登录)"), body);
rememberChk_->setCursor(Qt::PointingHandCursor); // ElaCheckBox 自绘 Fluent + 自动明暗
form->addWidget(rememberChk_);
// 错误提示:固定占位高度,避免出现时整体布局跳动。
errorLabel_ = new QLabel(body);
geopro::app::applyTokenizedStyleSheet(
errorLabel_, QStringLiteral("color: {{status/danger}}; font-size: %1px;").arg(scaledPx(type::kCaption)));
errorLabel_->setWordWrap(true);
errorLabel_->setMinimumHeight(18);
form->addWidget(errorLabel_);
form->addStretch();
// 主操作满宽强调主按钮von Restorff唯一高强调元素引导主流程
loginBtn_ = new QPushButton(QStringLiteral("登 录"), body); // Fluent 主按钮(自动明暗)
loginBtn_->setMinimumHeight(44);
loginBtn_->setCursor(Qt::PointingHandCursor);
loginBtn_->setDefault(true);
form->addWidget(loginBtn_);
root->addWidget(body, 1);
connect(refreshBtn_, &QPushButton::clicked, this, &LoginWindow::refreshCaptcha);
connect(loginBtn_, &QPushButton::clicked, this, &LoginWindow::attemptLogin);
connect(codeEdit_, &QLineEdit::returnPressed, this, &LoginWindow::attemptLogin);
connect(pwdEdit_, &QLineEdit::returnPressed, this, &LoginWindow::attemptLogin);
refreshCaptcha(); // 打开即拉一张验证码
userEdit_->setFocus(); // 焦点落在第一个待填字段
}
void LoginWindow::refreshCaptcha()
{
codeEdit_->clear();
try {
const auto cap = auth_.fetchCaptcha();
codeId_ = cap.codeId;
captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code));
} catch (const std::exception& e) {
showError(QStringLiteral("获取验证码失败:%1").arg(QString::fromUtf8(e.what())));
captchaLabel_->setText(QStringLiteral("加载失败"));
} catch (...) {
showError(QStringLiteral("获取验证码失败"));
captchaLabel_->setText(QStringLiteral("加载失败"));
}
}
void LoginWindow::attemptLogin()
{
const QString user = userEdit_->text().trimmed();
const QString pwd = pwdEdit_->text();
const QString code = codeEdit_->text().trimmed();
if (user.isEmpty() || pwd.isEmpty() || code.isEmpty()) {
showError(QStringLiteral("请填写用户名、密码和验证码"));
return;
}
errorLabel_->clear();
loginBtn_->setEnabled(false);
const QString origText = loginBtn_->text();
loginBtn_->setText(QStringLiteral("登录中..."));
loginBtn_->repaint(); // 同步阻塞前刷新按钮文案
geopro::net::AuthService::LoginResult result;
try {
result = auth_.login(user, pwd, code, codeId_);
} catch (const std::exception& e) {
result.ok = false;
result.error = QStringLiteral("登录异常:%1").arg(QString::fromUtf8(e.what()));
} catch (...) {
result.ok = false;
result.error = QStringLiteral("登录发生未知错误");
}
loginBtn_->setText(origText);
loginBtn_->setEnabled(true);
if (result.ok) {
token_ = result.token;
accept();
return;
}
showError(result.error.isEmpty() ? QStringLiteral("登录失败") : result.error);
refreshCaptcha(); // 失败刷新验证码
}
bool LoginWindow::remember() const
{
return rememberChk_ && rememberChk_->isChecked();
}
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