// // LoginViewController.swift // suixinkan // import SnapKit import UIKit @MainActor /// 登录页,展示手机号/密码表单、协议勾选和多账号选择入口。 final class LoginViewController: UIViewController { private let services: AppServices private let viewModel = LoginViewModel() private let scrollView = UIScrollView() private let contentView = UIView() private let backgroundImageView = UIImageView(image: UIImage(named: "LoginBackground")) private let titleLabel = UILabel() private let cardView = UIView() private let usernameField = LoginInputField(iconName: "person", placeholder: "请输入手机号", isSecure: false) private let passwordField = LoginInputField(iconName: "lock", placeholder: "请输入密码", isSecure: true) private let privacyButton = UIButton(type: .custom) private let privacyLabel = UILabel() private let userAgreementButton = UIButton(type: .system) private let privacyPolicyButton = UIButton(type: .system) private let loginButton = UIButton(type: .system) /// 初始化实例。 init(services: AppServices) { self.services = services super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = UIColor(hex: 0x0B1220) configureViews() bindViewModel() viewModel.applyPreferences(services.authSessionCoordinator.loginPreferences()) } /// 配置Views展示内容。 private func configureViews() { backgroundImageView.contentMode = .scaleAspectFill backgroundImageView.clipsToBounds = true titleLabel.text = "欢迎使用\n随心瞰商家版" titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.largeTitle, weight: .semibold) titleLabel.textColor = .white titleLabel.numberOfLines = 0 cardView.backgroundColor = .white cardView.layer.cornerRadius = AppMetrics.CornerRadius.card cardView.layer.shadowColor = UIColor.black.cgColor cardView.layer.shadowOpacity = 0.12 cardView.layer.shadowRadius = 18 cardView.layer.shadowOffset = CGSize(width: 0, height: 8) usernameField.textField.keyboardType = .phonePad usernameField.textField.textContentType = .telephoneNumber usernameField.textField.autocorrectionType = .no usernameField.textField.autocapitalizationType = .none usernameField.textField.returnKeyType = .next usernameField.textField.accessibilityIdentifier = "login.username" usernameField.textField.delegate = self usernameField.textField.addTarget(self, action: #selector(usernameChanged), for: .editingChanged) passwordField.textField.autocorrectionType = .no passwordField.textField.autocapitalizationType = .none passwordField.textField.returnKeyType = .go passwordField.textField.accessibilityIdentifier = "login.password" passwordField.textField.delegate = self passwordField.textField.addTarget(self, action: #selector(passwordChanged), for: .editingChanged) passwordField.onToggleVisibility = { [weak self] in self?.viewModel.showsPassword.toggle() } privacyButton.accessibilityIdentifier = "login.privacy" privacyButton.addTarget(self, action: #selector(togglePrivacy), for: .touchUpInside) configureAgreementText() loginButton.setTitle("登录", for: .normal) loginButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) loginButton.layer.cornerRadius = AppMetrics.CornerRadius.button loginButton.accessibilityIdentifier = "login.submit" loginButton.addTarget(self, action: #selector(loginTapped), for: .touchUpInside) view.addSubview(backgroundImageView) view.addSubview(scrollView) scrollView.addSubview(contentView) contentView.addSubview(titleLabel) contentView.addSubview(cardView) cardView.addSubview(usernameField) cardView.addSubview(passwordField) cardView.addSubview(privacyButton) cardView.addSubview(privacyLabel) cardView.addSubview(loginButton) backgroundImageView.snp.makeConstraints { make in make.edges.equalToSuperview() } scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() } contentView.snp.makeConstraints { make in make.edges.equalTo(scrollView.contentLayoutGuide) make.width.equalTo(scrollView.frameLayoutGuide) } titleLabel.snp.makeConstraints { make in make.top.equalTo(contentView.safeAreaLayoutGuide).offset(72) make.leading.trailing.equalToSuperview().inset(24) } titleLabel.accessibilityIdentifier = "login.title" cardView.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.xxLarge) make.leading.trailing.equalToSuperview().inset(20) make.bottom.equalToSuperview().inset(AppMetrics.Spacing.xxLarge) } usernameField.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium) make.height.equalTo(AppMetrics.ControlSize.inputHeight) } passwordField.snp.makeConstraints { make in make.top.equalTo(usernameField.snp.bottom).offset(AppMetrics.Spacing.xSmall) make.leading.trailing.equalTo(usernameField) make.height.equalTo(AppMetrics.ControlSize.inputHeight) } privacyButton.snp.makeConstraints { make in make.top.equalTo(passwordField.snp.bottom).offset(AppMetrics.ControlSize.checkboxTapArea) make.leading.equalToSuperview().inset(AppMetrics.Spacing.medium) make.width.height.equalTo(AppMetrics.ControlSize.checkboxTapArea) } privacyLabel.snp.makeConstraints { make in make.leading.equalTo(privacyButton.snp.trailing) make.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium) make.top.equalTo(privacyButton).offset(AppMetrics.Spacing.xxSmall) } loginButton.snp.makeConstraints { make in make.top.equalTo(privacyLabel.snp.bottom).offset(AppMetrics.Spacing.mediumLarge) make.leading.trailing.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium) make.height.equalTo(AppMetrics.ControlSize.primaryButtonHeight) } let tap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) tap.cancelsTouchesInView = false view.addGestureRecognizer(tap) } /// 配置AgreementText展示内容。 private func configureAgreementText() { privacyLabel.numberOfLines = 0 privacyLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) userAgreementButton.setTitle("《用户协议》", for: .normal) userAgreementButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) userAgreementButton.setTitleColor(UIColor(hex: 0x208BFF), for: .normal) userAgreementButton.accessibilityIdentifier = "login.userAgreement" userAgreementButton.addTarget(self, action: #selector(openUserAgreement), for: .touchUpInside) privacyPolicyButton.setTitle("《隐私政策》", for: .normal) privacyPolicyButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) privacyPolicyButton.setTitleColor(UIColor(hex: 0x208BFF), for: .normal) privacyPolicyButton.accessibilityIdentifier = "login.privacyPolicy" privacyPolicyButton.addTarget(self, action: #selector(openPrivacyPolicy), for: .touchUpInside) let prefix = UILabel() prefix.text = "已阅读并同意" prefix.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) prefix.textColor = UIColor(hex: 0x666666) let separator = UILabel() separator.text = "与" separator.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) separator.textColor = UIColor(hex: 0x666666) let row = UIStackView(arrangedSubviews: [prefix, userAgreementButton, separator, privacyPolicyButton]) row.axis = .horizontal row.spacing = 0 row.alignment = .center privacyLabel.addSubview(row) row.snp.makeConstraints { make in make.edges.equalToSuperview() } } /// 绑定ViewModel回调或数据。 private func bindViewModel() { viewModel.onChange = { [weak self] in self?.renderViewModel() } renderViewModel() } /// render视图模型相关逻辑。 private func renderViewModel() { if usernameField.textField.text != viewModel.username { usernameField.textField.text = viewModel.username } if passwordField.textField.text != viewModel.password { passwordField.textField.text = viewModel.password } passwordField.setSecureEntry(!viewModel.showsPassword) let checkboxImage = viewModel.privacyChecked ? "LoginCheckboxChecked" : "LoginCheckboxUnchecked" privacyButton.setImage(UIImage(named: checkboxImage), for: .normal) loginButton.isEnabled = viewModel.canSubmit && !viewModel.isLoading loginButton.backgroundColor = viewModel.canSubmit ? AppDesign.primary : .gray loginButton.setTitleColor(.white, for: .normal) loginButton.alpha = viewModel.isLoading ? 0.7 : 1 if viewModel.showsAgreementSheet { viewModel.showsAgreementSheet = false presentAgreementSheet() } 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 weak var accountSelectionController: AccountSelectionViewController? /// username变更相关逻辑。 @objc private func usernameChanged() { viewModel.username = usernameField.textField.text ?? "" viewModel.normalizeUsernameCountryCodeIfNeeded() services.toastCenter.dismiss() } /// password变更相关逻辑。 @objc private func passwordChanged() { viewModel.password = passwordField.textField.text ?? "" services.toastCenter.dismiss() } /// togglePrivacy相关逻辑。 @objc private func togglePrivacy() { viewModel.privacyChecked.toggle() } /// openUserAgreement相关逻辑。 @objc private func openUserAgreement() { showToast("用户协议页面待接入") } /// openPrivacyPolicy相关逻辑。 @objc private func openPrivacyPolicy() { showToast("隐私政策页面待接入") } /// 点击login的处理逻辑。 @objc private func loginTapped() { view.endEditing(true) performLogin() } /// dismiss键盘相关逻辑。 @objc private func dismissKeyboard() { view.endEditing(true) } /// perform登录相关逻辑。 private func performLogin() { if let validationError = viewModel.validateForLogin() { if validationError == .privacyUnchecked { presentAgreementSheet() } else { showToast(validationError.message) if validationError.focusField == .username { usernameField.textField.becomeFirstResponder() } else if validationError.focusField == .password { passwordField.textField.becomeFirstResponder() } } return } Task { do { try await services.globalLoading.withLoading(message: "登录中...") { let resolution = try await viewModel.login(authAPI: services.authAPI) switch resolution { case let .completed(response): await completeLogin(with: response) case .needsAccountSelection: break } } } catch is CancellationError { return } catch { showToast(error.localizedDescription) } } } /// 弹出AgreementSheet页面。 private func presentAgreementSheet() { let controller = LoginAgreementConsentViewController( onOpenAgreement: { [weak self] title in self?.showToast("\(title)页面待接入") }, onAgreeAndContinue: { [weak self] in self?.viewModel.acceptAgreement() self?.performLogin() } ) if let sheet = controller.sheetPresentationController { sheet.detents = [.custom(resolver: { _ in 240 })] sheet.prefersGrabberVisible = true } present(controller, animated: true) } /// 弹出AccountSelection页面。 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) } /// select账号相关逻辑。 private func selectAccount(_ account: AccountSwitchAccount) { Task { do { try await services.globalLoading.withLoading(message: "账号切换中...") { let response = try await viewModel.selectAccount(account, authAPI: services.authAPI) await completeLogin(with: response) accountSelectionController = nil dismiss(animated: true) } } catch is CancellationError { return } catch { showToast(error.localizedDescription) } } } /// complete登录相关逻辑。 private func completeLogin(with response: V9AuthResponse) async { do { try await services.authSessionCoordinator.completeLogin( with: response, username: viewModel.normalizedUsername, privacyAgreementAccepted: viewModel.privacyChecked, appSession: services.appSession, accountContext: services.accountContext, permissionContext: services.permissionContext, profileAPI: services.profileAPI, accountContextAPI: services.accountContextAPI ) } catch { showToast(error.localizedDescription) } } } extension LoginViewController: UITextFieldDelegate { /// text字段ShouldReturn相关逻辑。 func textFieldShouldReturn(_ textField: UITextField) -> Bool { if textField === usernameField.textField { passwordField.textField.becomeFirstResponder() } else if viewModel.canSubmit, !viewModel.isLoading { performLogin() } return true } } /// 登录输入框,包含图标、文本框和密码可见切换。 private final class LoginInputField: UIView { let textField = UITextField() var onToggleVisibility: (() -> Void)? private let toggleButton = UIButton(type: .custom) private var isSecure = false /// 初始化实例。 init(iconName: String, placeholder: String, isSecure: Bool) { self.isSecure = isSecure super.init(frame: .zero) backgroundColor = UIColor(hex: 0xF1F5F9) layer.cornerRadius = AppMetrics.CornerRadius.input layer.borderWidth = 1 layer.borderColor = UIColor(hex: 0xDDE3EA).cgColor let icon = UIImageView(image: UIImage(systemName: iconName)) icon.tintColor = AppDesign.textSecondary icon.contentMode = .scaleAspectFit textField.placeholder = placeholder textField.font = .systemFont(ofSize: AppMetrics.FontSize.body) textField.textColor = AppDesign.textPrimary textField.tintColor = AppDesign.primary textField.borderStyle = .none textField.isSecureTextEntry = isSecure addSubview(icon) addSubview(textField) icon.snp.makeConstraints { make in make.leading.equalToSuperview().inset(AppMetrics.Spacing.medium) make.centerY.equalToSuperview() make.width.height.equalTo(AppMetrics.ControlSize.smallIcon) } if isSecure { toggleButton.addTarget(self, action: #selector(toggleVisibility), for: .touchUpInside) addSubview(toggleButton) toggleButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(AppMetrics.Spacing.small) make.centerY.equalToSuperview() make.width.height.equalTo(AppMetrics.ControlSize.iconTapArea) } textField.snp.makeConstraints { make in make.leading.equalTo(icon.snp.trailing).offset(AppMetrics.Spacing.xSmall) make.trailing.equalTo(toggleButton.snp.leading) make.centerY.equalToSuperview() } } else { textField.snp.makeConstraints { make in make.leading.equalTo(icon.snp.trailing).offset(AppMetrics.Spacing.xSmall) make.trailing.equalToSuperview().inset(AppMetrics.Spacing.medium) make.centerY.equalToSuperview() } } } required init?(coder: NSCoder) { nil } /// setSecureEntry相关逻辑。 func setSecureEntry(_ secure: Bool) { textField.isSecureTextEntry = secure let imageName = secure ? "LoginPwdInvisible" : "LoginPwdVisible" toggleButton.setImage(UIImage(named: imageName), for: .normal) } /// toggleVisibility相关逻辑。 @objc private func toggleVisibility() { onToggleVisibility?() } } /// 协议确认弹窗,引导用户勾选协议后继续登录。 private final class LoginAgreementConsentViewController: UIViewController { private let onOpenAgreement: (String) -> Void private let onAgreeAndContinue: () -> Void /// 初始化实例。 init(onOpenAgreement: @escaping (String) -> Void, onAgreeAndContinue: @escaping () -> Void) { self.onOpenAgreement = onOpenAgreement self.onAgreeAndContinue = onAgreeAndContinue super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { nil } /// 视图加载完成后的 UI 初始化与数据绑定。 override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground let titleLabel = UILabel() titleLabel.text = "请先阅读并同意相关协议" titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold) titleLabel.textColor = AppDesign.textPrimary titleLabel.textAlignment = .center let descriptionLabel = UILabel() descriptionLabel.text = "为了保障你的账号安全和服务体验,请阅读并同意用户协议和隐私政策。" descriptionLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) descriptionLabel.textColor = AppDesign.textSecondary descriptionLabel.numberOfLines = 0 descriptionLabel.textAlignment = .center let continueButton = UIButton(type: .system) continueButton.setTitle("同意并继续", for: .normal) continueButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) continueButton.backgroundColor = AppDesign.primary continueButton.setTitleColor(.white, for: .normal) continueButton.layer.cornerRadius = AppMetrics.CornerRadius.button continueButton.accessibilityIdentifier = "login.agreement.continue" continueButton.addTarget(self, action: #selector(continueTapped), for: .touchUpInside) view.addSubview(titleLabel) view.addSubview(descriptionLabel) view.addSubview(continueButton) titleLabel.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(AppMetrics.Spacing.sheet) make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.large) } descriptionLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(AppMetrics.Spacing.small) make.leading.trailing.equalTo(titleLabel) } continueButton.snp.makeConstraints { make in make.leading.trailing.equalTo(titleLabel) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppMetrics.Spacing.mediumLarge) make.height.equalTo(AppMetrics.ControlSize.sheetButtonHeight) } } /// 点击continue的处理逻辑。 @objc private func continueTapped() { dismiss(animated: true) { [onAgreeAndContinue] in onAgreeAndContinue() } } }