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 @@
+
\ 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)")
+ }
+ }
}