feat: 增强邮箱登录功能和密码恢复流程

- 更新邮箱登录相关功能,新增邮箱验证码获取和登录API端点。
- 添加AccountModel以管理用户认证信息,支持会话票据的存储和更新。
- 实现密码恢复功能,支持通过邮箱获取验证码和重置密码。
- 增加本地化支持,更新相关字符串以适应新功能。
- 引入ValidationHelper以验证邮箱和密码格式,确保用户输入的有效性。
- 更新视图以支持邮箱登录和密码恢复的用户交互。
This commit is contained in:
edwinQQQ
2025-07-10 14:00:58 +08:00
parent c470dba79c
commit e45ad3bad5
23 changed files with 2054 additions and 164 deletions

View File

@@ -0,0 +1,303 @@
import SwiftUI
import ComposableArchitecture
struct RecoverPasswordView: View {
let store: StoreOf<RecoverPasswordFeature>
let onBack: () -> Void
// 使@StateUI
@State private var email: String = ""
@State private var verificationCode: String = ""
@State private var newPassword: String = ""
@State private var isNewPasswordVisible: Bool = false
//
@State private var countdown: Int = 0
@State private var countdownTimer: Timer?
//
private var isConfirmButtonEnabled: Bool {
return !store.isResetLoading && !email.isEmpty && !verificationCode.isEmpty && !newPassword.isEmpty
}
//
private var isGetCodeButtonEnabled: Bool {
return !store.isCodeLoading && !email.isEmpty && countdown == 0
}
//
private var getCodeButtonText: String {
if store.isCodeLoading {
return ""
} else if countdown > 0 {
return "\(countdown)s"
} else {
return "recover_password.get_code".localized
}
}
var body: some View {
GeometryReader { geometry in
ZStack {
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
//
HStack {
Button(action: {
onBack()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer()
.frame(height: 60)
//
Text("recover_password.title".localized)
.font(.system(size: 28, weight: .medium))
.foregroundColor(.white)
.padding(.bottom, 80)
//
VStack(spacing: 24) {
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
TextField("", text: $email)
.placeholder(when: email.isEmpty) {
Text("recover_password.placeholder_email".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
.padding(.horizontal, 24)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
HStack {
TextField("", text: $verificationCode)
.placeholder(when: verificationCode.isEmpty) {
Text("recover_password.placeholder_verification_code".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
.keyboardType(.numberPad)
//
Button(action: {
//
startCountdown()
// API
store.send(.getVerificationCodeTapped)
}) {
ZStack {
if store.isCodeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.7)
} else {
Text(getCodeButtonText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
}
}
.frame(width: 60, height: 36)
.background(
RoundedRectangle(cornerRadius: 15)
.fill(Color.white.opacity(isGetCodeButtonEnabled ? 0.2 : 0.1))
)
}
.disabled(!isGetCodeButtonEnabled || store.isCodeLoading)
}
.padding(.horizontal, 24)
}
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
HStack {
if isNewPasswordVisible {
TextField("", text: $newPassword)
.placeholder(when: newPassword.isEmpty) {
Text("recover_password.placeholder_new_password".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
} else {
SecureField("", text: $newPassword)
.placeholder(when: newPassword.isEmpty) {
Text("recover_password.placeholder_new_password".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
}
Button(action: {
isNewPasswordVisible.toggle()
}) {
Image(systemName: isNewPasswordVisible ? "eye.slash" : "eye")
.foregroundColor(.white.opacity(0.7))
.font(.system(size: 18))
}
}
.padding(.horizontal, 24)
}
}
.padding(.horizontal, 32)
Spacer()
.frame(height: 80)
//
Button(action: {
store.send(.resetPasswordTapped)
}) {
ZStack {
//
LinearGradient(
colors: [
Color(red: 0.85, green: 0.37, blue: 1.0), // #D85EFF
Color(red: 0.54, green: 0.31, blue: 1.0) // #8A4FFF
],
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 28))
HStack {
if store.isResetLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
}
Text(store.isResetLoading ? "recover_password.resetting".localized : "recover_password.confirm_button".localized)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
}
.frame(height: 56)
}
.disabled(!isConfirmButtonEnabled)
.opacity(isConfirmButtonEnabled ? 1.0 : 0.5)
.padding(.horizontal, 32)
//
if let errorMessage = store.errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
Spacer()
}
}
}
.onAppear {
//
store.send(.resetState)
email = ""
verificationCode = ""
newPassword = ""
isNewPasswordVisible = false
countdown = 0
stopCountdown()
#if DEBUG
email = "exzero@126.com"
store.send(.emailChanged(email))
#endif
}
.onDisappear {
stopCountdown()
}
.onChange(of: email) { newEmail in
store.send(.emailChanged(newEmail))
}
.onChange(of: verificationCode) { newCode in
store.send(.verificationCodeChanged(newCode))
}
.onChange(of: newPassword) { newPassword in
store.send(.newPasswordChanged(newPassword))
}
.onChange(of: store.isCodeLoading) { isCodeLoading in
// API
if !isCodeLoading && store.errorMessage == nil {
//
}
}
}
// MARK: - Private Methods
private func startCountdown() {
countdown = 60
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if countdown > 0 {
countdown -= 1
} else {
stopCountdown()
}
}
}
private func stopCountdown() {
countdownTimer?.invalidate()
countdownTimer = nil
countdown = 0
}
}
#Preview {
RecoverPasswordView(
store: Store(
initialState: RecoverPasswordFeature.State()
) {
RecoverPasswordFeature()
},
onBack: {}
)
}