添加网络层并接入 v9 登录流程及单元测试

This commit is contained in:
2026-07-06 15:33:34 +08:00
parent 31ffec0f2b
commit d424928243
21 changed files with 2387 additions and 18 deletions

View File

@ -10,9 +10,11 @@ import UIKit
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: "请输入账号")
@ -24,7 +26,7 @@ final class LoginViewController: BaseViewController {
private let registerPrefixLabel = UILabel()
private let registerButton = UIButton(type: .system)
private var hasPlayedEntranceAnimation = false
private weak var accountSelectionController: AccountSelectionViewController?
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
@ -46,6 +48,12 @@ final class LoginViewController: BaseViewController {
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
@ -75,7 +83,8 @@ final class LoginViewController: BaseViewController {
view.addSubview(backgroundImageView)
view.addSubview(welcomeLabel)
view.addSubview(formCardView)
view.addSubview(formCardShadowView)
formCardShadowView.addSubview(formCardView)
formCardView.addSubview(topFormStack)
formCardView.addSubview(registerContainer)
@ -89,6 +98,12 @@ final class LoginViewController: BaseViewController {
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()
}
@ -103,12 +118,16 @@ final class LoginViewController: BaseViewController {
make.leading.trailing.equalToSuperview().inset(24)
}
formCardView.snp.makeConstraints { make in
formCardShadowView.snp.makeConstraints { make in
make.top.equalTo(welcomeLabel.snp.bottom).offset(48)
make.leading.trailing.equalToSuperview().inset(24)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-24)
}
formCardView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
topFormStack.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalToSuperview().inset(16)
@ -120,6 +139,14 @@ final class LoginViewController: BaseViewController {
}
}
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)
@ -127,6 +154,12 @@ final class LoginViewController: BaseViewController {
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)
@ -145,11 +178,24 @@ final class LoginViewController: BaseViewController {
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)
playEntranceAnimationIfNeeded()
Task { await viewModel.loadAppConfig(authAPI: authAPI) }
}
private func bindViewModel() {
@ -160,7 +206,7 @@ final class LoginViewController: BaseViewController {
self?.showToast(message)
}
viewModel.onLogin = { [weak self] in
self?.showToast("登录功能待接入")
self?.performLogin()
}
viewModel.onLoginByCode = { [weak self] in
self?.showToast("验证码登录待接入")
@ -178,7 +224,8 @@ final class LoginViewController: BaseViewController {
private func updateUI() {
agreementRow.isChecked = viewModel.isPrivacyChecked
loginButton.isEnabled = viewModel.isLoginEnabled
loginButton.isEnabled = viewModel.isLoginEnabled && !viewModel.isLoading
loginButton.alpha = viewModel.isLoading ? 0.7 : 1
let showRegisterSection = viewModel.enableRegister
loginByCodeButton.isHidden = !showRegisterSection
@ -192,21 +239,87 @@ final class LoginViewController: BaseViewController {
make.bottom.equalToSuperview().inset(16)
}
}
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
}
}
private func playEntranceAnimationIfNeeded() {
guard !hasPlayedEntranceAnimation else { return }
hasPlayedEntranceAnimation = true
private func performLogin() {
viewModel.normalizeUsernameCountryCodeIfNeeded()
accountField.text = viewModel.normalizedUsername
formCardView.transform = CGAffineTransform(translationX: 0, y: view.bounds.height)
welcomeLabel.alpha = 0
Task {
showLoading()
defer { hideLoading() }
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut) {
self.formCardView.transform = .identity
self.welcomeLabel.alpha = 1
do {
let resolution = try await viewModel.login(authAPI: authAPI)
switch resolution {
case let .completed(response):
completeLogin(with: response)
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)
accountSelectionController = nil
dismiss(animated: true)
} catch is CancellationError {
return
} catch {
showToast(error.localizedDescription)
}
}
}
private func completeLogin(with response: V9AuthResponse) {
AuthSessionHelper.completeLogin(
with: response,
username: viewModel.normalizedUsername,
privacyAgreementAccepted: viewModel.isPrivacyChecked
)
}
@objc private func loginButtonTapped() {
view.endEditing(true)
viewModel.login()
@ -222,3 +335,24 @@ final class LoginViewController: BaseViewController {
viewModel.onRegister?()
}
}
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
}
}