214 lines
6.7 KiB
Swift
214 lines
6.7 KiB
Swift
//
|
||
// LoginViewModel.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/18.
|
||
//
|
||
|
||
import Foundation
|
||
import Observation
|
||
|
||
/// 登录输入焦点实体,标识当前需要聚焦的输入框。
|
||
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
|
||
@Observable
|
||
/// 登录页 ViewModel,负责表单状态、登录请求和多账号选择流程。
|
||
final class LoginViewModel {
|
||
var username = "18651857230"
|
||
var password = "zhifly666"
|
||
var privacyChecked = false
|
||
var isLoading = false
|
||
var isSelectingAccount = false
|
||
var showsPassword = false
|
||
var showsAgreementSheet = false
|
||
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
|
||
}
|
||
}
|