#include "login/LoginWindow.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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(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