// // LoginViewModel.swift // suixinkan // // Created by Codex on 2026/6/18. // import Foundation import Combine /// 登录输入焦点实体,标识当前需要聚焦的输入框。 enum LoginField: Hashable { case username case password } /// 登录表单校验错误实体,负责提供用户提示和焦点位置。 enum LoginValidationError: Equatable { case invalidPhone case emptyPassword case privacyUnchecked var message: String { switch self { case .invalidPhone: "请输入有效的手机号码" case .emptyPassword: "请输入密码" case .privacyUnchecked: "请先阅读并同意相关协议" } } var focusField: LoginField? { switch self { case .invalidPhone: .username case .emptyPassword: .password case .privacyUnchecked: nil } } } /// 登录结果实体,区分已完成登录和需要用户选择账号两种情况。 enum LoginResolution { case completed(V9AuthResponse) case needsAccountSelection(AccountSelectionPayload) } /// 登录流程错误实体,表示 token、账号列表或账号 ID 异常。 enum LoginFlowError: LocalizedError { case missingToken case noAvailableAccount case invalidAccount var errorDescription: String? { switch self { case .missingToken: "登录响应缺少 token" case .noAvailableAccount: "当前账号没有可用的景区账号或门店账号,请联系管理员" case .invalidAccount: "账号信息异常,请重新选择账号" } } } @MainActor /// 登录页 ViewModel,负责表单状态、登录请求和多账号选择流程。 final class LoginViewModel: ObservableObject { @Published var username = "18651857230" @Published var password = "zhifly666" @Published var privacyChecked = false @Published var isLoading = false @Published var isSelectingAccount = false @Published var showsPassword = false @Published var showsAgreementSheet = false @Published var pendingAccountSelection: AccountSelectionPayload? var canSubmit: Bool { isValidPhone && !trimmedPassword.isEmpty } var trimmedPassword: String { password.trimmingCharacters(in: .whitespacesAndNewlines) } var normalizedUsername: String { normalizedPhoneNumber(username) } var isValidPhone: Bool { normalizedUsername.count == 11 && normalizedUsername.first == "1" } /// 应用本地登录偏好,只恢复手机号和协议状态,不恢复密码。 func applyPreferences(_ preferences: LoginPreferences) { if username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let lastUsername = preferences.lastUsername { username = lastUsername } privacyChecked = preferences.privacyAgreementAccepted } /// 校验登录表单,并返回第一个需要处理的错误。 func validateForLogin() -> LoginValidationError? { guard isValidPhone else { return .invalidPhone } guard !trimmedPassword.isEmpty else { return .emptyPassword } guard privacyChecked else { return .privacyUnchecked } return nil } /// 用户同意协议后关闭协议确认弹窗。 func acceptAgreement() { privacyChecked = true showsAgreementSheet = false } /// 当输入包含 +86 前缀时,把手机号规范化为 11 位国内手机号。 func normalizeUsernameCountryCodeIfNeeded() { let digits = username.filter(\.isNumber) let normalizedPhone = normalizedPhoneNumber(username) guard normalizedPhone != digits, normalizedPhone.count == 11 else { return } username = normalizedPhone } /// 发起登录,并根据账号数量决定直接完成或进入账号选择。 func login(authAPI: AuthAPI) async throws -> LoginResolution { guard !isLoading else { throw CancellationError() } isLoading = true defer { isLoading = false } let response = try await authAPI.login( username: normalizedUsername, password: trimmedPassword ) return try await resolveLoginResponse(response, authAPI: authAPI) } /// 选择一个账号并调用 set-user 换取正式 token。 func selectAccount(_ account: AccountSwitchAccount, authAPI: AuthAPI) async throws -> V9AuthResponse { guard let payload = pendingAccountSelection, payload.hasTempToken else { throw LoginFlowError.missingToken } guard account.businessUserId > 0 else { throw LoginFlowError.invalidAccount } guard !isSelectingAccount else { throw CancellationError() } isSelectingAccount = true defer { isSelectingAccount = false } let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken) guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw LoginFlowError.missingToken } pendingAccountSelection = nil return response } /// 清空待选账号信息,通常在用户取消账号选择时调用。 func clearPendingAccountSelection() { pendingAccountSelection = nil } /// 解析登录响应,单账号自动 set-user,多账号保留选择载荷。 private func resolveLoginResponse(_ response: V9AuthResponse, authAPI: AuthAPI) async throws -> LoginResolution { let token = response.token.trimmingCharacters(in: .whitespacesAndNewlines) guard !token.isEmpty else { throw LoginFlowError.missingToken } let accounts = response.accounts.filter { $0.businessUserId > 0 } guard !accounts.isEmpty else { throw LoginFlowError.noAvailableAccount } if accounts.count == 1, let account = accounts.first { let finalResponse = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: token) guard !finalResponse.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw LoginFlowError.missingToken } return .completed(finalResponse) } let payload = AccountSelectionPayload(tempToken: token, accounts: accounts) pendingAccountSelection = payload return .needsAccountSelection(payload) } /// 过滤手机号中的非数字字符,并去掉 86 国家码前缀。 private func normalizedPhoneNumber(_ value: String) -> String { var digits = value.filter(\.isNumber) if digits.hasPrefix("86"), digits.count == 13 { digits.removeFirst(2) } return digits } }