304 lines
12 KiB
C++
304 lines
12 KiB
C++
#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);
|
||
|
||
// 记住登录:勾选后成功登录将安全存储 token,30 天内免登录。默认不勾(更安全)。
|
||
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);
|
||
|
||
// 错误淡入:柔化失败时刻(仅透明度 200ms;errorLabel_ 已预留固定高度,
|
||
// 不引发布局跳动)。复用同一 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
|