feat: 增强邮箱登录功能和密码恢复流程
- 更新邮箱登录相关功能,新增邮箱验证码获取和登录API端点。 - 添加AccountModel以管理用户认证信息,支持会话票据的存储和更新。 - 实现密码恢复功能,支持通过邮箱获取验证码和重置密码。 - 增加本地化支持,更新相关字符串以适应新功能。 - 引入ValidationHelper以验证邮箱和密码格式,确保用户输入的有效性。 - 更新视图以支持邮箱登录和密码恢复的用户交互。
This commit is contained in:
@@ -6,7 +6,7 @@ alwaysApply: true
|
||||
|
||||
# CONTEXT
|
||||
|
||||
I am a native Chinese speaker who has just begun learning Swift 6 and Xcode 16, and I am enthusiastic about exploring new technologies. I wish to receive advice using the latest tools and
|
||||
I am a native Chinese speaker who has just begun learning Swift 6 and Xcode 15+, and I am enthusiastic about exploring new technologies. I wish to receive advice using the latest tools and
|
||||
seek step-by-step guidance to fully understand the implementation process. Since many excellent code resources are in English, I hope my questions can be thoroughly understood. Therefore,
|
||||
I would like the AI assistant to think and reason in English, then translate the English responses into Chinese for me.
|
||||
|
||||
@@ -42,7 +42,7 @@ alwaysApply: true
|
||||
|
||||
# AUDIENCE
|
||||
|
||||
The target audience is me—a native Chinese developer eager to learn Swift 6 and Xcode 16, seeking guidance and advice on utilizing the latest technologies.
|
||||
The target audience is me—a native Chinese developer eager to learn Swift 6 and Xcode 15+, seeking guidance and advice on utilizing the latest technologies.
|
||||
|
||||
---
|
||||
|
||||
|
@@ -7,6 +7,7 @@ alwaysApply: true
|
||||
|
||||
# Architechture
|
||||
- Use TCA(The Composable Architecture) architecture with SwiftUI & Swift
|
||||
- Don't use TCA for UI Navigation
|
||||
|
||||
# Code Structure
|
||||
- Use Swift's latest features and protocol-oriented programming
|
||||
|
@@ -180,5 +180,53 @@
|
||||
landmarkType = "24">
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
<BreakpointProxy
|
||||
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
||||
<BreakpointContent
|
||||
uuid = "CF5E29EE-0D89-4141-9696-9587D243115B"
|
||||
shouldBeEnabled = "Yes"
|
||||
ignoreCount = "0"
|
||||
continueAfterRunningActions = "No"
|
||||
filePath = "yana/Features/IDLoginFeature.swift"
|
||||
startingColumnNumber = "9223372036854775807"
|
||||
endingColumnNumber = "9223372036854775807"
|
||||
startingLineNumber = "104"
|
||||
endingLineNumber = "104"
|
||||
landmarkName = "body"
|
||||
landmarkType = "24">
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
<BreakpointProxy
|
||||
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
||||
<BreakpointContent
|
||||
uuid = "057A0951-B4B1-4417-85B8-1D1C3962D30A"
|
||||
shouldBeEnabled = "Yes"
|
||||
ignoreCount = "0"
|
||||
continueAfterRunningActions = "No"
|
||||
filePath = "yana/Features/IDLoginFeature.swift"
|
||||
startingColumnNumber = "9223372036854775807"
|
||||
endingColumnNumber = "9223372036854775807"
|
||||
startingLineNumber = "161"
|
||||
endingLineNumber = "161"
|
||||
landmarkName = "body"
|
||||
landmarkType = "24">
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
<BreakpointProxy
|
||||
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
||||
<BreakpointContent
|
||||
uuid = "F36191A2-34B7-4321-80B7-1A80A7479E32"
|
||||
shouldBeEnabled = "Yes"
|
||||
ignoreCount = "0"
|
||||
continueAfterRunningActions = "No"
|
||||
filePath = "yana/Features/LoginFeature.swift"
|
||||
startingColumnNumber = "9223372036854775807"
|
||||
endingColumnNumber = "9223372036854775807"
|
||||
startingLineNumber = "154"
|
||||
endingLineNumber = "154"
|
||||
landmarkName = "body"
|
||||
landmarkType = "24">
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
</Breakpoints>
|
||||
</Bucket>
|
||||
|
@@ -18,6 +18,7 @@ enum APIEndpoint: String, CaseIterable {
|
||||
case configInit = "/client/init"
|
||||
case login = "/oauth/token"
|
||||
case ticket = "/oauth/ticket"
|
||||
case emailGetCode = "/email/getCode" // 新增:邮箱验证码获取端点
|
||||
// Web 页面路径
|
||||
case userAgreement = "/modules/rule/protocol.html"
|
||||
case privacyPolicy = "/modules/rule/privacy-wap.html"
|
||||
|
@@ -35,6 +35,10 @@ enum APIError: Error, Equatable {
|
||||
case httpError(statusCode: Int, message: String?)
|
||||
case timeout
|
||||
case resourceTooLarge
|
||||
case encryptionFailed // 新增:加密失败
|
||||
case invalidResponse // 新增:无效响应
|
||||
case ticketFailed // 新增:票据获取失败
|
||||
case custom(String) // 新增:自定义错误信息
|
||||
case unknown(String)
|
||||
|
||||
var localizedDescription: String {
|
||||
@@ -53,6 +57,14 @@ enum APIError: Error, Equatable {
|
||||
return "请求超时"
|
||||
case .resourceTooLarge:
|
||||
return "响应数据过大"
|
||||
case .encryptionFailed:
|
||||
return "数据加密失败"
|
||||
case .invalidResponse:
|
||||
return "服务器响应无效"
|
||||
case .ticketFailed:
|
||||
return "获取会话票据失败"
|
||||
case .custom(let message):
|
||||
return message
|
||||
case .unknown(let message):
|
||||
return "未知错误: \(message)"
|
||||
}
|
||||
@@ -233,6 +245,7 @@ struct UserInfoManager {
|
||||
static let accessToken = "access_token"
|
||||
static let ticket = "user_ticket"
|
||||
static let userInfo = "user_info"
|
||||
static let accountModel = "account_model" // 新增:AccountModel存储键
|
||||
}
|
||||
|
||||
// MARK: - User ID Management
|
||||
@@ -337,6 +350,7 @@ struct UserInfoManager {
|
||||
userDefaults.removeObject(forKey: StorageKeys.userId)
|
||||
userDefaults.removeObject(forKey: StorageKeys.accessToken)
|
||||
userDefaults.removeObject(forKey: StorageKeys.userInfo)
|
||||
clearAccountModel() // 新增:清除 AccountModel
|
||||
clearTicket()
|
||||
userDefaults.synchronize()
|
||||
|
||||
@@ -356,6 +370,76 @@ struct UserInfoManager {
|
||||
// 实际实现中应该调用 TicketHelper.createTicketRequest
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Account Model Management
|
||||
/// 保存 AccountModel
|
||||
/// - Parameter accountModel: 要保存的账户模型
|
||||
static func saveAccountModel(_ accountModel: AccountModel) {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(accountModel)
|
||||
userDefaults.set(data, forKey: StorageKeys.accountModel)
|
||||
userDefaults.synchronize()
|
||||
|
||||
// 同时更新各个独立字段(向后兼容)
|
||||
if let uid = accountModel.uid {
|
||||
saveUserId(uid)
|
||||
}
|
||||
if let accessToken = accountModel.accessToken {
|
||||
saveAccessToken(accessToken)
|
||||
}
|
||||
if let ticket = accountModel.ticket {
|
||||
saveTicket(ticket)
|
||||
}
|
||||
|
||||
print("💾 AccountModel 保存成功")
|
||||
} catch {
|
||||
print("❌ AccountModel 保存失败: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 AccountModel
|
||||
/// - Returns: 存储的账户模型,如果不存在或解析失败返回 nil
|
||||
static func getAccountModel() -> AccountModel? {
|
||||
guard let data = userDefaults.data(forKey: StorageKeys.accountModel) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(AccountModel.self, from: data)
|
||||
} catch {
|
||||
print("❌ AccountModel 解析失败: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新 AccountModel 中的 ticket
|
||||
/// - Parameter ticket: 新的票据
|
||||
static func updateAccountModelTicket(_ ticket: String) {
|
||||
guard var accountModel = getAccountModel() else {
|
||||
print("❌ 无法更新 ticket:AccountModel 不存在")
|
||||
return
|
||||
}
|
||||
|
||||
accountModel.ticket = ticket
|
||||
saveAccountModel(accountModel)
|
||||
saveTicket(ticket) // 同时更新内存中的 ticket
|
||||
}
|
||||
|
||||
/// 检查是否有有效的 AccountModel
|
||||
/// - Returns: 是否存在有效的账户模型
|
||||
static func hasValidAccountModel() -> Bool {
|
||||
guard let accountModel = getAccountModel() else {
|
||||
return false
|
||||
}
|
||||
return accountModel.hasValidAuthentication
|
||||
}
|
||||
|
||||
/// 清除 AccountModel
|
||||
static func clearAccountModel() {
|
||||
userDefaults.removeObject(forKey: StorageKeys.accountModel)
|
||||
userDefaults.synchronize()
|
||||
print("🗑️ AccountModel 已清除")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Request Protocol
|
||||
|
@@ -1,5 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Account Model
|
||||
/// 账户认证信息模型
|
||||
/// 用于承接 oauth/token 和 oauth/ticket 接口的认证数据
|
||||
/// 参照 OC 版本的 AccountModel 设计
|
||||
struct AccountModel: Codable, Equatable {
|
||||
let uid: String? // 用户唯一标识
|
||||
let jti: String? // JWT ID
|
||||
let tokenType: String? // Token 类型 (bearer)
|
||||
let refreshToken: String? // 刷新令牌
|
||||
let netEaseToken: String? // 网易云信令牌
|
||||
let accessToken: String? // OAuth 访问令牌
|
||||
let expiresIn: Int? // 过期时间(秒)
|
||||
let scope: String? // 权限范围
|
||||
var ticket: String? // 业务会话票据(来自 oauth/ticket)
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case uid
|
||||
case jti
|
||||
case tokenType = "token_type"
|
||||
case refreshToken = "refresh_token"
|
||||
case netEaseToken
|
||||
case accessToken = "access_token"
|
||||
case expiresIn = "expires_in"
|
||||
case scope
|
||||
case ticket
|
||||
}
|
||||
|
||||
/// 检查是否有有效的认证信息
|
||||
var hasValidAuthentication: Bool {
|
||||
return accessToken != nil && !accessToken!.isEmpty
|
||||
}
|
||||
|
||||
/// 检查是否有有效的业务会话
|
||||
var hasValidSession: Bool {
|
||||
return hasValidAuthentication && ticket != nil && !ticket!.isEmpty
|
||||
}
|
||||
|
||||
/// 从 IDLoginData 创建 AccountModel
|
||||
/// - Parameter loginData: 登录响应数据
|
||||
/// - Returns: AccountModel 实例,如果数据无效则返回 nil
|
||||
static func from(loginData: IDLoginData) -> AccountModel? {
|
||||
// 确保至少有 accessToken 和 uid
|
||||
guard let accessToken = loginData.accessToken,
|
||||
let uid = loginData.uid else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AccountModel(
|
||||
uid: String(uid),
|
||||
jti: loginData.jti,
|
||||
tokenType: loginData.tokenType,
|
||||
refreshToken: loginData.refreshToken,
|
||||
netEaseToken: loginData.netEaseToken,
|
||||
accessToken: accessToken,
|
||||
expiresIn: loginData.expiresIn,
|
||||
scope: loginData.scope,
|
||||
ticket: nil // 初始为空,后续通过 oauth/ticket 填充
|
||||
)
|
||||
}
|
||||
|
||||
/// 更新 ticket 信息
|
||||
/// - Parameter ticket: 从 oauth/ticket 获取的票据
|
||||
/// - Returns: 更新后的 AccountModel
|
||||
func withTicket(_ ticket: String) -> AccountModel {
|
||||
var updatedModel = self
|
||||
updatedModel.ticket = ticket
|
||||
return updatedModel
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ID Login Request Model
|
||||
struct IDLoginAPIRequest: APIRequestProtocol {
|
||||
typealias Response = IDLoginResponse
|
||||
@@ -234,3 +304,120 @@ struct TicketHelper {
|
||||
|
||||
// MARK: - 兼容旧的LoginResponse(如果需要)
|
||||
typealias LoginResponse = IDLoginResponse
|
||||
|
||||
// MARK: - Email Verification Code Models
|
||||
|
||||
/// 邮箱验证码获取请求
|
||||
struct EmailGetCodeRequest: APIRequestProtocol {
|
||||
typealias Response = EmailGetCodeResponse
|
||||
|
||||
let endpoint = APIEndpoint.emailGetCode.path
|
||||
let method: HTTPMethod = .POST
|
||||
let includeBaseParameters = true
|
||||
let queryParameters: [String: String]?
|
||||
let bodyParameters: [String: Any]? = nil
|
||||
let timeout: TimeInterval = 30.0
|
||||
|
||||
/// 初始化邮箱验证码获取请求
|
||||
/// - Parameters:
|
||||
/// - emailAddress: DES加密后的邮箱地址
|
||||
/// - type: 验证码类型(1=注册/登录)
|
||||
init(emailAddress: String, type: Int = 1) {
|
||||
self.queryParameters = [
|
||||
"emailAddress": emailAddress,
|
||||
"type": String(type)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// 邮箱验证码获取响应
|
||||
struct EmailGetCodeResponse: Codable, Equatable {
|
||||
let status: String?
|
||||
let message: String?
|
||||
let code: Int?
|
||||
let data: String? // 通常为空,成功时只需要检查状态码
|
||||
|
||||
/// 是否发送成功
|
||||
var isSuccess: Bool {
|
||||
return code == 200 || status?.lowercased() == "success"
|
||||
}
|
||||
|
||||
/// 错误消息(如果有)
|
||||
var errorMessage: String {
|
||||
return message ?? "验证码发送失败,请重试"
|
||||
}
|
||||
}
|
||||
|
||||
/// 邮箱验证码登录请求
|
||||
struct EmailLoginRequest: APIRequestProtocol {
|
||||
typealias Response = IDLoginResponse // 复用ID登录的响应模型
|
||||
|
||||
let endpoint = APIEndpoint.login.path
|
||||
let method: HTTPMethod = .POST
|
||||
let includeBaseParameters = true
|
||||
let queryParameters: [String: String]?
|
||||
let bodyParameters: [String: Any]? = nil
|
||||
let timeout: TimeInterval = 30.0
|
||||
|
||||
/// 初始化邮箱验证码登录请求
|
||||
/// - Parameters:
|
||||
/// - email: DES加密后的邮箱地址
|
||||
/// - code: 验证码
|
||||
/// - clientSecret: 客户端密钥,固定为"uyzjdhds"
|
||||
/// - version: 版本号,固定为"1"
|
||||
/// - clientId: 客户端ID,固定为"erban-client"
|
||||
/// - grantType: 授权类型,固定为"email"
|
||||
init(email: String, code: String, clientSecret: String = "uyzjdhds", version: String = "1", clientId: String = "erban-client", grantType: String = "email") {
|
||||
self.queryParameters = [
|
||||
"email": email,
|
||||
"code": code,
|
||||
"client_secret": clientSecret,
|
||||
"version": version,
|
||||
"client_id": clientId,
|
||||
"grant_type": grantType
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Email Login Helper
|
||||
extension LoginHelper {
|
||||
|
||||
/// 创建邮箱验证码获取请求
|
||||
/// - Parameter email: 原始邮箱地址
|
||||
/// - Returns: 配置好的API请求,如果加密失败返回nil
|
||||
static func createEmailGetCodeRequest(email: String) -> EmailGetCodeRequest? {
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
||||
print("❌ 邮箱DES加密失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
print("🔐 邮箱DES加密成功")
|
||||
print(" 原始邮箱: \(email)")
|
||||
print(" 加密邮箱: \(encryptedEmail)")
|
||||
|
||||
return EmailGetCodeRequest(emailAddress: email, type: 1)
|
||||
}
|
||||
|
||||
/// 创建邮箱验证码登录请求
|
||||
/// - Parameters:
|
||||
/// - email: 原始邮箱地址
|
||||
/// - code: 验证码
|
||||
/// - Returns: 配置好的API请求,如果加密失败返回nil
|
||||
static func createEmailLoginRequest(email: String, code: String) -> EmailLoginRequest? {
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
||||
print("❌ 邮箱DES加密失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
print("🔐 邮箱验证码登录DES加密成功")
|
||||
print(" 原始邮箱: \(email)")
|
||||
print(" 加密邮箱: \(encryptedEmail)")
|
||||
print(" 验证码: \(code)")
|
||||
|
||||
return EmailLoginRequest(email: encryptedEmail, code: code)
|
||||
}
|
||||
}
|
||||
|
434
yana/APIs/email login flow.md
Normal file
434
yana/APIs/email login flow.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# 邮箱验证码登录流程文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档详细描述了 YuMi iOS 应用中 `LoginTypesViewController` 在 `LoginDisplayType_email` 模式下的邮箱验证码登录流程。该流程实现了基于邮箱和验证码的用户认证机制。
|
||||
|
||||
## 系统架构
|
||||
|
||||
### 核心组件
|
||||
- **LoginTypesViewController**: 登录类型控制器,负责 UI 展示和用户交互
|
||||
- **LoginPresenter**: 登录业务逻辑处理器,负责与 API 交互
|
||||
- **LoginInputItemView**: 输入组件,提供邮箱和验证码输入界面
|
||||
- **Api+Login**: 登录相关 API 接口封装
|
||||
- **AccountInfoStorage**: 账户信息本地存储管理
|
||||
|
||||
### 数据模型
|
||||
|
||||
#### LoginDisplayType 枚举
|
||||
```objc
|
||||
typedef NS_ENUM(NSUInteger, LoginDisplayType) {
|
||||
LoginDisplayType_id, // ID 登录
|
||||
LoginDisplayType_email, // 邮箱登录 ✓
|
||||
LoginDisplayType_phoneNum, // 手机号登录
|
||||
LoginDisplayType_email_forgetPassword, // 邮箱忘记密码
|
||||
LoginDisplayType_phoneNum_forgetPassword, // 手机号忘记密码
|
||||
};
|
||||
```
|
||||
|
||||
#### LoginInputType 枚举
|
||||
```objc
|
||||
typedef NS_ENUM(NSUInteger, LoginInputType) {
|
||||
LoginInputType_email, // 邮箱输入
|
||||
LoginInputType_verificationCode, // 验证码输入
|
||||
LoginInputType_login, // 登录按钮
|
||||
// ... 其他类型
|
||||
};
|
||||
```
|
||||
|
||||
#### GetSmsType 验证码类型
|
||||
```objc
|
||||
typedef NS_ENUM(NSUInteger, GetSmsType) {
|
||||
GetSmsType_Regist = 1, // 注册(邮箱登录使用此类型)
|
||||
GetSmsType_Login = 2, // 登录
|
||||
GetSmsType_Reset_Password = 3, // 重设密码
|
||||
// ... 其他类型
|
||||
};
|
||||
```
|
||||
|
||||
## 登录流程详解
|
||||
|
||||
### 1. 界面初始化流程
|
||||
|
||||
#### 1.1 控制器初始化
|
||||
```objc
|
||||
// 在 LoginViewController 中点击邮箱登录按钮
|
||||
- (void)didTapEntrcyButton:(UIButton *)sender {
|
||||
if (sender.tag == LoginType_Email) {
|
||||
LoginTypesViewController *vc = [[LoginTypesViewController alloc] init];
|
||||
[self.navigationController pushViewController:vc animated:YES];
|
||||
[vc updateLoginType:LoginDisplayType_email]; // 设置为邮箱登录模式
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 输入区域设置
|
||||
```objc
|
||||
- (void)setupEmailInputArea {
|
||||
[self setupInpuArea:LoginInputType_email // 第一行:邮箱输入
|
||||
second:LoginInputType_verificationCode // 第二行:验证码输入
|
||||
third:LoginInputType_none // 第三行:无
|
||||
action:LoginInputType_login // 操作按钮:登录
|
||||
showForgetPassword:NO]; // 不显示忘记密码
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 UI 组件配置
|
||||
- **第一行输入框**: 邮箱地址输入
|
||||
- 占位符: "请输入邮箱地址"
|
||||
- 键盘类型: `UIKeyboardTypeEmailAddress`
|
||||
- 回调: `handleFirstInputContentUpdate`
|
||||
|
||||
- **第二行输入框**: 验证码输入
|
||||
- 占位符: "请输入验证码"
|
||||
- 键盘类型: `UIKeyboardTypeDefault`
|
||||
- 附带"获取验证码"按钮
|
||||
- 回调: `handleSecondInputContentUpdate`
|
||||
|
||||
### 2. 验证码获取流程
|
||||
|
||||
#### 2.1 用户交互触发
|
||||
```objc
|
||||
// 用户点击"获取验证码"按钮
|
||||
[self.secondLineInputView setHandleItemAction:^(LoginInputType inputType) {
|
||||
if (inputType == LoginInputType_verificationCode) {
|
||||
if (self.type == LoginDisplayType_email) {
|
||||
[self handleTapGetMailVerificationCode];
|
||||
}
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
#### 2.2 邮箱验证码获取处理
|
||||
```objc
|
||||
- (void)handleTapGetMailVerificationCode {
|
||||
NSString *email = [self.firstLineInputView inputContent];
|
||||
|
||||
// 邮箱地址验证
|
||||
if (email.length == 0) {
|
||||
[self.secondLineInputView endVerificationCountDown];
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用 Presenter 发送验证码
|
||||
[self.presenter sendMailVerificationCode:email type:GetSmsType_Regist];
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 Presenter 层处理
|
||||
```objc
|
||||
- (void)sendMailVerificationCode:(NSString *)emailAddress type:(NSInteger)type {
|
||||
// DES 加密邮箱地址
|
||||
NSString *desEmail = [DESEncrypt encryptUseDES:emailAddress
|
||||
key:KeyWithType(KeyType_PasswordEncode)];
|
||||
|
||||
@kWeakify(self);
|
||||
[Api emailGetCode:[self createHttpCompletion:^(BaseModel *data) {
|
||||
@kStrongify(self);
|
||||
if ([[self getView] respondsToSelector:@selector(emailCodeSucess:type:)]) {
|
||||
[[self getView] emailCodeSucess:@"" type:type];
|
||||
}
|
||||
} fail:^(NSInteger code, NSString *msg) {
|
||||
@kStrongify(self);
|
||||
if ([[self getView] respondsToSelector:@selector(emailCodeFailure)]) {
|
||||
[[self getView] emailCodeFailure];
|
||||
}
|
||||
} showLoading:YES errorToast:YES]
|
||||
emailAddress:desEmail
|
||||
type:@(type)];
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.4 API 接口调用
|
||||
```objc
|
||||
+ (void)emailGetCode:(HttpRequestHelperCompletion)completion
|
||||
emailAddress:(NSString *)emailAddress
|
||||
type:(NSNumber *)type {
|
||||
[self makeRequest:@"email/getCode"
|
||||
method:HttpRequestHelperMethodPOST
|
||||
completion:completion, __FUNCTION__, emailAddress, type, nil];
|
||||
}
|
||||
```
|
||||
|
||||
**API 详情**:
|
||||
- **接口路径**: `POST /email/getCode`
|
||||
- **请求参数**:
|
||||
- `emailAddress`: 邮箱地址(DES 加密)
|
||||
- `type`: 验证码类型(1=注册)
|
||||
|
||||
#### 2.5 获取验证码成功处理
|
||||
```objc
|
||||
- (void)emailCodeSucess:(NSString *)message type:(GetSmsType)type {
|
||||
[self showSuccessToast:YMLocalizedString(@"XPLoginPhoneViewController2")]; // "验证码已发送"
|
||||
[self.secondLineInputView startVerificationCountDown]; // 开始倒计时
|
||||
[self.secondLineInputView displayKeyboard]; // 显示键盘
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.6 获取验证码失败处理
|
||||
```objc
|
||||
- (void)emailCodeFailure {
|
||||
[self.secondLineInputView endVerificationCountDown]; // 结束倒计时
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 邮箱登录流程
|
||||
|
||||
#### 3.1 登录按钮状态检查
|
||||
```objc
|
||||
- (void)checkActionButtonStatus {
|
||||
switch (self.type) {
|
||||
case LoginDisplayType_email: {
|
||||
NSString *accountString = [self.firstLineInputView inputContent]; // 邮箱
|
||||
NSString *codeString = [self.secondLineInputView inputContent]; // 验证码
|
||||
|
||||
// 只有当邮箱和验证码都不为空时才启用登录按钮
|
||||
if (![NSString isEmpty:accountString] && ![NSString isEmpty:codeString]) {
|
||||
self.bottomActionButton.enabled = YES;
|
||||
} else {
|
||||
self.bottomActionButton.enabled = NO;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 登录按钮点击处理
|
||||
```objc
|
||||
- (void)didTapActionButton {
|
||||
[self.view endEditing:true];
|
||||
|
||||
switch (self.type) {
|
||||
case LoginDisplayType_email: {
|
||||
// 调用 Presenter 进行邮箱登录
|
||||
[self.presenter loginWithEmail:[self.firstLineInputView inputContent]
|
||||
code:[self.secondLineInputView inputContent]];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 Presenter 层登录处理
|
||||
```objc
|
||||
- (void)loginWithEmail:(NSString *)email code:(NSString *)code {
|
||||
// DES 加密邮箱地址
|
||||
NSString *desMail = [DESEncrypt encryptUseDES:email
|
||||
key:KeyWithType(KeyType_PasswordEncode)];
|
||||
|
||||
@kWeakify(self);
|
||||
[Api loginWithCode:[self createHttpCompletion:^(BaseModel *data) {
|
||||
@kStrongify(self);
|
||||
|
||||
// 解析账户模型
|
||||
AccountModel *accountModel = [AccountModel modelWithDictionary:data.data];
|
||||
|
||||
// 保存账户信息
|
||||
if (accountModel && accountModel.access_token.length > 0) {
|
||||
[[AccountInfoStorage instance] saveAccountInfo:accountModel];
|
||||
}
|
||||
|
||||
// 通知登录成功
|
||||
if ([[self getView] respondsToSelector:@selector(loginSuccess)]) {
|
||||
[[self getView] loginSuccess];
|
||||
}
|
||||
} fail:^(NSInteger code, NSString *msg) {
|
||||
@kStrongify(self);
|
||||
[[self getView] loginFailWithMsg:msg];
|
||||
} errorToast:NO]
|
||||
email:desMail
|
||||
code:code
|
||||
client_secret:clinet_s // 客户端密钥
|
||||
version:@"1"
|
||||
client_id:@"erban-client"
|
||||
grant_type:@"email"]; // 邮箱登录类型
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4 API 接口调用
|
||||
```objc
|
||||
+ (void)loginWithCode:(HttpRequestHelperCompletion)completion
|
||||
email:(NSString *)email
|
||||
code:(NSString *)code
|
||||
client_secret:(NSString *)client_secret
|
||||
version:(NSString *)version
|
||||
client_id:(NSString *)client_id
|
||||
grant_type:(NSString *)grant_type {
|
||||
|
||||
NSString *fang = [NSString stringFromBase64String:@"b2F1dGgvdG9rZW4="]; // oauth/token
|
||||
[self makeRequest:fang
|
||||
method:HttpRequestHelperMethodPOST
|
||||
completion:completion, __FUNCTION__, email, code, client_secret,
|
||||
version, client_id, grant_type, nil];
|
||||
}
|
||||
```
|
||||
|
||||
**API 详情**:
|
||||
- **接口路径**: `POST /oauth/token`
|
||||
- **请求参数**:
|
||||
- `email`: 邮箱地址(DES 加密)
|
||||
- `code`: 验证码
|
||||
- `client_secret`: 客户端密钥
|
||||
- `version`: 版本号 "1"
|
||||
- `client_id`: 客户端ID "erban-client"
|
||||
- `grant_type`: 授权类型 "email"
|
||||
|
||||
#### 3.5 登录成功处理
|
||||
```objc
|
||||
- (void)loginSuccess {
|
||||
[self showSuccessToast:YMLocalizedString(@"XPLoginPhoneViewController1")]; // "登录成功"
|
||||
[PILoginManager loginWithVC:self isLoginPhone:NO]; // 执行登录后续处理
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.6 登录失败处理
|
||||
```objc
|
||||
- (void)loginFailWithMsg:(NSString *)msg {
|
||||
[self showSuccessToast:msg]; // 显示错误信息
|
||||
}
|
||||
```
|
||||
|
||||
## 数据流时序图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant VC as LoginTypesViewController
|
||||
participant IV as LoginInputItemView
|
||||
participant P as LoginPresenter
|
||||
participant API as Api+Login
|
||||
participant Storage as AccountInfoStorage
|
||||
|
||||
Note over User,Storage: 1. 初始化邮箱登录界面
|
||||
User->>VC: 选择邮箱登录
|
||||
VC->>VC: updateLoginType(LoginDisplayType_email)
|
||||
VC->>VC: setupEmailInputArea()
|
||||
VC->>IV: 创建邮箱输入框
|
||||
VC->>IV: 创建验证码输入框
|
||||
|
||||
Note over User,Storage: 2. 获取邮箱验证码
|
||||
User->>IV: 输入邮箱地址
|
||||
User->>IV: 点击"获取验证码"
|
||||
IV->>VC: handleTapGetMailVerificationCode
|
||||
VC->>VC: 验证邮箱地址非空
|
||||
VC->>P: sendMailVerificationCode(email, GetSmsType_Regist)
|
||||
P->>P: DES加密邮箱地址
|
||||
P->>API: emailGetCode(encryptedEmail, type=1)
|
||||
API-->>P: 验证码发送结果
|
||||
P-->>VC: emailCodeSucess / emailCodeFailure
|
||||
VC->>IV: startVerificationCountDown / endVerificationCountDown
|
||||
VC->>User: 显示成功/失败提示
|
||||
|
||||
Note over User,Storage: 3. 邮箱验证码登录
|
||||
User->>IV: 输入验证码
|
||||
IV->>VC: 输入内容变化回调
|
||||
VC->>VC: checkActionButtonStatus()
|
||||
VC->>User: 启用/禁用登录按钮
|
||||
User->>VC: 点击登录按钮
|
||||
VC->>VC: didTapActionButton()
|
||||
VC->>P: loginWithEmail(email, code)
|
||||
P->>P: DES加密邮箱地址
|
||||
P->>API: loginWithCode(email, code, ...)
|
||||
API-->>P: OAuth Token 响应
|
||||
P->>P: 解析 AccountModel
|
||||
P->>Storage: saveAccountInfo(accountModel)
|
||||
P-->>VC: loginSuccess / loginFailWithMsg
|
||||
VC->>User: 显示登录结果
|
||||
VC->>User: 跳转到主界面
|
||||
```
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 1. 数据加密
|
||||
- **邮箱地址加密**: 使用 DES 算法加密邮箱地址后传输
|
||||
```objc
|
||||
NSString *desEmail = [DESEncrypt encryptUseDES:email key:KeyWithType(KeyType_PasswordEncode)];
|
||||
```
|
||||
|
||||
### 2. 输入验证
|
||||
- **邮箱格式验证**: 通过 `UIKeyboardTypeEmailAddress` 键盘类型引导正确输入
|
||||
- **非空验证**: 邮箱和验证码都必须非空才能执行登录
|
||||
|
||||
### 3. 验证码安全
|
||||
- **时效性**: 验证码具有倒计时机制,防止重复获取
|
||||
- **类型标识**: 使用 `GetSmsType_Regist = 1` 标识登录验证码
|
||||
|
||||
### 4. 网络安全
|
||||
- **错误处理**: 完整的成功/失败回调机制
|
||||
- **加载状态**: `showLoading:YES` 防止重复请求
|
||||
- **错误提示**: `errorToast:YES` 显示网络错误
|
||||
|
||||
## 错误处理机制
|
||||
|
||||
### 1. 邮箱验证码获取错误
|
||||
```objc
|
||||
- (void)emailCodeFailure {
|
||||
[self.secondLineInputView endVerificationCountDown]; // 停止倒计时
|
||||
// 用户可以重新获取验证码
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 登录失败处理
|
||||
```objc
|
||||
- (void)loginFailWithMsg:(NSString *)msg {
|
||||
[self showSuccessToast:msg]; // 显示具体错误信息
|
||||
// 用户可以重新尝试登录
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 网络请求错误
|
||||
- **自动重试**: 用户可以手动重新点击获取验证码或登录
|
||||
- **错误提示**: 通过 Toast 显示具体错误信息
|
||||
- **状态恢复**: 失败后恢复按钮可点击状态
|
||||
|
||||
## 本地化支持
|
||||
|
||||
### 关键文本资源
|
||||
- `@"20.20.51_text_1"`: "邮箱登录"
|
||||
- `@"20.20.51_text_4"`: "请输入邮箱地址"
|
||||
- `@"20.20.51_text_7"`: "请输入验证码"
|
||||
- `@"XPLoginPhoneViewController2"`: "验证码已发送"
|
||||
- `@"XPLoginPhoneViewController1"`: "登录成功"
|
||||
|
||||
### 多语言支持
|
||||
- 简体中文 (`zh-Hant.lproj`)
|
||||
- 英文 (`en.lproj`)
|
||||
- 阿拉伯语 (`ar.lproj`)
|
||||
- 土耳其语 (`tr.lproj`)
|
||||
|
||||
## 依赖组件
|
||||
|
||||
### 外部框架
|
||||
- **MASConstraintMaker**: 自动布局
|
||||
- **ReactiveObjC**: 响应式编程(部分组件使用)
|
||||
|
||||
### 内部组件
|
||||
- **YMLocalizedString**: 本地化字符串管理
|
||||
- **DESEncrypt**: DES 加密工具
|
||||
- **AccountInfoStorage**: 账户信息存储
|
||||
- **HttpRequestHelper**: 网络请求管理
|
||||
|
||||
## 扩展和维护
|
||||
|
||||
### 新增功能建议
|
||||
1. **邮箱格式验证**: 添加正则表达式验证邮箱格式
|
||||
2. **验证码长度限制**: 限制验证码输入长度
|
||||
3. **自动填充**: 支持系统邮箱自动填充
|
||||
4. **记住邮箱**: 保存最近使用的邮箱地址
|
||||
|
||||
### 性能优化
|
||||
1. **请求去重**: 防止短时间内重复请求验证码
|
||||
2. **缓存机制**: 缓存验证码倒计时状态
|
||||
3. **网络优化**: 添加请求超时和重试机制
|
||||
|
||||
### 代码维护
|
||||
1. **常量管理**: 将硬编码字符串提取为常量
|
||||
2. **错误码统一**: 统一管理API错误码
|
||||
3. **日志记录**: 添加详细的操作日志
|
||||
|
||||
## 总结
|
||||
|
||||
邮箱验证码登录流程是一个完整的用户认证系统,包含了界面展示、验证码获取、用户登录、数据存储等完整环节。该流程具有良好的安全性、用户体验和错误处理机制,符合现代移动应用的认证标准。
|
||||
|
||||
通过本文档,开发人员可以全面了解邮箱登录的实现细节,便于后续的功能扩展和维护工作。
|
1
yana/APIs/email login flow.svg
Normal file
1
yana/APIs/email login flow.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 42 KiB |
@@ -10,32 +10,29 @@ struct EMailLoginFeature {
|
||||
var isLoading: Bool = false
|
||||
var isCodeLoading: Bool = false
|
||||
var errorMessage: String? = nil
|
||||
var codeCountdown: Int = 0
|
||||
var isCodeButtonEnabled: Bool = true
|
||||
var isCodeSent: Bool = false
|
||||
|
||||
// Debug模式下的默认值
|
||||
#if DEBUG
|
||||
init() {
|
||||
self.email = "85494536@gmail.com"
|
||||
self.email = "exzero@126.com"
|
||||
self.verificationCode = ""
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
enum Action {
|
||||
case emailChanged(String)
|
||||
case verificationCodeChanged(String)
|
||||
case getVerificationCodeTapped
|
||||
case getCodeResponse(Result<EmailGetCodeResponse, Error>)
|
||||
case loginButtonTapped(email: String, verificationCode: String)
|
||||
case loginResponse(Result<AccountModel, Error>)
|
||||
case forgotPasswordTapped
|
||||
case codeCountdownTick
|
||||
case setLoading(Bool)
|
||||
case setCodeLoading(Bool)
|
||||
case setError(String?)
|
||||
case startCodeCountdown
|
||||
case resetCodeCountdown
|
||||
case resetState
|
||||
}
|
||||
|
||||
@Dependency(\.apiService) var apiService
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
@@ -55,28 +52,57 @@ struct EMailLoginFeature {
|
||||
return .none
|
||||
}
|
||||
|
||||
guard isValidEmail(state.email) else {
|
||||
guard ValidationHelper.isValidEmail(state.email) else {
|
||||
state.errorMessage = "email_login.invalid_email".localized
|
||||
return .none
|
||||
}
|
||||
|
||||
state.isCodeLoading = true
|
||||
state.isCodeSent = false // 重置状态确保触发变化
|
||||
state.errorMessage = nil
|
||||
|
||||
return .run { send in
|
||||
// 模拟获取验证码API调用
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000) // 1秒
|
||||
await send(.setCodeLoading(false))
|
||||
await send(.startCodeCountdown)
|
||||
return .run { [email = state.email] send in
|
||||
do {
|
||||
guard let request = LoginHelper.createEmailGetCodeRequest(email: email) else {
|
||||
await send(.getCodeResponse(.failure(APIError.encryptionFailed)))
|
||||
return
|
||||
}
|
||||
|
||||
let response = try await apiService.request(request)
|
||||
await send(.getCodeResponse(.success(response)))
|
||||
|
||||
} catch {
|
||||
await send(.getCodeResponse(.failure(error)))
|
||||
}
|
||||
}
|
||||
|
||||
case .getCodeResponse(.success(let response)):
|
||||
state.isCodeLoading = false
|
||||
|
||||
if response.isSuccess {
|
||||
state.isCodeSent = true
|
||||
return .none
|
||||
} else {
|
||||
state.errorMessage = response.errorMessage
|
||||
return .none
|
||||
}
|
||||
|
||||
case .getCodeResponse(.failure(let error)):
|
||||
state.isCodeLoading = false
|
||||
if let apiError = error as? APIError {
|
||||
state.errorMessage = apiError.localizedDescription
|
||||
} else {
|
||||
state.errorMessage = "验证码发送失败,请检查网络连接"
|
||||
}
|
||||
return .none
|
||||
|
||||
case .loginButtonTapped(let email, let verificationCode):
|
||||
guard !email.isEmpty && !verificationCode.isEmpty else {
|
||||
state.errorMessage = "email_login.fields_required".localized
|
||||
return .none
|
||||
}
|
||||
|
||||
guard isValidEmail(email) else {
|
||||
guard ValidationHelper.isValidEmail(email) else {
|
||||
state.errorMessage = "email_login.invalid_email".localized
|
||||
return .none
|
||||
}
|
||||
@@ -85,61 +111,78 @@ struct EMailLoginFeature {
|
||||
state.errorMessage = nil
|
||||
|
||||
return .run { send in
|
||||
// 模拟登录API调用
|
||||
try await Task.sleep(nanoseconds: 2_000_000_000) // 2秒
|
||||
await send(.setLoading(false))
|
||||
// 这里应该处理实际的登录逻辑
|
||||
print("🔐 邮箱登录尝试: \(email), 验证码: \(verificationCode)")
|
||||
do {
|
||||
guard let request = LoginHelper.createEmailLoginRequest(email: email, code: verificationCode) else {
|
||||
await send(.loginResponse(.failure(APIError.encryptionFailed)))
|
||||
return
|
||||
}
|
||||
|
||||
case .forgotPasswordTapped:
|
||||
// 处理忘记密码逻辑
|
||||
print("📧 忘记密码点击")
|
||||
return .none
|
||||
let response = try await apiService.request(request)
|
||||
|
||||
case .codeCountdownTick:
|
||||
if state.codeCountdown > 0 {
|
||||
state.codeCountdown -= 1
|
||||
state.isCodeButtonEnabled = false
|
||||
if response.isSuccess, let loginData = response.data {
|
||||
guard let accountModel = AccountModel.from(loginData: loginData) else {
|
||||
await send(.loginResponse(.failure(APIError.invalidResponse)))
|
||||
return
|
||||
}
|
||||
|
||||
return .run { send in
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000) // 1秒
|
||||
await send(.codeCountdownTick)
|
||||
// 第二阶段:获取Ticket
|
||||
let ticketRequest = TicketHelper.createTicketRequest(
|
||||
accessToken: accountModel.accessToken ?? "",
|
||||
uid: accountModel.uid.flatMap { Int($0) }
|
||||
)
|
||||
let ticketResponse = try await apiService.request(ticketRequest)
|
||||
|
||||
if ticketResponse.isSuccess, let ticket = ticketResponse.ticket {
|
||||
let completeAccount = accountModel.withTicket(ticket)
|
||||
await send(.loginResponse(.success(completeAccount)))
|
||||
} else {
|
||||
await send(.loginResponse(.failure(APIError.ticketFailed)))
|
||||
}
|
||||
} else {
|
||||
state.isCodeButtonEnabled = true
|
||||
return .none
|
||||
await send(.loginResponse(.failure(APIError.custom(response.errorMessage))))
|
||||
}
|
||||
|
||||
case .setLoading(let isLoading):
|
||||
state.isLoading = isLoading
|
||||
} catch {
|
||||
await send(.loginResponse(.failure(error)))
|
||||
}
|
||||
}
|
||||
|
||||
case .loginResponse(.success(let accountModel)):
|
||||
state.isLoading = false
|
||||
|
||||
// 保存AccountModel到本地存储
|
||||
UserInfoManager.saveAccountModel(accountModel)
|
||||
|
||||
// 发送成功通知,触发导航到主界面
|
||||
NotificationCenter.default.post(name: .ticketSuccess, object: nil)
|
||||
|
||||
return .none
|
||||
|
||||
case .setCodeLoading(let isLoading):
|
||||
state.isCodeLoading = isLoading
|
||||
case .loginResponse(.failure(let error)):
|
||||
state.isLoading = false
|
||||
if let apiError = error as? APIError {
|
||||
state.errorMessage = apiError.localizedDescription
|
||||
} else {
|
||||
state.errorMessage = "登录失败,请重试"
|
||||
}
|
||||
return .none
|
||||
|
||||
case .setError(let error):
|
||||
state.errorMessage = error
|
||||
case .forgotPasswordTapped:
|
||||
return .none
|
||||
|
||||
case .startCodeCountdown:
|
||||
state.codeCountdown = 60
|
||||
state.isCodeButtonEnabled = false
|
||||
return .send(.codeCountdownTick)
|
||||
|
||||
case .resetCodeCountdown:
|
||||
state.codeCountdown = 0
|
||||
state.isCodeButtonEnabled = true
|
||||
case .resetState:
|
||||
state.email = ""
|
||||
state.verificationCode = ""
|
||||
state.isLoading = false
|
||||
state.isCodeLoading = false
|
||||
state.errorMessage = nil
|
||||
state.isCodeSent = false
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
private func isValidEmail(_ email: String) -> Bool {
|
||||
let emailRegex = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"
|
||||
let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
|
||||
return emailPredicate.evaluate(with: email)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
70
yana/Features/HomeFeature.swift
Normal file
70
yana/Features/HomeFeature.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
|
||||
@Reducer
|
||||
struct HomeFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var isInitialized = false
|
||||
var userInfo: UserInfo?
|
||||
var accountModel: AccountModel?
|
||||
var error: String?
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
case onAppear
|
||||
case loadUserInfo
|
||||
case userInfoLoaded(UserInfo?)
|
||||
case loadAccountModel
|
||||
case accountModelLoaded(AccountModel?)
|
||||
case logoutTapped
|
||||
case logout
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .onAppear:
|
||||
state.isInitialized = true
|
||||
return .concatenate(
|
||||
.send(.loadUserInfo),
|
||||
.send(.loadAccountModel)
|
||||
)
|
||||
|
||||
case .loadUserInfo:
|
||||
// 从本地存储加载用户信息
|
||||
let userInfo = UserInfoManager.getUserInfo()
|
||||
return .send(.userInfoLoaded(userInfo))
|
||||
|
||||
case let .userInfoLoaded(userInfo):
|
||||
state.userInfo = userInfo
|
||||
return .none
|
||||
|
||||
case .loadAccountModel:
|
||||
// 从本地存储加载账户信息
|
||||
let accountModel = UserInfoManager.getAccountModel()
|
||||
return .send(.accountModelLoaded(accountModel))
|
||||
|
||||
case let .accountModelLoaded(accountModel):
|
||||
state.accountModel = accountModel
|
||||
return .none
|
||||
|
||||
case .logoutTapped:
|
||||
return .send(.logout)
|
||||
|
||||
case .logout:
|
||||
// 清除所有认证数据
|
||||
UserInfoManager.clearAllAuthenticationData()
|
||||
|
||||
// 发送通知返回登录页面
|
||||
NotificationCenter.default.post(name: .homeLogout, object: nil)
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Extension
|
||||
extension Notification.Name {
|
||||
static let homeLogout = Notification.Name("homeLogout")
|
||||
}
|
@@ -11,13 +11,11 @@ struct IDLoginFeature {
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
// 新增:Ticket 相关状态
|
||||
var accessToken: String?
|
||||
var ticket: String?
|
||||
// 新增:Account Model 和 Ticket 相关状态
|
||||
var accountModel: AccountModel?
|
||||
var isTicketLoading = false
|
||||
var ticketError: String?
|
||||
var loginStep: LoginStep = .initial
|
||||
var uid: Int? // 修改:保存用户 uid,类型改为Int
|
||||
|
||||
enum LoginStep: Equatable {
|
||||
case initial // 初始状态
|
||||
@@ -101,22 +99,26 @@ struct IDLoginFeature {
|
||||
if response.isSuccess {
|
||||
// OAuth 认证成功,清除错误信息
|
||||
state.errorMessage = nil
|
||||
state.accessToken = response.data?.accessToken
|
||||
state.uid = response.data?.uid // 保存 uid
|
||||
|
||||
// 从响应数据创建 AccountModel
|
||||
if let loginData = response.data,
|
||||
let accountModel = AccountModel.from(loginData: loginData) {
|
||||
state.accountModel = accountModel
|
||||
|
||||
// 保存用户信息(如果有)
|
||||
if let userInfo = response.data?.userInfo {
|
||||
if let userInfo = loginData.userInfo {
|
||||
UserInfoManager.saveUserInfo(userInfo)
|
||||
}
|
||||
|
||||
print("✅ ID 登录 OAuth 认证成功")
|
||||
if let accessToken = response.data?.accessToken {
|
||||
print("🔑 Access Token: \(accessToken)")
|
||||
// 自动获取 ticket,传递 uid
|
||||
return .send(.requestTicket(accessToken: accessToken))
|
||||
}
|
||||
if let uid = response.data?.uid {
|
||||
print("🆔 用户 UID: \(uid)")
|
||||
print("🔑 Access Token: \(accountModel.accessToken ?? "nil")")
|
||||
print("🆔 用户 UID: \(accountModel.uid ?? "nil")")
|
||||
|
||||
// 自动获取 ticket
|
||||
return .send(.requestTicket(accessToken: accountModel.accessToken!))
|
||||
} else {
|
||||
state.errorMessage = "登录数据格式错误"
|
||||
state.loginStep = .failed
|
||||
}
|
||||
} else {
|
||||
state.errorMessage = response.errorMessage
|
||||
@@ -135,9 +137,10 @@ struct IDLoginFeature {
|
||||
state.ticketError = nil
|
||||
state.loginStep = .gettingTicket
|
||||
|
||||
return .run { [uid = state.uid] send in
|
||||
return .run { [accountModel = state.accountModel] send in
|
||||
do {
|
||||
// 使用 TicketHelper 创建请求,传递 uid
|
||||
// 从 AccountModel 获取 uid,转换为 Int 类型
|
||||
let uid = accountModel?.uid != nil ? Int(accountModel!.uid!) : nil
|
||||
let ticketRequest = TicketHelper.createTicketRequest(accessToken: accessToken, uid: uid)
|
||||
let response = try await apiService.request(ticketRequest)
|
||||
await send(.ticketResponse(.success(response)))
|
||||
@@ -151,26 +154,31 @@ struct IDLoginFeature {
|
||||
state.isTicketLoading = false
|
||||
if response.isSuccess {
|
||||
state.ticketError = nil
|
||||
state.ticket = response.ticket
|
||||
state.loginStep = .completed
|
||||
|
||||
print("✅ ID 登录完整流程成功")
|
||||
print("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
|
||||
|
||||
// 保存认证信息到本地存储(包括用户信息)
|
||||
if let accessToken = state.accessToken,
|
||||
let ticket = response.ticket {
|
||||
// 从之前的登录响应中获取用户信息
|
||||
let userInfo = UserInfoManager.getUserInfo()
|
||||
UserInfoManager.saveCompleteAuthenticationData(
|
||||
accessToken: accessToken,
|
||||
ticket: ticket,
|
||||
uid: state.uid,
|
||||
userInfo: userInfo
|
||||
)
|
||||
}
|
||||
// 更新 AccountModel 中的 ticket 并保存
|
||||
if let ticket = response.ticket {
|
||||
if var accountModel = state.accountModel {
|
||||
accountModel.ticket = ticket
|
||||
state.accountModel = accountModel
|
||||
|
||||
// TODO: 触发导航到主界面
|
||||
// 保存完整的 AccountModel
|
||||
UserInfoManager.saveAccountModel(accountModel)
|
||||
|
||||
// 发送 Ticket 获取成功通知,触发导航到主页面
|
||||
NotificationCenter.default.post(name: .ticketSuccess, object: nil)
|
||||
} else {
|
||||
print("❌ AccountModel 不存在,无法保存 ticket")
|
||||
state.ticketError = "内部错误:账户信息丢失"
|
||||
state.loginStep = .failed
|
||||
}
|
||||
} else {
|
||||
state.ticketError = "Ticket 为空"
|
||||
state.loginStep = .failed
|
||||
}
|
||||
|
||||
} else {
|
||||
state.ticketError = response.errorMessage
|
||||
@@ -194,9 +202,7 @@ struct IDLoginFeature {
|
||||
state.isTicketLoading = false
|
||||
state.errorMessage = nil
|
||||
state.ticketError = nil
|
||||
state.accessToken = nil
|
||||
state.ticket = nil
|
||||
state.uid = nil // 清除 uid
|
||||
state.accountModel = nil // 清除 AccountModel
|
||||
state.loginStep = .initial
|
||||
|
||||
// 清除本地存储的认证信息
|
||||
|
@@ -10,14 +10,13 @@ struct LoginFeature {
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var idLoginState = IDLoginFeature.State()
|
||||
var emailLoginState = EMailLoginFeature.State() // 新增:邮箱登录状态
|
||||
|
||||
// 新增:Ticket 相关状态
|
||||
var accessToken: String?
|
||||
var ticket: String?
|
||||
// 新增:Account Model 和 Ticket 相关状态
|
||||
var accountModel: AccountModel?
|
||||
var isTicketLoading = false
|
||||
var ticketError: String?
|
||||
var loginStep: LoginStep = .initial
|
||||
var uid: Int? // 修改:保存用户 uid,类型改为Int
|
||||
|
||||
enum LoginStep: Equatable {
|
||||
case initial // 初始状态
|
||||
@@ -36,12 +35,13 @@ struct LoginFeature {
|
||||
#endif
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
enum Action {
|
||||
case updateAccount(String)
|
||||
case updatePassword(String)
|
||||
case login
|
||||
case loginResponse(TaskResult<IDLoginResponse>)
|
||||
case idLogin(IDLoginFeature.Action)
|
||||
case emailLogin(EMailLoginFeature.Action) // 新增:邮箱登录action
|
||||
|
||||
// 新增:Ticket 相关 actions
|
||||
case requestTicket(accessToken: String)
|
||||
@@ -57,6 +57,10 @@ struct LoginFeature {
|
||||
IDLoginFeature()
|
||||
}
|
||||
|
||||
Scope(state: \.emailLoginState, action: \.emailLogin) {
|
||||
EMailLoginFeature()
|
||||
}
|
||||
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case let .updateAccount(account):
|
||||
@@ -99,20 +103,21 @@ struct LoginFeature {
|
||||
if response.isSuccess {
|
||||
// OAuth 认证成功,清除错误信息
|
||||
state.error = nil
|
||||
state.accessToken = response.data?.accessToken
|
||||
state.uid = response.data?.uid // 保存 uid
|
||||
|
||||
// 从响应数据创建 AccountModel
|
||||
if let loginData = response.data,
|
||||
let accountModel = AccountModel.from(loginData: loginData) {
|
||||
state.accountModel = accountModel
|
||||
|
||||
print("✅ OAuth 认证成功")
|
||||
if let accessToken = response.data?.accessToken {
|
||||
print("🔑 Access Token: \(accessToken)")
|
||||
// 自动获取 ticket,传递 uid
|
||||
return .send(.requestTicket(accessToken: accessToken))
|
||||
}
|
||||
if let userInfo = response.data?.userInfo {
|
||||
print("👤 用户信息: \(userInfo)")
|
||||
}
|
||||
if let uid = response.data?.uid {
|
||||
print("🆔 用户 UID: \(uid)")
|
||||
print("🔑 Access Token: \(accountModel.accessToken ?? "nil")")
|
||||
print("🆔 用户 UID: \(accountModel.uid ?? "nil")")
|
||||
|
||||
// 自动获取 ticket
|
||||
return .send(.requestTicket(accessToken: accountModel.accessToken!))
|
||||
} else {
|
||||
state.error = "登录数据格式错误"
|
||||
state.loginStep = .failed
|
||||
}
|
||||
} else {
|
||||
state.error = response.errorMessage
|
||||
@@ -131,9 +136,10 @@ struct LoginFeature {
|
||||
state.ticketError = nil
|
||||
state.loginStep = .gettingTicket
|
||||
|
||||
return .run { [uid = state.uid] send in
|
||||
return .run { [accountModel = state.accountModel] send in
|
||||
do {
|
||||
// 使用 TicketHelper 创建请求,传递 uid
|
||||
// 从 AccountModel 获取 uid,转换为 Int 类型
|
||||
let uid = accountModel?.uid != nil ? Int(accountModel!.uid!) : nil
|
||||
let ticketRequest = TicketHelper.createTicketRequest(accessToken: accessToken, uid: uid)
|
||||
let response = try await apiService.request(ticketRequest)
|
||||
await send(.ticketResponse(.success(response)))
|
||||
@@ -147,24 +153,31 @@ struct LoginFeature {
|
||||
state.isTicketLoading = false
|
||||
if response.isSuccess {
|
||||
state.ticketError = nil
|
||||
state.ticket = response.ticket
|
||||
state.loginStep = .completed
|
||||
|
||||
print("✅ 完整登录流程成功")
|
||||
print("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
|
||||
|
||||
// 保存认证信息到本地存储
|
||||
if let accessToken = state.accessToken,
|
||||
let ticket = response.ticket {
|
||||
UserInfoManager.saveCompleteAuthenticationData(
|
||||
accessToken: accessToken,
|
||||
ticket: ticket,
|
||||
uid: state.uid,
|
||||
userInfo: nil // LoginFeature 中没有用户信息,由具体的登录页面传递
|
||||
)
|
||||
}
|
||||
// 更新 AccountModel 中的 ticket 并保存
|
||||
if let ticket = response.ticket {
|
||||
if var accountModel = state.accountModel {
|
||||
accountModel.ticket = ticket
|
||||
state.accountModel = accountModel
|
||||
|
||||
// TODO: 触发导航到主界面
|
||||
// 保存完整的 AccountModel
|
||||
UserInfoManager.saveAccountModel(accountModel)
|
||||
|
||||
// 发送 Ticket 获取成功通知,触发导航到主页面
|
||||
NotificationCenter.default.post(name: .ticketSuccess, object: nil)
|
||||
} else {
|
||||
print("❌ AccountModel 不存在,无法保存 ticket")
|
||||
state.ticketError = "内部错误:账户信息丢失"
|
||||
state.loginStep = .failed
|
||||
}
|
||||
} else {
|
||||
state.ticketError = "Ticket 为空"
|
||||
state.loginStep = .failed
|
||||
}
|
||||
|
||||
} else {
|
||||
state.ticketError = response.errorMessage
|
||||
@@ -188,9 +201,7 @@ struct LoginFeature {
|
||||
state.isTicketLoading = false
|
||||
state.error = nil
|
||||
state.ticketError = nil
|
||||
state.accessToken = nil
|
||||
state.ticket = nil
|
||||
state.uid = nil // 清除 uid
|
||||
state.accountModel = nil // 清除 AccountModel
|
||||
state.loginStep = .initial
|
||||
|
||||
// 清除本地存储的认证信息
|
||||
@@ -201,6 +212,10 @@ struct LoginFeature {
|
||||
case .idLogin:
|
||||
// IDLogin动作由子feature处理
|
||||
return .none
|
||||
|
||||
case .emailLogin:
|
||||
// EmailLogin动作由子feature处理
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
274
yana/Features/RecoverPasswordFeature.swift
Normal file
274
yana/Features/RecoverPasswordFeature.swift
Normal file
@@ -0,0 +1,274 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
|
||||
@Reducer
|
||||
struct RecoverPasswordFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var email: String = ""
|
||||
var verificationCode: String = ""
|
||||
var newPassword: String = ""
|
||||
var isCodeLoading: Bool = false
|
||||
var isResetLoading: Bool = false
|
||||
var errorMessage: String? = nil
|
||||
var isCodeSent: Bool = false
|
||||
|
||||
#if DEBUG
|
||||
init() {
|
||||
self.email = "exzero@126.com"
|
||||
self.verificationCode = ""
|
||||
self.newPassword = ""
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case emailChanged(String)
|
||||
case verificationCodeChanged(String)
|
||||
case newPasswordChanged(String)
|
||||
case getVerificationCodeTapped
|
||||
case getCodeResponse(Result<EmailGetCodeResponse, Error>)
|
||||
case resetPasswordTapped
|
||||
case resetPasswordResponse(Result<ResetPasswordResponse, Error>)
|
||||
case resetState
|
||||
}
|
||||
|
||||
@Dependency(\.apiService) var apiService
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .emailChanged(let email):
|
||||
state.email = email
|
||||
state.errorMessage = nil
|
||||
return .none
|
||||
|
||||
case .verificationCodeChanged(let code):
|
||||
state.verificationCode = code
|
||||
state.errorMessage = nil
|
||||
return .none
|
||||
|
||||
case .newPasswordChanged(let password):
|
||||
state.newPassword = password
|
||||
state.errorMessage = nil
|
||||
return .none
|
||||
|
||||
case .getVerificationCodeTapped:
|
||||
guard !state.email.isEmpty else {
|
||||
state.errorMessage = "recover_password.email_required".localized
|
||||
return .none
|
||||
}
|
||||
|
||||
guard ValidationHelper.isValidEmail(state.email) else {
|
||||
state.errorMessage = "recover_password.invalid_email".localized
|
||||
return .none
|
||||
}
|
||||
|
||||
state.isCodeLoading = true
|
||||
state.isCodeSent = false
|
||||
state.errorMessage = nil
|
||||
|
||||
return .run { [email = state.email] send in
|
||||
do {
|
||||
guard let request = RecoverPasswordHelper.createEmailGetCodeRequest(email: email) else {
|
||||
await send(.getCodeResponse(.failure(APIError.encryptionFailed)))
|
||||
return
|
||||
}
|
||||
|
||||
let response = try await apiService.request(request)
|
||||
await send(.getCodeResponse(.success(response)))
|
||||
|
||||
} catch {
|
||||
await send(.getCodeResponse(.failure(error)))
|
||||
}
|
||||
}
|
||||
|
||||
case .getCodeResponse(.success(let response)):
|
||||
state.isCodeLoading = false
|
||||
|
||||
if response.isSuccess {
|
||||
state.isCodeSent = true
|
||||
return .none
|
||||
} else {
|
||||
state.errorMessage = response.errorMessage
|
||||
return .none
|
||||
}
|
||||
|
||||
case .getCodeResponse(.failure(let error)):
|
||||
state.isCodeLoading = false
|
||||
if let apiError = error as? APIError {
|
||||
state.errorMessage = apiError.localizedDescription
|
||||
} else {
|
||||
state.errorMessage = "recover_password.code_send_failed".localized
|
||||
}
|
||||
return .none
|
||||
|
||||
case .resetPasswordTapped:
|
||||
guard !state.email.isEmpty && !state.verificationCode.isEmpty && !state.newPassword.isEmpty else {
|
||||
state.errorMessage = "recover_password.fields_required".localized
|
||||
return .none
|
||||
}
|
||||
|
||||
guard ValidationHelper.isValidEmail(state.email) else {
|
||||
state.errorMessage = "recover_password.invalid_email".localized
|
||||
return .none
|
||||
}
|
||||
|
||||
guard ValidationHelper.isValidPassword(state.newPassword) else {
|
||||
state.errorMessage = "recover_password.invalid_password".localized
|
||||
return .none
|
||||
}
|
||||
|
||||
state.isResetLoading = true
|
||||
state.errorMessage = nil
|
||||
|
||||
return .run { [email = state.email, code = state.verificationCode, password = state.newPassword] send in
|
||||
do {
|
||||
guard let request = RecoverPasswordHelper.createResetPasswordRequest(
|
||||
email: email,
|
||||
code: code,
|
||||
newPassword: password
|
||||
) else {
|
||||
await send(.resetPasswordResponse(.failure(APIError.encryptionFailed)))
|
||||
return
|
||||
}
|
||||
|
||||
let response = try await apiService.request(request)
|
||||
await send(.resetPasswordResponse(.success(response)))
|
||||
|
||||
} catch {
|
||||
await send(.resetPasswordResponse(.failure(error)))
|
||||
}
|
||||
}
|
||||
|
||||
case .resetPasswordResponse(.success(let response)):
|
||||
state.isResetLoading = false
|
||||
|
||||
if response.isSuccess {
|
||||
// 密码重置成功,可以显示成功信息并导航回登录页面
|
||||
state.errorMessage = nil
|
||||
return .none
|
||||
} else {
|
||||
state.errorMessage = response.errorMessage
|
||||
return .none
|
||||
}
|
||||
|
||||
case .resetPasswordResponse(.failure(let error)):
|
||||
state.isResetLoading = false
|
||||
if let apiError = error as? APIError {
|
||||
state.errorMessage = apiError.localizedDescription
|
||||
} else {
|
||||
state.errorMessage = "recover_password.reset_failed".localized
|
||||
}
|
||||
return .none
|
||||
|
||||
case .resetState:
|
||||
state.email = ""
|
||||
state.verificationCode = ""
|
||||
state.newPassword = ""
|
||||
state.isCodeLoading = false
|
||||
state.isResetLoading = false
|
||||
state.errorMessage = nil
|
||||
state.isCodeSent = false
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Password Reset API Models
|
||||
|
||||
/// 密码重置响应模型
|
||||
struct ResetPasswordResponse: Codable, Equatable {
|
||||
let status: String?
|
||||
let message: String?
|
||||
let code: Int?
|
||||
let data: String?
|
||||
|
||||
/// 是否重置成功
|
||||
var isSuccess: Bool {
|
||||
return code == 200 || status?.lowercased() == "success"
|
||||
}
|
||||
|
||||
/// 错误消息(如果有)
|
||||
var errorMessage: String {
|
||||
return message ?? "recover_password.reset_failed".localized
|
||||
}
|
||||
}
|
||||
|
||||
/// 密码重置请求
|
||||
struct ResetPasswordRequest: APIRequestProtocol {
|
||||
typealias Response = ResetPasswordResponse
|
||||
|
||||
let endpoint = "/password/reset" // 假设的密码重置端点
|
||||
let method: HTTPMethod = .POST
|
||||
let includeBaseParameters = true
|
||||
let queryParameters: [String: String]?
|
||||
let bodyParameters: [String: Any]? = nil
|
||||
let timeout: TimeInterval = 30.0
|
||||
|
||||
/// 初始化密码重置请求
|
||||
/// - Parameters:
|
||||
/// - email: DES加密后的邮箱地址
|
||||
/// - code: 验证码
|
||||
/// - newPassword: DES加密后的新密码
|
||||
init(email: String, code: String, newPassword: String) {
|
||||
self.queryParameters = [
|
||||
"email": email,
|
||||
"code": code,
|
||||
"newPassword": newPassword
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recover Password Helper
|
||||
struct RecoverPasswordHelper {
|
||||
|
||||
/// 创建邮箱验证码获取请求(复用邮箱登录的实现)
|
||||
/// - Parameter email: 原始邮箱地址
|
||||
/// - Returns: 配置好的API请求,如果加密失败返回nil
|
||||
static func createEmailGetCodeRequest(email: String) -> EmailGetCodeRequest? {
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
||||
print("❌ 邮箱DES加密失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
print("🔐 密码恢复邮箱DES加密成功")
|
||||
print(" 原始邮箱: \(email)")
|
||||
print(" 加密邮箱: \(encryptedEmail)")
|
||||
|
||||
// 使用type=3表示密码重置验证码
|
||||
return EmailGetCodeRequest(emailAddress: email, type: 3)
|
||||
}
|
||||
|
||||
/// 创建密码重置请求
|
||||
/// - Parameters:
|
||||
/// - email: 原始邮箱地址
|
||||
/// - code: 验证码
|
||||
/// - newPassword: 新密码
|
||||
/// - Returns: 配置好的API请求,如果加密失败返回nil
|
||||
static func createResetPasswordRequest(email: String, code: String, newPassword: String) -> ResetPasswordRequest? {
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey),
|
||||
let encryptedPassword = DESEncrypt.encryptUseDES(newPassword, key: encryptionKey) else {
|
||||
print("❌ 密码重置DES加密失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
print("🔐 密码重置DES加密成功")
|
||||
print(" 原始邮箱: \(email)")
|
||||
print(" 加密邮箱: \(encryptedEmail)")
|
||||
print(" 验证码: \(code)")
|
||||
print(" 原始新密码: \(newPassword)")
|
||||
print(" 加密新密码: \(encryptedPassword)")
|
||||
|
||||
return ResetPasswordRequest(
|
||||
email: encryptedEmail,
|
||||
code: code,
|
||||
newPassword: encryptedPassword
|
||||
)
|
||||
}
|
||||
}
|
@@ -57,3 +57,22 @@
|
||||
"validation.password_required" = "Please enter your password";
|
||||
"error.encryption_failed" = "Encryption failed, please try again";
|
||||
"error.login_failed" = "Login failed, please check your credentials";
|
||||
|
||||
// MARK: - 密码恢复页面
|
||||
"recover_password.title" = "Recover Password";
|
||||
"recover_password.placeholder_email" = "Please enter email";
|
||||
"recover_password.placeholder_verification_code" = "Please enter verification code";
|
||||
"recover_password.placeholder_new_password" = "6-16 Digits + English Letters";
|
||||
"recover_password.get_code" = "Get";
|
||||
"recover_password.confirm_button" = "Confirm";
|
||||
"recover_password.email_required" = "Please enter email";
|
||||
"recover_password.invalid_email" = "Please enter a valid email address";
|
||||
"recover_password.fields_required" = "Please fill in all fields";
|
||||
"recover_password.invalid_password" = "Password must be 6-16 characters with digits and letters";
|
||||
"recover_password.code_send_failed" = "Failed to send verification code";
|
||||
"recover_password.reset_failed" = "Failed to reset password";
|
||||
"recover_password.reset_success" = "Password reset successfully";
|
||||
"recover_password.resetting" = "Resetting...";
|
||||
|
||||
// MARK: - 主页
|
||||
"home.title" = "Enjoy your Life Time";
|
||||
|
@@ -57,3 +57,22 @@
|
||||
"validation.password_required" = "请输入您的密码";
|
||||
"error.encryption_failed" = "加密失败,请重试";
|
||||
"error.login_failed" = "登录失败,请检查您的凭据";
|
||||
|
||||
// MARK: - 密码恢复页面
|
||||
"recover_password.title" = "找回密码";
|
||||
"recover_password.placeholder_email" = "请输入邮箱";
|
||||
"recover_password.placeholder_verification_code" = "请输入验证码";
|
||||
"recover_password.placeholder_new_password" = "6-16位数字+英文字母";
|
||||
"recover_password.get_code" = "获取";
|
||||
"recover_password.confirm_button" = "确认";
|
||||
"recover_password.email_required" = "请输入邮箱";
|
||||
"recover_password.invalid_email" = "请输入有效的邮箱地址";
|
||||
"recover_password.fields_required" = "请填写所有字段";
|
||||
"recover_password.invalid_password" = "密码必须是6-16位数字和字母";
|
||||
"recover_password.code_send_failed" = "验证码发送失败";
|
||||
"recover_password.reset_failed" = "密码重置失败";
|
||||
"recover_password.reset_success" = "密码重置成功";
|
||||
"recover_password.resetting" = "重置中...";
|
||||
|
||||
// MARK: - 主页
|
||||
"home.title" = "享受您的生活时光";
|
||||
|
41
yana/Utils/ValidationHelper.swift
Normal file
41
yana/Utils/ValidationHelper.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
struct ValidationHelper {
|
||||
|
||||
/// 验证邮箱地址格式是否正确
|
||||
/// - Parameter email: 邮箱地址
|
||||
/// - Returns: 是否为有效的邮箱格式
|
||||
static func isValidEmail(_ email: String) -> Bool {
|
||||
let emailRegex = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"
|
||||
let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
|
||||
return emailPredicate.evaluate(with: email)
|
||||
}
|
||||
|
||||
/// 验证手机号码格式是否正确(中国大陆)
|
||||
/// - Parameter phoneNumber: 手机号码
|
||||
/// - Returns: 是否为有效的手机号格式
|
||||
static func isValidPhoneNumber(_ phoneNumber: String) -> Bool {
|
||||
let phoneRegex = "^1[3-9]\\d{9}$"
|
||||
let phonePredicate = NSPredicate(format: "SELF MATCHES %@", phoneRegex)
|
||||
return phonePredicate.evaluate(with: phoneNumber)
|
||||
}
|
||||
|
||||
/// 验证密码强度
|
||||
/// - Parameter password: 密码
|
||||
/// - Returns: 是否满足密码强度要求(6-16位,包含字母和数字)
|
||||
static func isValidPassword(_ password: String) -> Bool {
|
||||
guard password.count >= 6 && password.count <= 16 else { return false }
|
||||
let hasLetter = password.rangeOfCharacter(from: .letters) != nil
|
||||
let hasNumber = password.rangeOfCharacter(from: .decimalDigits) != nil
|
||||
return hasLetter && hasNumber
|
||||
}
|
||||
|
||||
/// 验证验证码格式
|
||||
/// - Parameter code: 验证码
|
||||
/// - Returns: 是否为有效的验证码格式(4-6位数字)
|
||||
static func isValidVerificationCode(_ code: String) -> Bool {
|
||||
let codeRegex = "^\\d{4,6}$"
|
||||
let codePredicate = NSPredicate(format: "SELF MATCHES %@", codeRegex)
|
||||
return codePredicate.evaluate(with: code)
|
||||
}
|
||||
}
|
@@ -3,6 +3,7 @@ import ComposableArchitecture
|
||||
|
||||
struct AppRootView: View {
|
||||
@State private var shouldShowMainApp = false
|
||||
@State private var shouldShowHomePage = false
|
||||
|
||||
let splashStore = Store(
|
||||
initialState: SplashFeature.State()
|
||||
@@ -16,9 +17,19 @@ struct AppRootView: View {
|
||||
LoginFeature()
|
||||
}
|
||||
|
||||
let homeStore = Store(
|
||||
initialState: HomeFeature.State()
|
||||
) {
|
||||
HomeFeature()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if shouldShowMainApp {
|
||||
if shouldShowHomePage {
|
||||
// 主页
|
||||
HomeView(store: homeStore)
|
||||
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
|
||||
} else if shouldShowMainApp {
|
||||
// 登录界面
|
||||
LoginView(store: loginStore)
|
||||
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
|
||||
@@ -31,11 +42,25 @@ struct AppRootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ticketSuccess)) { _ in
|
||||
// Ticket 获取成功,切换到主页
|
||||
withAnimation(.easeInOut(duration: 0.5)) {
|
||||
shouldShowHomePage = true
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .homeLogout)) { _ in
|
||||
// 从主页登出,返回登录页面
|
||||
withAnimation(.easeInOut(duration: 0.5)) {
|
||||
shouldShowHomePage = false
|
||||
shouldShowMainApp = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let splashFinished = Notification.Name("splashFinished")
|
||||
static let ticketSuccess = Notification.Name("ticketSuccess")
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
@@ -8,6 +8,16 @@ struct EMailLoginView: View {
|
||||
// 使用本地@State管理UI状态
|
||||
@State private var email: String = ""
|
||||
@State private var verificationCode: String = ""
|
||||
@State private var codeCountdown: Int = 0
|
||||
@State private var timer: Timer?
|
||||
|
||||
// 管理输入框焦点状态
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
enum Field {
|
||||
case email
|
||||
case verificationCode
|
||||
}
|
||||
|
||||
// 计算登录按钮是否可用
|
||||
private var isLoginButtonEnabled: Bool {
|
||||
@@ -18,17 +28,22 @@ struct EMailLoginView: View {
|
||||
private var getCodeButtonText: String {
|
||||
if store.isCodeLoading {
|
||||
return ""
|
||||
} else if store.codeCountdown > 0 {
|
||||
return "\(store.codeCountdown)S"
|
||||
} else if codeCountdown > 0 {
|
||||
return "\(codeCountdown)S"
|
||||
} else {
|
||||
return "email_login.get_code".localized
|
||||
}
|
||||
}
|
||||
|
||||
// 计算获取验证码按钮是否可用
|
||||
private var isCodeButtonEnabled: Bool {
|
||||
return !store.isCodeLoading && codeCountdown == 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// 背景图片 - 使用与登录页面相同的"bg"
|
||||
// 背景图片
|
||||
Image("bg")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
@@ -83,6 +98,7 @@ struct EMailLoginView: View {
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.focused($focusedField, equals: .email)
|
||||
}
|
||||
|
||||
// 验证码输入框
|
||||
@@ -104,9 +120,13 @@ struct EMailLoginView: View {
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .verificationCode)
|
||||
|
||||
// 获取验证码按钮
|
||||
Button(action: {
|
||||
// 立即开始倒计时
|
||||
startCountdown()
|
||||
// 发送API请求
|
||||
store.send(.getVerificationCodeTapped)
|
||||
}) {
|
||||
ZStack {
|
||||
@@ -123,10 +143,10 @@ struct EMailLoginView: View {
|
||||
.frame(width: 60, height: 36)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(Color.white.opacity(store.isCodeButtonEnabled && !email.isEmpty ? 0.2 : 0.1))
|
||||
.fill(Color.white.opacity(isCodeButtonEnabled && !email.isEmpty ? 0.2 : 0.1))
|
||||
)
|
||||
}
|
||||
.disabled(!store.isCodeButtonEnabled || email.isEmpty || store.isCodeLoading)
|
||||
.disabled(!isCodeButtonEnabled || email.isEmpty || store.isCodeLoading)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
@@ -138,7 +158,6 @@ struct EMailLoginView: View {
|
||||
|
||||
// 登录按钮
|
||||
Button(action: {
|
||||
// 发送登录action时传递本地状态
|
||||
store.send(.loginButtonTapped(email: email, verificationCode: verificationCode))
|
||||
}) {
|
||||
ZStack {
|
||||
@@ -184,21 +203,59 @@ struct EMailLoginView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// 初始化时同步TCA状态到本地状态
|
||||
email = store.email
|
||||
verificationCode = store.verificationCode
|
||||
// 每次进入页面都重置状态
|
||||
store.send(.resetState)
|
||||
|
||||
email = ""
|
||||
verificationCode = ""
|
||||
codeCountdown = 0
|
||||
stopCountdown()
|
||||
|
||||
#if DEBUG
|
||||
// Debug环境下,确保默认数据已加载
|
||||
if email.isEmpty {
|
||||
email = "85494536@gmail.com"
|
||||
}
|
||||
if verificationCode.isEmpty {
|
||||
verificationCode = "784544"
|
||||
}
|
||||
print("🐛 Debug模式: 默认邮箱=\(email), 默认验证码=\(verificationCode)")
|
||||
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: store.isCodeLoading) { isCodeLoading in
|
||||
// 当API请求完成且成功时,自动将焦点切换到验证码输入框
|
||||
if !isCodeLoading && store.errorMessage == nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
focusedField = .verificationCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 倒计时管理
|
||||
private func startCountdown() {
|
||||
stopCountdown()
|
||||
|
||||
// 立即设置倒计时
|
||||
codeCountdown = 60
|
||||
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
DispatchQueue.main.async {
|
||||
if codeCountdown > 0 {
|
||||
codeCountdown -= 1
|
||||
} else {
|
||||
stopCountdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCountdown() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
124
yana/Views/HomeView.swift
Normal file
124
yana/Views/HomeView.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
|
||||
struct HomeView: View {
|
||||
let store: StoreOf<HomeFeature>
|
||||
@ObservedObject private var localizationManager = LocalizationManager.shared
|
||||
|
||||
var body: some View {
|
||||
WithPerceptionTracking {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// 背景图片 - 使用"bg"图片,全屏显示
|
||||
Image("bg")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.ignoresSafeArea(.all)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Navigation Bar 标题区域
|
||||
Text("home.title".localized)
|
||||
.font(.custom("PingFang SC-Semibold", size: 16))
|
||||
.foregroundColor(.white)
|
||||
.frame(
|
||||
width: 158,
|
||||
height: 22,
|
||||
alignment: .center
|
||||
) // 参考代码中的尺寸
|
||||
.padding(.top, 8)
|
||||
.padding(.horizontal)
|
||||
|
||||
// 中间内容区域
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
// 用户信息区域
|
||||
VStack(spacing: 16) {
|
||||
// 优先显示 UserInfo 中的用户名,否则显示通用欢迎信息
|
||||
if let userInfo = store.userInfo, let userName = userInfo.username {
|
||||
Text("欢迎, \(userName)")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
} else {
|
||||
Text("欢迎")
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
// 显示用户ID信息:优先 UserInfo,其次 AccountModel
|
||||
if let userInfo = store.userInfo, let userId = userInfo.userId {
|
||||
Text("ID: \(userId)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
} else if let accountModel = store.accountModel, let uid = accountModel.uid {
|
||||
Text("UID: \(uid)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
// 显示账户状态(如果有 AccountModel)
|
||||
if let accountModel = store.accountModel {
|
||||
VStack(spacing: 4) {
|
||||
if accountModel.hasValidSession {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("已登录")
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
.font(.caption)
|
||||
} else if accountModel.hasValidAuthentication {
|
||||
HStack {
|
||||
Image(systemName: "clock.circle.fill")
|
||||
.foregroundColor(.orange)
|
||||
Text("认证中")
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black.opacity(0.3))
|
||||
.cornerRadius(12)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 登出按钮
|
||||
Button(action: {
|
||||
store.send(.logoutTapped)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.right.square")
|
||||
Text("退出登录")
|
||||
}
|
||||
.font(.body)
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.red.opacity(0.7))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding(.bottom, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
store.send(.onAppear)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HomeView(
|
||||
store: Store(
|
||||
initialState: HomeFeature.State()
|
||||
) {
|
||||
HomeFeature()
|
||||
}
|
||||
)
|
||||
}
|
@@ -9,6 +9,7 @@ struct IDLoginView: View {
|
||||
@State private var userID: String = ""
|
||||
@State private var password: String = ""
|
||||
@State private var isPasswordVisible: Bool = false
|
||||
@State private var showRecoverPassword: Bool = false
|
||||
|
||||
// 计算登录按钮是否可用
|
||||
private var isLoginButtonEnabled: Bool {
|
||||
@@ -119,7 +120,7 @@ struct IDLoginView: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
store.send(.forgotPasswordTapped)
|
||||
showRecoverPassword = true
|
||||
}) {
|
||||
Text("id_login.forgot_password".localized)
|
||||
.font(.system(size: 14))
|
||||
@@ -177,6 +178,25 @@ struct IDLoginView: View {
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// 隐藏的NavigationLink - 导航到密码恢复页面
|
||||
NavigationLink(
|
||||
destination: RecoverPasswordView(
|
||||
store: Store(
|
||||
initialState: RecoverPasswordFeature.State()
|
||||
) {
|
||||
RecoverPasswordFeature()
|
||||
},
|
||||
onBack: {
|
||||
showRecoverPassword = false
|
||||
}
|
||||
)
|
||||
.navigationBarHidden(true),
|
||||
isActive: $showRecoverPassword
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
@@ -18,6 +18,7 @@ struct LoginView: View {
|
||||
@State private var showUserAgreement = false
|
||||
@State private var showPrivacyPolicy = false
|
||||
@State private var showIDLogin = false // 使用SwiftUI的@State管理导航
|
||||
@State private var showEmailLogin = false // 新增:邮箱登录导航状态
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
@@ -87,7 +88,7 @@ struct LoginView: View {
|
||||
iconColor: .blue,
|
||||
title: "login.email_login".localized
|
||||
) {
|
||||
// TODO: 处理Email登录
|
||||
showEmailLogin = true // 显示邮箱登录界面
|
||||
}
|
||||
}.padding(.top, max(0, topImageHeight+140))
|
||||
}
|
||||
@@ -130,6 +131,24 @@ struct LoginView: View {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
|
||||
// 新增:邮箱登录的NavigationLink
|
||||
NavigationLink(
|
||||
destination: EMailLoginView(
|
||||
store: store.scope(
|
||||
state: \.emailLoginState,
|
||||
action: \.emailLogin
|
||||
),
|
||||
onBack: {
|
||||
showEmailLogin = false // 直接设置SwiftUI状态
|
||||
}
|
||||
)
|
||||
.navigationBarHidden(true),
|
||||
isActive: $showEmailLogin // 使用SwiftUI的绑定
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
|
303
yana/Views/RecoverPasswordView.swift
Normal file
303
yana/Views/RecoverPasswordView.swift
Normal file
@@ -0,0 +1,303 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
|
||||
struct RecoverPasswordView: View {
|
||||
let store: StoreOf<RecoverPasswordFeature>
|
||||
let onBack: () -> Void
|
||||
|
||||
// 使用本地@State管理UI状态
|
||||
@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: {}
|
||||
)
|
||||
}
|
@@ -73,4 +73,103 @@ final class yanaAPITests: XCTestCase {
|
||||
XCTAssertTrue(successResponse.isSuccess, "响应应该标记为成功")
|
||||
XCTAssertEqual(successResponse.data?.accessToken, "test_token", "访问令牌应该正确")
|
||||
}
|
||||
|
||||
func testAccountModelFlow() {
|
||||
// 测试完整的 AccountModel 流程
|
||||
|
||||
// 1. 模拟 oauth/token 响应(基于用户提供的真实数据)
|
||||
let loginData = IDLoginData(
|
||||
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test",
|
||||
refreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh",
|
||||
tokenType: "bearer",
|
||||
expiresIn: 2591999,
|
||||
scope: "read write",
|
||||
userInfo: nil, // 真实API没有返回user_info
|
||||
uid: 3184,
|
||||
netEaseToken: "6fba51065b5e32ad18a935438517a1a9",
|
||||
jti: "d3a82ddb-ea6f-4d2f-8dc7-7bdb3d6b9e87"
|
||||
)
|
||||
|
||||
// 2. 从 IDLoginData 创建 AccountModel
|
||||
let accountModel = AccountModel.from(loginData: loginData)
|
||||
|
||||
XCTAssertNotNil(accountModel, "应该能从IDLoginData创建AccountModel")
|
||||
XCTAssertEqual(accountModel?.uid, "3184", "UID应该正确转换为字符串")
|
||||
XCTAssertEqual(accountModel?.accessToken, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test", "Access Token应该正确")
|
||||
XCTAssertEqual(accountModel?.tokenType, "bearer", "Token类型应该正确")
|
||||
XCTAssertEqual(accountModel?.netEaseToken, "6fba51065b5e32ad18a935438517a1a9", "网易云Token应该正确")
|
||||
XCTAssertNil(accountModel?.ticket, "初始ticket应该为空")
|
||||
|
||||
// 3. 检查认证状态
|
||||
XCTAssertTrue(accountModel?.hasValidAuthentication ?? false, "应该有有效的认证")
|
||||
XCTAssertFalse(accountModel?.hasValidSession ?? true, "没有ticket时不应该有有效会话")
|
||||
|
||||
// 4. 模拟获取ticket后更新AccountModel
|
||||
let ticketString = "eyJhbGciOiJIUzI1NiJ9.ticket"
|
||||
let updatedAccountModel = accountModel?.withTicket(ticketString)
|
||||
|
||||
XCTAssertNotNil(updatedAccountModel, "应该能更新ticket")
|
||||
XCTAssertEqual(updatedAccountModel?.ticket, ticketString, "Ticket应该正确设置")
|
||||
XCTAssertTrue(updatedAccountModel?.hasValidSession ?? false, "有ticket时应该有有效会话")
|
||||
|
||||
// 5. 测试持久化和加载
|
||||
if let finalAccountModel = updatedAccountModel {
|
||||
UserInfoManager.saveAccountModel(finalAccountModel)
|
||||
|
||||
let loadedAccountModel = UserInfoManager.getAccountModel()
|
||||
XCTAssertNotNil(loadedAccountModel, "应该能加载保存的AccountModel")
|
||||
XCTAssertEqual(loadedAccountModel?.uid, "3184", "加载的UID应该正确")
|
||||
XCTAssertEqual(loadedAccountModel?.accessToken, finalAccountModel.accessToken, "加载的Access Token应该正确")
|
||||
XCTAssertEqual(loadedAccountModel?.ticket, ticketString, "加载的Ticket应该正确")
|
||||
|
||||
// 6. 测试向后兼容性
|
||||
let userId = UserInfoManager.getCurrentUserId()
|
||||
let accessToken = UserInfoManager.getAccessToken()
|
||||
let ticket = UserInfoManager.getCurrentUserTicket()
|
||||
|
||||
XCTAssertEqual(userId, "3184", "向后兼容的用户ID应该正确")
|
||||
XCTAssertEqual(accessToken, finalAccountModel.accessToken, "向后兼容的Access Token应该正确")
|
||||
XCTAssertEqual(ticket, ticketString, "向后兼容的Ticket应该正确")
|
||||
}
|
||||
|
||||
// 7. 清理
|
||||
UserInfoManager.clearAllAuthenticationData()
|
||||
XCTAssertNil(UserInfoManager.getAccountModel(), "清理后AccountModel应该为空")
|
||||
XCTAssertNil(UserInfoManager.getCurrentUserId(), "清理后用户ID应该为空")
|
||||
}
|
||||
|
||||
func testAccountModelWithRealAPIData() {
|
||||
// 使用用户提供的真实API返回数据进行测试
|
||||
let realAPIResponseData: [String: Any] = [
|
||||
"uid": 3184,
|
||||
"jti": "d3a82ddb-ea6f-4d2f-8dc7-7bdb3d6b9e87",
|
||||
"token_type": "bearer",
|
||||
"scope": "read write",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiIyMzU2ODE0Iiwic2NvcGUiOlsicmVhZCIsIndyaXRlIl0sImF0aSI6ImQzYTgyZGRiLWVhNmYtNGQyZi04ZGM3LTdiZGIzZDZiOWU4NyIsImV4cCI6MTc1NTI0MjY5MiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9hY2NvdW50IiwiUk9MRV9NT0JJTEUiLCJST0xFX1VOSVRZIl0sImp0aSI6ImFiZjhjN2ZjLTllOWEtNDE2Yy04NTk2LTBkMWYxZWQyODU2MiIsImNsaWVudF9pZCI6ImVyYmFuLWNsaWVudCJ9.6i_9FnZvviuWYIoXDv9of7EDRyjRVxNbkiHayNUFxNw",
|
||||
"netEaseToken": "6fba51065b5e32ad18a935438517a1a9",
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTQ2Mzc4OTIsInVzZXJfbmFtZSI6IjIzNTY4MTQiLCJhdXRob3JpdGllcyI6WyJST0xFX2FjY291bnQiLCJST0xFX01PQklMRSIsIlJPTEVfVU5JVFkiXSwianRpIjoiZDNhODJkZGItZWE2Zi00ZDJmLThkYzctN2JkYjNkNmI5ZTg3IiwiY2xpZW50X2lkIjoiZXJiYW4tY2xpZW50Iiwic2NvcGUiOlsicmVhZCIsIndyaXRlIl19.ynUptBtAoPVXz4J1AO8LbaAhmFRF4UnF4C-Ggj6Izpc",
|
||||
"expires_in": 2591999
|
||||
]
|
||||
|
||||
// 模拟JSON解析
|
||||
do {
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: realAPIResponseData)
|
||||
let loginData = try JSONDecoder().decode(IDLoginData.self, from: jsonData)
|
||||
|
||||
// 创建AccountModel
|
||||
let accountModel = AccountModel.from(loginData: loginData)
|
||||
|
||||
XCTAssertNotNil(accountModel, "应该能从真实API数据创建AccountModel")
|
||||
XCTAssertEqual(accountModel?.uid, "3184", "真实API数据的UID应该正确")
|
||||
XCTAssertTrue(accountModel?.hasValidAuthentication ?? false, "真实API数据应该有有效认证")
|
||||
|
||||
print("✅ 真实API数据测试通过")
|
||||
print(" UID: \(accountModel?.uid ?? "nil")")
|
||||
print(" Access Token存在: \(accountModel?.accessToken != nil)")
|
||||
print(" Token类型: \(accountModel?.tokenType ?? "nil")")
|
||||
|
||||
} catch {
|
||||
XCTFail("解析真实API数据失败: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user