Files
suixinkan_uikit/suixinkan/UI/Login/LoginViewController.swift
T
lujiuyinandCursor c083f1d4b3 升级 AppStore 缓存 Key 并在覆盖安装时强制重新登录。
统一账号作用域 Key 对齐 Android,新增缓存 schema 迁移清理旧数据,并同步更新相关模块与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 09:35:55 +08:00

376 lines
13 KiB
Swift

//
// LoginViewController.swift
// suixinkan
//
import SafariServices
import SnapKit
import UIKit
/// 登录页,UI 严格对齐 Android `LoginScreen`。
final class LoginViewController: BaseViewController {
private let viewModel = LoginViewModel()
private let authAPI = NetworkServices.shared.authAPI
private let backgroundImageView = UIImageView()
private let welcomeLabel = UILabel()
private let formCardShadowView = UIView()
private let formCardView = UIView()
private let topFormStack = UIStackView()
private let accountField = LoginTextField(placeholder: "请输入账号")
private let passwordField = PasswordInputField()
private let agreementRow = AgreementRowView()
private let loginButton = AppButton(title: "登录", style: .primary)
private let loginByCodeButton = AppButton(title: "验证码登录", style: .secondary)
private let registerContainer = UIStackView()
private let registerPrefixLabel = UILabel()
private let registerButton = UIButton(type: .system)
private weak var accountSelectionController: AccountSelectionViewController?
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
override func setupNavigationBar() {
navigationController?.setNavigationBarHidden(true, animated: false)
}
override func setupUI() {
view.backgroundColor = .black
backgroundImageView.image = UIImage(named: "img_login_bg")
backgroundImageView.contentMode = .scaleAspectFill
backgroundImageView.clipsToBounds = true
welcomeLabel.text = "欢迎使用\n随心瞰商家版"
welcomeLabel.numberOfLines = 2
welcomeLabel.font = .systemFont(ofSize: 28, weight: .semibold)
welcomeLabel.textColor = .white
formCardShadowView.backgroundColor = .clear
formCardShadowView.layer.shadowColor = UIColor.black.cgColor
formCardShadowView.layer.shadowOpacity = 0.14
formCardShadowView.layer.shadowOffset = CGSize(width: 0, height: 8)
formCardShadowView.layer.shadowRadius = 20
formCardView.backgroundColor = .white
formCardView.layer.cornerRadius = 16
formCardView.clipsToBounds = true
topFormStack.axis = .vertical
topFormStack.spacing = 0
topFormStack.alignment = .fill
topFormStack.distribution = .fill
loginButton.addTarget(self, action: #selector(loginButtonTapped), for: .touchUpInside)
loginByCodeButton.addTarget(self, action: #selector(loginByCodeButtonTapped), for: .touchUpInside)
registerContainer.axis = .horizontal
registerContainer.alignment = .center
registerContainer.spacing = 0
registerPrefixLabel.text = "没有账号?"
registerPrefixLabel.font = .systemFont(ofSize: 14, weight: .medium)
registerPrefixLabel.textColor = AppColor.text333
registerButton.setTitle("去注册", for: .normal)
registerButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
registerButton.setTitleColor(AppColor.primary, for: .normal)
registerButton.addTarget(self, action: #selector(registerButtonTapped), for: .touchUpInside)
registerContainer.addArrangedSubview(registerPrefixLabel)
registerContainer.addArrangedSubview(registerButton)
view.addSubview(backgroundImageView)
view.addSubview(welcomeLabel)
view.addSubview(formCardShadowView)
formCardShadowView.addSubview(formCardView)
formCardView.addSubview(topFormStack)
formCardView.addSubview(registerContainer)
topFormStack.addArrangedSubview(accountField)
topFormStack.setCustomSpacing(8, after: accountField)
topFormStack.addArrangedSubview(passwordField)
topFormStack.setCustomSpacing(24, after: passwordField)
topFormStack.addArrangedSubview(agreementRow)
topFormStack.setCustomSpacing(16, after: agreementRow)
topFormStack.addArrangedSubview(loginButton)
topFormStack.setCustomSpacing(8, after: loginButton)
topFormStack.addArrangedSubview(loginByCodeButton)
viewModel.applyStoredPreferences()
if let username = viewModel.normalizedUsername.nonEmpty ?? viewModel.account.nonEmpty {
accountField.text = username
}
agreementRow.isChecked = viewModel.isPrivacyChecked
bindViewModel()
updateUI()
}
override func setupConstraints() {
backgroundImageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
welcomeLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(50)
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(24)
}
formCardShadowView.snp.makeConstraints { make in
make.top.equalTo(welcomeLabel.snp.bottom).offset(50)
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(24)
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-24)
}
formCardView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
registerContainer.snp.makeConstraints { make in
make.centerX.equalToSuperview()
}
applyCardLayoutConstraints(showRegisterSection: false)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
formCardShadowView.layer.shadowPath = UIBezierPath(
roundedRect: formCardShadowView.bounds,
cornerRadius: 16
).cgPath
}
override func bindActions() {
accountField.onTextChange = { [weak self] text in
self?.viewModel.updateAccount(text)
}
accountField.onReturnKey = { [weak self] in
self?.passwordField.becomeFirstResponder()
}
accountField.onEditingDidEnd = { [weak self] in
self?.viewModel.normalizeUsernameCountryCodeIfNeeded()
if let username = self?.viewModel.normalizedUsername {
self?.accountField.text = username
}
}
passwordField.onTextChange = { [weak self] text in
self?.viewModel.updatePassword(text)
}
passwordField.onReturnKey = { [weak self] in
self?.view.endEditing(true)
self?.viewModel.login()
}
agreementRow.onCheckedChange = { [weak self] in
self?.viewModel.togglePrivacyChecked()
}
agreementRow.onUserAgreementTap = { [weak self] in
self?.viewModel.onUserAgreement?()
}
agreementRow.onPrivacyPolicyTap = { [weak self] in
self?.viewModel.onPrivacyPolicy?()
}
setupDismissKeyboardGesture()
}
private func setupDismissKeyboardGesture() {
let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
tap.cancelsTouchesInView = false
tap.delegate = self
view.addGestureRecognizer(tap)
}
@objc private func dismissKeyboard() {
view.endEditing(true)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task { await viewModel.loadAppConfig(authAPI: authAPI) }
}
private func bindViewModel() {
viewModel.onStateChange = { [weak self] in
self?.updateUI()
}
viewModel.onShowMessage = { [weak self] message in
self?.showToast(message)
}
viewModel.onLogin = { [weak self] in
self?.performLogin()
}
viewModel.onLoginByCode = { [weak self] in
self?.showToast("验证码登录待接入")
}
viewModel.onRegister = { [weak self] in
self?.showToast("注册待接入")
}
viewModel.onUserAgreement = { [weak self] in
self?.openAgreementURL(AppAgreementURLs.userAgreement)
}
viewModel.onPrivacyPolicy = { [weak self] in
self?.openAgreementURL(AppAgreementURLs.privacyPolicy)
}
}
private func updateUI() {
agreementRow.isChecked = viewModel.isPrivacyChecked
loginButton.isEnabled = viewModel.isLoginEnabled && !viewModel.isLoading
loginButton.alpha = viewModel.isLoading ? 0.7 : 1
let showRegisterSection = viewModel.enableRegister
loginByCodeButton.isHidden = !showRegisterSection
registerContainer.isHidden = !showRegisterSection
applyCardLayoutConstraints(showRegisterSection: showRegisterSection)
if let payload = viewModel.pendingAccountSelection {
if let controller = accountSelectionController {
controller.updateLoading(viewModel.isSelectingAccount)
} else if presentedViewController == nil {
presentAccountSelection(payload)
}
} else if accountSelectionController != nil {
dismiss(animated: true)
accountSelectionController = nil
}
}
/// 卡片高度随内容自适应,底部与屏幕 safeArea 至少保留 24pt 间距。
private func applyCardLayoutConstraints(showRegisterSection: Bool) {
if showRegisterSection {
topFormStack.snp.remakeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
}
registerContainer.snp.remakeConstraints { make in
make.top.equalTo(topFormStack.snp.bottom).offset(16)
make.centerX.equalToSuperview()
make.bottom.equalToSuperview().inset(16)
}
} else {
registerContainer.snp.remakeConstraints { make in
make.centerX.equalToSuperview()
}
topFormStack.snp.remakeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
}
}
private func performLogin() {
viewModel.normalizeUsernameCountryCodeIfNeeded()
accountField.text = viewModel.normalizedUsername
Task {
showLoading()
defer { hideLoading() }
do {
let resolution = try await viewModel.login(authAPI: authAPI)
switch resolution {
case let .completed(response, account):
completeLogin(with: response, account: account)
case .needsAccountSelection:
break
}
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
private func presentAccountSelection(_ payload: AccountSelectionPayload) {
let controller = AccountSelectionViewController(
payload: payload,
isLoading: viewModel.isSelectingAccount,
onCancel: { [weak self] in
self?.viewModel.clearPendingAccountSelection()
self?.accountSelectionController = nil
},
onConfirm: { [weak self] account in
self?.selectAccount(account)
}
)
accountSelectionController = controller
let navigation = UINavigationController(rootViewController: controller)
navigation.modalPresentationStyle = .formSheet
present(navigation, animated: true)
}
private func selectAccount(_ account: AccountSwitchAccount) {
Task {
accountSelectionController?.updateLoading(true)
defer { accountSelectionController?.updateLoading(false) }
do {
let response = try await viewModel.selectAccount(account, authAPI: authAPI)
completeLogin(with: response, account: account)
accountSelectionController = nil
dismiss(animated: true)
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
private func completeLogin(with response: V9AuthResponse, account: AccountSwitchAccount) {
AuthSessionHelper.completeLogin(
with: response,
username: viewModel.normalizedUsername,
privacyAgreementAccepted: viewModel.isPrivacyChecked,
selectedAccount: account
)
}
@objc private func loginButtonTapped() {
view.endEditing(true)
viewModel.login()
}
@objc private func loginByCodeButtonTapped() {
view.endEditing(true)
viewModel.onLoginByCode?()
}
@objc private func registerButtonTapped() {
view.endEditing(true)
viewModel.onRegister?()
}
private func openAgreementURL(_ url: URL) {
let controller = SFSafariViewController(url: url)
present(controller, animated: true)
}
}
extension LoginViewController: UIGestureRecognizerDelegate {
/// 点击输入框、按钮时不触发收键盘,避免干扰正常交互。
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
var candidate: UIView? = touch.view
while let view = candidate {
if view is UITextField || view is UIButton {
return false
}
candidate = view.superview
}
return true
}
}
private extension String {
var nonEmpty: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}