diff --git a/.cursor/rules/swift-assistant-style.mdc b/.cursor/rules/swift-assistant-style.mdc index 89e6229..d00ac0b 100644 --- a/.cursor/rules/swift-assistant-style.mdc +++ b/.cursor/rules/swift-assistant-style.mdc @@ -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. --- diff --git a/.cursor/rules/swift-swiftui-dev-rules.mdc b/.cursor/rules/swift-swiftui-dev-rules.mdc index ebf188f..915f72b 100644 --- a/.cursor/rules/swift-swiftui-dev-rules.mdc +++ b/.cursor/rules/swift-swiftui-dev-rules.mdc @@ -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 diff --git a/yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 0445d31..3ee8ee9 100644 --- a/yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -180,5 +180,53 @@ landmarkType = "24"> + + + + + + + + + + + + diff --git a/yana/APIs/APIEndpoints.swift b/yana/APIs/APIEndpoints.swift index ee4217b..dc32c89 100644 --- a/yana/APIs/APIEndpoints.swift +++ b/yana/APIs/APIEndpoints.swift @@ -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" diff --git a/yana/APIs/APIModels.swift b/yana/APIs/APIModels.swift index 24417b7..2a455c5 100644 --- a/yana/APIs/APIModels.swift +++ b/yana/APIs/APIModels.swift @@ -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 diff --git a/yana/APIs/LoginModels.swift b/yana/APIs/LoginModels.swift index 10afda4..aac54b8 100644 --- a/yana/APIs/LoginModels.swift +++ b/yana/APIs/LoginModels.swift @@ -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) + } +} diff --git a/yana/APIs/email login flow.md b/yana/APIs/email login flow.md new file mode 100644 index 0000000..26e6d4d --- /dev/null +++ b/yana/APIs/email login flow.md @@ -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. **日志记录**: 添加详细的操作日志 + +## 总结 + +邮箱验证码登录流程是一个完整的用户认证系统,包含了界面展示、验证码获取、用户登录、数据存储等完整环节。该流程具有良好的安全性、用户体验和错误处理机制,符合现代移动应用的认证标准。 + +通过本文档,开发人员可以全面了解邮箱登录的实现细节,便于后续的功能扩展和维护工作。 \ No newline at end of file diff --git a/yana/APIs/email login flow.svg b/yana/APIs/email login flow.svg new file mode 100644 index 0000000..d09637f --- /dev/null +++ b/yana/APIs/email login flow.svg @@ -0,0 +1 @@ +PILoginManagerAccountInfoStorageApi+LoginLoginPresenterLoginInputItemViewLoginTypesViewControllerLoginViewController用户PILoginManagerAccountInfoStorageApi+LoginLoginPresenterLoginInputItemViewLoginTypesViewControllerLoginViewController用户邮箱验证码登录完整流程1. 进入邮箱登录界面2. 获取邮箱验证码流程alt[验证码发送成功][验证码发送失败]3. 邮箱验证码登录流程alt[登录成功][登录失败]点击邮箱登录按钮didTapEntrcyButton(LoginType_Email)pushViewController + updateLoginType(email)setupEmailInputArea()创建邮箱输入框(LoginInputType_email)创建验证码输入框(LoginInputType_verificationCode)显示邮箱登录界面输入邮箱地址点击"获取验证码"按钮handleItemAction callbackhandleTapGetMailVerificationCode()验证邮箱地址非空sendMailVerificationCode(email, GetSmsType_Regist)DES加密邮箱地址emailGetCode(encryptedEmail, type=1)POST /email/getCode返回验证码发送结果emailCodeSucess callbackstartVerificationCountDown()displayKeyboard()显示"验证码已发送"提示emailCodeFailure callbackendVerificationCountDown()显示错误提示输入验证码handleSecondInputContentUpdate callbackcheckActionButtonStatus()启用登录按钮点击登录按钮didTapActionButton()loginWithEmail(email, code)DES加密邮箱地址loginWithCode(email, code, client_secret, ...)POST /oauth/token返回OAuth Token响应解析AccountModelsaveAccountInfo(accountModel)loginSuccess callback显示"登录成功"提示loginWithVC(self, isLoginPhone:NO)跳转到主界面loginFailWithMsg callback显示具体错误信息 \ No newline at end of file diff --git a/yana/Features/EMailLoginFeature.swift b/yana/Features/EMailLoginFeature.swift index 869a24b..344c0b6 100644 --- a/yana/Features/EMailLoginFeature.swift +++ b/yana/Features/EMailLoginFeature.swift @@ -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) case loginButtonTapped(email: String, verificationCode: String) + case loginResponse(Result) 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 { 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 + } + + let response = try await apiService.request(request) + + if response.isSuccess, let loginData = response.data { + guard let accountModel = AccountModel.from(loginData: loginData) else { + await send(.loginResponse(.failure(APIError.invalidResponse))) + return + } + + // 第二阶段:获取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 { + await send(.loginResponse(.failure(APIError.custom(response.errorMessage)))) + } + + } 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 .loginResponse(.failure(let error)): + state.isLoading = false + if let apiError = error as? APIError { + state.errorMessage = apiError.localizedDescription + } else { + state.errorMessage = "登录失败,请重试" + } + return .none + case .forgotPasswordTapped: - // 处理忘记密码逻辑 - print("📧 忘记密码点击") return .none - case .codeCountdownTick: - if state.codeCountdown > 0 { - state.codeCountdown -= 1 - state.isCodeButtonEnabled = false - - return .run { send in - try await Task.sleep(nanoseconds: 1_000_000_000) // 1秒 - await send(.codeCountdownTick) - } - } else { - state.isCodeButtonEnabled = true - return .none - } - - case .setLoading(let isLoading): - state.isLoading = isLoading - return .none - - case .setCodeLoading(let isLoading): - state.isCodeLoading = isLoading - return .none - - case .setError(let error): - state.errorMessage = error - 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) - } -} \ No newline at end of file + +} + + diff --git a/yana/Features/HomeFeature.swift b/yana/Features/HomeFeature.swift new file mode 100644 index 0000000..423becd --- /dev/null +++ b/yana/Features/HomeFeature.swift @@ -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 { + 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") +} \ No newline at end of file diff --git a/yana/Features/IDLoginFeature.swift b/yana/Features/IDLoginFeature.swift index f05fcac..1d617f6 100644 --- a/yana/Features/IDLoginFeature.swift +++ b/yana/Features/IDLoginFeature.swift @@ -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 - // 保存用户信息(如果有) - if let userInfo = response.data?.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)") + // 从响应数据创建 AccountModel + if let loginData = response.data, + let accountModel = AccountModel.from(loginData: loginData) { + state.accountModel = accountModel + + // 保存用户信息(如果有) + if let userInfo = loginData.userInfo { + UserInfoManager.saveUserInfo(userInfo) + } + + print("✅ ID 登录 OAuth 认证成功") + 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,27 +154,32 @@ 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 + + // 保存完整的 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 } - // TODO: 触发导航到主界面 - } else { state.ticketError = response.errorMessage state.loginStep = .failed @@ -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 // 清除本地存储的认证信息 diff --git a/yana/Features/LoginFeature.swift b/yana/Features/LoginFeature.swift index b0399ea..3e530f3 100644 --- a/yana/Features/LoginFeature.swift +++ b/yana/Features/LoginFeature.swift @@ -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) 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 - 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)") + // 从响应数据创建 AccountModel + if let loginData = response.data, + let accountModel = AccountModel.from(loginData: loginData) { + state.accountModel = accountModel + + print("✅ OAuth 认证成功") + 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,25 +153,32 @@ 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 + + // 保存完整的 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 } - // TODO: 触发导航到主界面 - } else { state.ticketError = response.errorMessage state.loginStep = .failed @@ -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 } } } diff --git a/yana/Features/RecoverPasswordFeature.swift b/yana/Features/RecoverPasswordFeature.swift new file mode 100644 index 0000000..e0fe672 --- /dev/null +++ b/yana/Features/RecoverPasswordFeature.swift @@ -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) + case resetPasswordTapped + case resetPasswordResponse(Result) + case resetState + } + + @Dependency(\.apiService) var apiService + + var body: some ReducerOf { + 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 + ) + } +} \ No newline at end of file diff --git a/yana/Resources/Localizable.strings b/yana/Resources/Localizable.strings index b85e582..8b48b84 100644 --- a/yana/Resources/Localizable.strings +++ b/yana/Resources/Localizable.strings @@ -56,4 +56,23 @@ "validation.id_required" = "Please enter your ID"; "validation.password_required" = "Please enter your password"; "error.encryption_failed" = "Encryption failed, please try again"; -"error.login_failed" = "Login failed, please check your credentials"; +"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"; diff --git a/yana/Resources/zh-Hans.lproj/Localizable.strings b/yana/Resources/zh-Hans.lproj/Localizable.strings index ffc3e45..9eb453e 100644 --- a/yana/Resources/zh-Hans.lproj/Localizable.strings +++ b/yana/Resources/zh-Hans.lproj/Localizable.strings @@ -56,4 +56,23 @@ "validation.id_required" = "请输入您的ID"; "validation.password_required" = "请输入您的密码"; "error.encryption_failed" = "加密失败,请重试"; -"error.login_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" = "享受您的生活时光"; diff --git a/yana/Utils/ValidationHelper.swift b/yana/Utils/ValidationHelper.swift new file mode 100644 index 0000000..c66c6a4 --- /dev/null +++ b/yana/Utils/ValidationHelper.swift @@ -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) + } +} \ No newline at end of file diff --git a/yana/Views/AppRootView.swift b/yana/Views/AppRootView.swift index 7b06459..a3f79fa 100644 --- a/yana/Views/AppRootView.swift +++ b/yana/Views/AppRootView.swift @@ -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 { diff --git a/yana/Views/EMailLoginView.swift b/yana/Views/EMailLoginView.swift index aba714d..452a88c 100644 --- a/yana/Views/EMailLoginView.swift +++ b/yana/Views/EMailLoginView.swift @@ -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 } } @@ -211,4 +268,4 @@ struct EMailLoginView: View { }, onBack: {} ) -} \ No newline at end of file +} diff --git a/yana/Views/HomeView.swift b/yana/Views/HomeView.swift new file mode 100644 index 0000000..ccee78b --- /dev/null +++ b/yana/Views/HomeView.swift @@ -0,0 +1,124 @@ +import SwiftUI +import ComposableArchitecture + +struct HomeView: View { + let store: StoreOf + @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() + } + ) +} diff --git a/yana/Views/IDLoginView.swift b/yana/Views/IDLoginView.swift index 1c45671..16c1730 100644 --- a/yana/Views/IDLoginView.swift +++ b/yana/Views/IDLoginView.swift @@ -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 { diff --git a/yana/Views/LoginView.swift b/yana/Views/LoginView.swift index 8ff3f88..215ec4d 100644 --- a/yana/Views/LoginView.swift +++ b/yana/Views/LoginView.swift @@ -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) diff --git a/yana/Views/RecoverPasswordView.swift b/yana/Views/RecoverPasswordView.swift new file mode 100644 index 0000000..e2e4823 --- /dev/null +++ b/yana/Views/RecoverPasswordView.swift @@ -0,0 +1,303 @@ +import SwiftUI +import ComposableArchitecture + +struct RecoverPasswordView: View { + let store: StoreOf + 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: {} + ) +} \ No newline at end of file diff --git a/yanaAPITests/yanaAPITests.swift b/yanaAPITests/yanaAPITests.swift index 0394312..8a86ace 100644 --- a/yanaAPITests/yanaAPITests.swift +++ b/yanaAPITests/yanaAPITests.swift @@ -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)") + } + } }