feat: 手机号 + 验证码登录

This commit is contained in:
AndyChen1024
2026-09-15 14:35:52 +08:00
parent 7f6cbb3190
commit affe326e90
2 changed files with 158 additions and 3 deletions

View File

@@ -1,10 +1,100 @@
import SwiftUI
import DesignSystem
/// FeatureAuth 占位(后续实现登录 / 注册)。
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 {
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))
}
}

View 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
}
}
}
}