Introduce APIClient/AuthAPI, complete login with multi-account selection, and polish the login card UI with shadow and keyboard dismiss. Co-authored-by: Cursor <cursoragent@cursor.com>
206 lines
6.3 KiB
Swift
206 lines
6.3 KiB
Swift
//
|
||
// LoginViewModel.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Foundation
|
||
|
||
/// 登录页 ViewModel,UI 状态与 Android `LoginViewModel` 对齐。
|
||
final class LoginViewModel {
|
||
|
||
private(set) var account = ""
|
||
private(set) var password = ""
|
||
private(set) var isPrivacyChecked = false
|
||
private(set) var enableRegister = false
|
||
private(set) var isLoading = false
|
||
private(set) var isSelectingAccount = false
|
||
private(set) var pendingAccountSelection: AccountSelectionPayload?
|
||
|
||
var isLoginEnabled: Bool {
|
||
isValidPhone && !trimmedPassword.isEmpty
|
||
}
|
||
|
||
var trimmedPassword: String {
|
||
password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
|
||
var normalizedUsername: String {
|
||
normalizedPhoneNumber(account)
|
||
}
|
||
|
||
var isValidPhone: Bool {
|
||
normalizedUsername.count == 11 && normalizedUsername.first == "1"
|
||
}
|
||
|
||
var onStateChange: (() -> Void)?
|
||
|
||
/// 从本地存储恢复上次登录手机号与协议勾选状态。
|
||
func applyStoredPreferences() {
|
||
if account.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||
let lastUsername = AppStore.shared.lastLoginUsername {
|
||
account = lastUsername
|
||
}
|
||
isPrivacyChecked = AppStore.shared.privacyAgreementAccepted
|
||
notifyStateChange()
|
||
}
|
||
|
||
func updateAccount(_ value: String) {
|
||
account = value
|
||
notifyStateChange()
|
||
}
|
||
|
||
func updatePassword(_ value: String) {
|
||
password = value
|
||
notifyStateChange()
|
||
}
|
||
|
||
func togglePrivacyChecked() {
|
||
isPrivacyChecked.toggle()
|
||
notifyStateChange()
|
||
}
|
||
|
||
func updateEnableRegister(_ enabled: Bool) {
|
||
enableRegister = enabled
|
||
notifyStateChange()
|
||
}
|
||
|
||
/// 当输入包含 +86 前缀时,把手机号规范化为 11 位国内手机号。
|
||
func normalizeUsernameCountryCodeIfNeeded() {
|
||
let digits = account.filter(\.isNumber)
|
||
let normalizedPhone = normalizedPhoneNumber(account)
|
||
guard normalizedPhone != digits, normalizedPhone.count == 11 else { return }
|
||
account = normalizedPhone
|
||
notifyStateChange()
|
||
}
|
||
|
||
/// 校验登录表单,并返回第一个需要处理的错误。
|
||
func validateForLogin() -> LoginValidationError? {
|
||
guard isValidPhone else {
|
||
return .invalidPhone
|
||
}
|
||
guard !trimmedPassword.isEmpty else {
|
||
return .emptyPassword
|
||
}
|
||
guard isPrivacyChecked else {
|
||
return .privacyUnchecked
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func login() {
|
||
if let error = validateForLogin() {
|
||
onShowMessage?(error.message)
|
||
return
|
||
}
|
||
onLogin?()
|
||
}
|
||
|
||
/// 加载 App 配置,控制验证码登录/注册入口显隐。
|
||
func loadAppConfig(authAPI: AuthAPI) async {
|
||
do {
|
||
let config = try await authAPI.getAppConfig()
|
||
updateEnableRegister(config.enableRegister)
|
||
} catch {
|
||
// 配置加载失败不影响密码登录,静默忽略。
|
||
}
|
||
}
|
||
|
||
/// 发起登录,并根据账号数量决定直接完成或进入账号选择。
|
||
func login(authAPI: AuthAPI) async throws -> LoginResolution {
|
||
guard !isLoading else {
|
||
throw CancellationError()
|
||
}
|
||
|
||
isLoading = true
|
||
notifyStateChange()
|
||
defer {
|
||
isLoading = false
|
||
notifyStateChange()
|
||
}
|
||
|
||
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
|
||
notifyStateChange()
|
||
defer {
|
||
isSelectingAccount = false
|
||
notifyStateChange()
|
||
}
|
||
|
||
let response = try await authAPI.setUser(account.toSetUserRequest(), tokenOverride: payload.tempToken)
|
||
guard !response.token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||
throw LoginFlowError.missingToken
|
||
}
|
||
pendingAccountSelection = nil
|
||
notifyStateChange()
|
||
return response
|
||
}
|
||
|
||
/// 清空待选账号信息,通常在用户取消账号选择时调用。
|
||
func clearPendingAccountSelection() {
|
||
pendingAccountSelection = nil
|
||
notifyStateChange()
|
||
}
|
||
|
||
var onShowMessage: ((String) -> Void)?
|
||
var onLogin: (() -> Void)?
|
||
var onLoginByCode: (() -> Void)?
|
||
var onRegister: (() -> Void)?
|
||
var onUserAgreement: (() -> Void)?
|
||
var onPrivacyPolicy: (() -> Void)?
|
||
|
||
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
|
||
notifyStateChange()
|
||
return .needsAccountSelection(payload)
|
||
}
|
||
|
||
private func normalizedPhoneNumber(_ value: String) -> String {
|
||
var digits = value.filter(\.isNumber)
|
||
if digits.hasPrefix("86"), digits.count == 13 {
|
||
digits.removeFirst(2)
|
||
}
|
||
return digits
|
||
}
|
||
|
||
private func notifyStateChange() {
|
||
onStateChange?()
|
||
}
|
||
}
|