feat: 手机号 + 验证码登录
This commit is contained in:
@@ -1,10 +1,100 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import DesignSystem
|
||||||
|
|
||||||
/// FeatureAuth 占位(后续实现登录 / 注册)。
|
|
||||||
public struct AuthScreen: View {
|
public struct AuthScreen: View {
|
||||||
public init() {}
|
private let viewModel: AuthViewModel
|
||||||
|
private let onLoggedIn: () -> Void
|
||||||
|
|
||||||
|
@State private var phone = ""
|
||||||
|
@State private var code = ""
|
||||||
|
@State private var privacyAgreed = false
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
public init(viewModel: AuthViewModel, onLoggedIn: @escaping () -> Void) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.onLoggedIn = onLoggedIn
|
||||||
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
ContentUnavailableView("登录", systemImage: "person.crop.circle")
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: DS.Spacing.md) {
|
||||||
|
Text("手机号登录")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(DS.Color.brandDark)
|
||||||
|
Text("手机号仅用于账号绑定,不会公开展示")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(DS.Color.faintText)
|
||||||
|
|
||||||
|
TextField("手机号", text: Binding(get: { phone }, set: { phone = digits($0, max: 11) }))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
|
HStack(spacing: DS.Spacing.sm) {
|
||||||
|
TextField("验证码", text: Binding(get: { code }, set: { code = digits($0, max: 6) }))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Button(getCodeTitle) { viewModel.requestSmsCode(phone: phone) }
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(!canSendCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button { privacyAgreed.toggle() } label: {
|
||||||
|
HStack(spacing: DS.Spacing.xs) {
|
||||||
|
Image(systemName: privacyAgreed ? "checkmark.square.fill" : "square")
|
||||||
|
.foregroundStyle(privacyAgreed ? DS.Color.brandGold : DS.Color.brandMuted)
|
||||||
|
Text("我已阅读并同意《用户协议》和《隐私政策》")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(DS.Color.faintText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(DS.Color.danger)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(viewModel.isSubmitting ? "登录中..." : "登录") {
|
||||||
|
viewModel.login(phone: phone, code: code)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
}
|
||||||
|
.padding(DS.Spacing.lg)
|
||||||
|
}
|
||||||
|
.navigationTitle("登录")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("取消") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.didLogin) { _, loggedIn in
|
||||||
|
if loggedIn { onLoggedIn() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var phoneValid: Bool {
|
||||||
|
phone.range(of: #"^1\d{10}$"#, options: .regularExpression) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSendCode: Bool {
|
||||||
|
phoneValid && viewModel.countdownSeconds == 0 && !viewModel.isSendingCode
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
phoneValid && !code.isEmpty && privacyAgreed && !viewModel.isSubmitting
|
||||||
|
}
|
||||||
|
|
||||||
|
private var getCodeTitle: String {
|
||||||
|
if viewModel.countdownSeconds > 0 { return "\(viewModel.countdownSeconds)s 后重发" }
|
||||||
|
if viewModel.isSendingCode { return "发送中" }
|
||||||
|
return "获取验证码"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func digits(_ string: String, max: Int) -> String {
|
||||||
|
String(string.filter(\.isNumber).prefix(max))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
IosClientKit/Sources/FeatureAuth/AuthViewModel.swift
Normal file
65
IosClientKit/Sources/FeatureAuth/AuthViewModel.swift
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import CoreDomain
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
public final class AuthViewModel {
|
||||||
|
public var isSendingCode = false
|
||||||
|
public var countdownSeconds = 0
|
||||||
|
public var isSubmitting = false
|
||||||
|
public var errorMessage: String?
|
||||||
|
public var didLogin = false
|
||||||
|
|
||||||
|
private let sessionUseCase: SessionUseCase
|
||||||
|
private var countdownTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
public init(sessionUseCase: SessionUseCase) {
|
||||||
|
self.sessionUseCase = sessionUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
public func requestSmsCode(phone: String) {
|
||||||
|
guard !isSendingCode, countdownSeconds == 0 else { return }
|
||||||
|
isSendingCode = true
|
||||||
|
errorMessage = nil
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
try await self.sessionUseCase.requestSmsCode(phone: phone)
|
||||||
|
self.startCountdown()
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||||
|
}
|
||||||
|
self.isSendingCode = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func login(phone: String, code: String) {
|
||||||
|
guard !isSubmitting else { return }
|
||||||
|
isSubmitting = true
|
||||||
|
errorMessage = nil
|
||||||
|
Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
try await self.sessionUseCase.login(phone: phone, code: code)
|
||||||
|
self.didLogin = true
|
||||||
|
} catch {
|
||||||
|
self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||||
|
}
|
||||||
|
self.isSubmitting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startCountdown() {
|
||||||
|
countdownTask?.cancel()
|
||||||
|
countdownSeconds = 60
|
||||||
|
countdownTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
while self.countdownSeconds > 0 {
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self.countdownSeconds -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user