feat: 更新项目配置和功能模块
- 修改Package.swift以支持iOS 15和macOS 12。 - 更新swift-tca-architecture-guidelines.mdc中的alwaysApply设置为false。 - 注释掉AppDelegate中的NIMSDK导入,移除不再使用的NIMConfigurationManager和NIMSessionManager文件。 - 添加新的API相关文件,包括EMailLoginFeature、IDLoginFeature和相关视图,增强登录功能。 - 更新APIConstants和APIEndpoints以反映新的API路径。 - 添加本地化支持文件,包含英文和中文简体的本地化字符串。 - 新增字体管理和安全工具类,支持AES和DES加密。 - 更新Xcode项目配置,调整版本号和启动画面设置。
@@ -15,7 +15,7 @@
|
||||
|
||||
| 环境 | 地址 | 说明 |
|
||||
|------|------|------|
|
||||
| 生产环境 | `https://api.hfighting.com` | 正式服务器 |
|
||||
| 生产环境 | `https://api.epartylive.com` | 正式服务器 |
|
||||
| 测试环境 | `http://beta.api.molistar.xyz` | 开发测试服务器 |
|
||||
| 图片服务 | `https://image.hfighting.com` | 静态资源服务器 |
|
||||
|
||||
@@ -177,4 +177,4 @@ YuMi iOS 项目的 API 架构设计了完整的网络请求体系,包含:
|
||||
- 🛠️ **开发支持**: 环境切换、错误追踪、调试日志
|
||||
- 🏗️ **架构清晰**: 模块化设计、统一管理、易于维护
|
||||
|
||||
这种设计确保了 API 请求的安全性、稳定性和高性能,为应用提供了可靠的网络服务基础。
|
||||
这种设计确保了 API 请求的安全性、稳定性和高性能,为应用提供了可靠的网络服务基础。
|
||||
|
@@ -3,17 +3,13 @@ import Foundation
|
||||
/// API 常量定义
|
||||
///
|
||||
/// 集中管理 API 相关的常量值,包括:
|
||||
/// - 服务器地址
|
||||
/// - 通用请求头
|
||||
/// - API 端点路径
|
||||
/// - 通用参数
|
||||
///
|
||||
/// 注意:此文件与 APIConfiguration 有部分重复,
|
||||
/// 注意:baseURL已统一到AppConfig中管理
|
||||
/// 建议后续重构时统一到 APIConfiguration 中
|
||||
enum APIConstants {
|
||||
// MARK: - Base URLs
|
||||
/// 测试环境服务器地址
|
||||
static let baseURL = "http://beta.api.molistar.xyz"
|
||||
|
||||
// MARK: - Common Headers
|
||||
/// 通用请求头配置
|
||||
@@ -34,7 +30,7 @@ enum APIConstants {
|
||||
/// 客户端初始化接口
|
||||
static let clientInit = "/client/init"
|
||||
/// 用户登录接口
|
||||
static let login = "/user/login"
|
||||
static let login = "/oauth/token"
|
||||
}
|
||||
|
||||
// MARK: - Common Parameters
|
||||
|
@@ -16,8 +16,11 @@ import Foundation
|
||||
enum APIEndpoint: String, CaseIterable {
|
||||
case config = "/client/config"
|
||||
case configInit = "/client/init"
|
||||
case login = "/auth/login"
|
||||
// 可以继续添加其他端点
|
||||
case login = "/oauth/token"
|
||||
case ticket = "/oauth/ticket"
|
||||
// Web 页面路径
|
||||
case userAgreement = "/modules/rule/protocol.html"
|
||||
case privacyPolicy = "/modules/rule/privacy-wap.html"
|
||||
|
||||
var path: String {
|
||||
return self.rawValue
|
||||
@@ -39,10 +42,38 @@ enum APIEndpoint: String, CaseIterable {
|
||||
/// - 防止资源超限的保护机制
|
||||
/// - 自动添加认证和设备信息头部
|
||||
struct APIConfiguration {
|
||||
static let baseURL = "http://beta.api.molistar.xyz"
|
||||
static var baseURL: String { AppConfig.baseURL }
|
||||
static let timeout: TimeInterval = 30.0
|
||||
static let maxDataSize: Int = 50 * 1024 * 1024 // 50MB 限制,防止资源超限
|
||||
|
||||
/// 构建完整的 URL
|
||||
/// - Parameter endpoint: API 端点
|
||||
/// - Returns: 完整的 URL 字符串
|
||||
static func fullURL(for endpoint: APIEndpoint) -> String {
|
||||
return baseURL + endpoint.path
|
||||
}
|
||||
|
||||
/// 构建完整的 URL 对象
|
||||
/// - Parameter endpoint: API 端点
|
||||
/// - Returns: URL 对象,如果构建失败返回 nil
|
||||
static func url(for endpoint: APIEndpoint) -> URL? {
|
||||
return URL(string: fullURL(for: endpoint))
|
||||
}
|
||||
|
||||
/// 构建Web页面的完整 URL
|
||||
/// - Parameter endpoint: API 端点
|
||||
/// - Returns: 完整的Web页面 URL 字符串
|
||||
static func fullWebURL(for endpoint: APIEndpoint) -> String {
|
||||
return baseURL + AppConfig.webPathPrefix + endpoint.path
|
||||
}
|
||||
|
||||
/// 构建Web页面的完整 URL 对象
|
||||
/// - Parameter endpoint: API 端点
|
||||
/// - Returns: Web页面 URL 对象,如果构建失败返回 nil
|
||||
static func webURL(for endpoint: APIEndpoint) -> URL? {
|
||||
return URL(string: fullWebURL(for: endpoint))
|
||||
}
|
||||
|
||||
/// 默认请求头配置
|
||||
///
|
||||
/// 返回所有 API 请求都需要的基础请求头,包括:
|
||||
@@ -58,7 +89,8 @@ struct APIConfiguration {
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip, br",
|
||||
"Accept-Language": Locale.current.languageCode ?? "en",
|
||||
"App-Version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||
"App-Version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0",
|
||||
"User-Agent": "YuMi/20.20.61 (iPhone; iOS 16.4; Scale/2.00)"
|
||||
]
|
||||
|
||||
// 添加用户认证相关 headers(如果存在)
|
||||
|
@@ -118,7 +118,7 @@ struct BaseRequest: Codable {
|
||||
self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||
|
||||
// 应用名称
|
||||
self.app = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "yana"
|
||||
self.app = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "eparty"
|
||||
|
||||
// 网络类型检测(WiFi=2, 蜂窝网络=1)
|
||||
self.netType = NetworkTypeDetector.getCurrentNetworkType()
|
||||
@@ -131,7 +131,7 @@ struct BaseRequest: Codable {
|
||||
|
||||
// 渠道信息
|
||||
#if DEBUG
|
||||
self.channel = "TestFlight"
|
||||
self.channel = "molistar_enterprise"
|
||||
#else
|
||||
self.channel = "appstore"
|
||||
#endif
|
||||
@@ -186,9 +186,10 @@ struct BaseRequest: Codable {
|
||||
}
|
||||
|
||||
// 3. 按 key 升序排序并拼接
|
||||
// 拼接格式 "key0=value0&key1=value1&key2=value2"
|
||||
let sortedKeys = filteredParams.keys.sorted()
|
||||
let paramString = sortedKeys.map { key in
|
||||
"\(key)=\(filteredParams[key] ?? "")"
|
||||
"\(key)=\(String(describing: filteredParams[key] ?? ""))"
|
||||
}.joined(separator: "&")
|
||||
|
||||
// 4. 添加密钥
|
||||
@@ -205,7 +206,7 @@ struct NetworkTypeDetector {
|
||||
static func getCurrentNetworkType() -> Int {
|
||||
// WiFi = 2, 蜂窝网络 = 1
|
||||
// 这里是简化实现,实际应该检测网络状态
|
||||
return 1 // 默认蜂窝网络
|
||||
return 2 // 默认蜂窝网络
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,16 +225,136 @@ struct CarrierInfoManager {
|
||||
|
||||
// MARK: - User Info Manager (for Headers)
|
||||
struct UserInfoManager {
|
||||
static func getCurrentUserId() -> String? {
|
||||
// 从存储中获取当前用户 ID
|
||||
// 实际实现应该从 AccountInfoStorage 或类似的地方获取
|
||||
return nil
|
||||
private static let userDefaults = UserDefaults.standard
|
||||
|
||||
// MARK: - Storage Keys
|
||||
private enum StorageKeys {
|
||||
static let userId = "user_id"
|
||||
static let accessToken = "access_token"
|
||||
static let ticket = "user_ticket"
|
||||
static let userInfo = "user_info"
|
||||
}
|
||||
|
||||
// MARK: - User ID Management
|
||||
static func getCurrentUserId() -> String? {
|
||||
return userDefaults.string(forKey: StorageKeys.userId)
|
||||
}
|
||||
|
||||
static func saveUserId(_ userId: String) {
|
||||
userDefaults.set(userId, forKey: StorageKeys.userId)
|
||||
userDefaults.synchronize()
|
||||
print("💾 保存用户ID: \(userId)")
|
||||
}
|
||||
|
||||
// MARK: - Access Token Management
|
||||
static func getAccessToken() -> String? {
|
||||
return userDefaults.string(forKey: StorageKeys.accessToken)
|
||||
}
|
||||
|
||||
static func saveAccessToken(_ accessToken: String) {
|
||||
userDefaults.set(accessToken, forKey: StorageKeys.accessToken)
|
||||
userDefaults.synchronize()
|
||||
print("💾 保存 Access Token")
|
||||
}
|
||||
|
||||
// MARK: - Ticket Management (内存存储)
|
||||
private static var currentTicket: String?
|
||||
|
||||
static func getCurrentUserTicket() -> String? {
|
||||
// 从存储中获取当前用户认证票据
|
||||
// 实际实现应该从 AccountInfoStorage 或类似的地方获取
|
||||
return nil
|
||||
return currentTicket
|
||||
}
|
||||
|
||||
static func saveTicket(_ ticket: String) {
|
||||
currentTicket = ticket
|
||||
print("💾 保存 Ticket 到内存")
|
||||
}
|
||||
|
||||
static func clearTicket() {
|
||||
currentTicket = nil
|
||||
print("🗑️ 清除 Ticket")
|
||||
}
|
||||
|
||||
// MARK: - User Info Management
|
||||
static func saveUserInfo(_ userInfo: UserInfo) {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(userInfo)
|
||||
userDefaults.set(data, forKey: StorageKeys.userInfo)
|
||||
userDefaults.synchronize()
|
||||
|
||||
// 同时保存用户ID
|
||||
if let userId = userInfo.userId {
|
||||
saveUserId(userId)
|
||||
}
|
||||
|
||||
print("💾 保存用户信息成功")
|
||||
} catch {
|
||||
print("❌ 保存用户信息失败: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
static func getUserInfo() -> UserInfo? {
|
||||
guard let data = userDefaults.data(forKey: StorageKeys.userInfo) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
return try JSONDecoder().decode(UserInfo.self, from: data)
|
||||
} catch {
|
||||
print("❌ 解析用户信息失败: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Complete Authentication Data Management
|
||||
/// 保存完整的认证信息(OAuth Token + Ticket + 用户信息)
|
||||
static func saveCompleteAuthenticationData(
|
||||
accessToken: String,
|
||||
ticket: String,
|
||||
uid: Int?, // 修改:从String?改为Int?
|
||||
userInfo: UserInfo?
|
||||
) {
|
||||
saveAccessToken(accessToken)
|
||||
saveTicket(ticket)
|
||||
|
||||
if let uid = uid {
|
||||
saveUserId("\(uid)") // 转换为字符串保存
|
||||
}
|
||||
|
||||
if let userInfo = userInfo {
|
||||
saveUserInfo(userInfo)
|
||||
}
|
||||
|
||||
print("✅ 完整认证信息保存成功")
|
||||
}
|
||||
|
||||
/// 检查是否有有效的认证信息
|
||||
static func hasValidAuthentication() -> Bool {
|
||||
return getAccessToken() != nil && getCurrentUserTicket() != nil
|
||||
}
|
||||
|
||||
/// 清除所有认证信息
|
||||
static func clearAllAuthenticationData() {
|
||||
userDefaults.removeObject(forKey: StorageKeys.userId)
|
||||
userDefaults.removeObject(forKey: StorageKeys.accessToken)
|
||||
userDefaults.removeObject(forKey: StorageKeys.userInfo)
|
||||
clearTicket()
|
||||
userDefaults.synchronize()
|
||||
|
||||
print("🗑️ 清除所有认证信息")
|
||||
}
|
||||
|
||||
/// 尝试恢复 Ticket(用于应用重启后)
|
||||
static func restoreTicketIfNeeded() async -> Bool {
|
||||
guard let accessToken = getAccessToken(),
|
||||
getCurrentUserTicket() == nil else {
|
||||
return false
|
||||
}
|
||||
|
||||
print("🔄 尝试使用 Access Token 恢复 Ticket...")
|
||||
|
||||
// 这里需要注入 APIService 依赖,暂时返回 false
|
||||
// 实际实现中应该调用 TicketHelper.createTicketRequest
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +388,7 @@ protocol APIRequestProtocol {
|
||||
var queryParameters: [String: String]? { get }
|
||||
var bodyParameters: [String: Any]? { get }
|
||||
var headers: [String: String]? { get }
|
||||
var customHeaders: [String: String]? { get } // 新增:自定义请求头
|
||||
var timeout: TimeInterval { get }
|
||||
var includeBaseParameters: Bool { get }
|
||||
}
|
||||
@@ -275,6 +397,7 @@ extension APIRequestProtocol {
|
||||
var timeout: TimeInterval { 30.0 }
|
||||
var includeBaseParameters: Bool { true }
|
||||
var headers: [String: String]? { nil }
|
||||
var customHeaders: [String: String]? { nil } // 新增:默认实现
|
||||
}
|
||||
|
||||
// MARK: - Generic API Response
|
||||
@@ -285,19 +408,5 @@ struct APIResponse<T: Codable>: Codable {
|
||||
let code: Int?
|
||||
}
|
||||
|
||||
// MARK: - String MD5 Extension
|
||||
extension String {
|
||||
func md5() -> String {
|
||||
let data = Data(self.utf8)
|
||||
let hash = data.withUnsafeBytes { bytes -> [UInt8] in
|
||||
var hash = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
|
||||
CC_MD5(bytes.baseAddress, CC_LONG(data.count), &hash)
|
||||
return hash
|
||||
}
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
// 需要导入 CommonCrypto
|
||||
import CommonCrypto
|
||||
// 注意:String+MD5 扩展已移至 Utils/Extensions/String+MD5.swift
|
||||
|
||||
|
@@ -93,6 +93,11 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
headers.merge(customHeaders) { _, new in new }
|
||||
}
|
||||
|
||||
// 添加自定义请求头支持
|
||||
if let additionalHeaders = request.customHeaders {
|
||||
headers.merge(additionalHeaders) { _, new in new }
|
||||
}
|
||||
|
||||
for (key, value) in headers {
|
||||
urlRequest.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
236
yana/APIs/LoginModels.swift
Normal file
@@ -0,0 +1,236 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - ID Login Request Model
|
||||
struct IDLoginAPIRequest: APIRequestProtocol {
|
||||
typealias Response = IDLoginResponse
|
||||
|
||||
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
|
||||
|
||||
/// 初始化ID登录请求
|
||||
/// - Parameters:
|
||||
/// - phone: DES加密后的用户ID/手机号
|
||||
/// - password: DES加密后的密码
|
||||
/// - clientSecret: 客户端密钥,固定为"uyzjdhds"
|
||||
/// - version: 版本号,固定为"1"
|
||||
/// - clientId: 客户端ID,固定为"erban-client"
|
||||
/// - grantType: 授权类型,固定为"password"
|
||||
init(phone: String, password: String, clientSecret: String = "uyzjdhds", version: String = "1", clientId: String = "erban-client", grantType: String = "password") {
|
||||
self.queryParameters = [
|
||||
"phone": phone,
|
||||
"password": password,
|
||||
"client_secret": clientSecret,
|
||||
"version": version,
|
||||
"client_id": clientId,
|
||||
"grant_type": grantType
|
||||
];
|
||||
// self.bodyParameters = [
|
||||
// "phone": phone,
|
||||
// "password": password,
|
||||
// "client_secret": clientSecret,
|
||||
// "version": version,
|
||||
// "client_id": clientId,
|
||||
// "grant_type": grantType
|
||||
// ]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ID Login Response Model
|
||||
struct IDLoginResponse: Codable, Equatable {
|
||||
let status: String?
|
||||
let message: String?
|
||||
let code: Int?
|
||||
let data: IDLoginData?
|
||||
|
||||
/// 是否登录成功
|
||||
var isSuccess: Bool {
|
||||
return code == 200 || status?.lowercased() == "success"
|
||||
}
|
||||
|
||||
/// 错误消息(如果有)
|
||||
var errorMessage: String {
|
||||
return message ?? "登录失败,请重试"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ID Login Data Model
|
||||
struct IDLoginData: Codable, Equatable {
|
||||
let accessToken: String?
|
||||
let refreshToken: String?
|
||||
let tokenType: String?
|
||||
let expiresIn: Int?
|
||||
let scope: String?
|
||||
let userInfo: UserInfo?
|
||||
let uid: Int? // 修改:从String?改为Int?以匹配API返回
|
||||
let netEaseToken: String? // 新增:网易云token
|
||||
let jti: String? // 新增:JWT token identifier
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case refreshToken = "refresh_token"
|
||||
case tokenType = "token_type"
|
||||
case expiresIn = "expires_in"
|
||||
case scope
|
||||
case userInfo = "user_info"
|
||||
case uid
|
||||
case netEaseToken
|
||||
case jti
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Info Model
|
||||
struct UserInfo: Codable, Equatable {
|
||||
let userId: String?
|
||||
let username: String?
|
||||
let nickname: String?
|
||||
let avatar: String?
|
||||
let email: String?
|
||||
let phone: String?
|
||||
let status: String?
|
||||
let createTime: String?
|
||||
let updateTime: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case username
|
||||
case nickname
|
||||
case avatar
|
||||
case email
|
||||
case phone
|
||||
case status
|
||||
case createTime = "create_time"
|
||||
case updateTime = "update_time"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Login Helper
|
||||
struct LoginHelper {
|
||||
|
||||
/// 创建ID登录请求
|
||||
/// 这个方法会自动处理DES加密
|
||||
/// - Parameters:
|
||||
/// - userID: 原始用户ID
|
||||
/// - password: 原始密码
|
||||
/// - Returns: 配置好的API请求,如果加密失败返回nil
|
||||
static func createIDLoginRequest(userID: String, password: String) -> IDLoginAPIRequest? {
|
||||
// 使用DES加密ID和密码
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedID = DESEncrypt.encryptUseDES(userID, key: encryptionKey),
|
||||
let encryptedPassword = DESEncrypt.encryptUseDES(password, key: encryptionKey) else {
|
||||
print("❌ DES加密失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
print("🔐 DES加密成功")
|
||||
print(" 原始ID: \(userID)")
|
||||
print(" 加密后ID: \(encryptedID)")
|
||||
print(" 原始密码: \(password)")
|
||||
print(" 加密后密码: \(encryptedPassword)")
|
||||
|
||||
return IDLoginAPIRequest(
|
||||
phone: userID,
|
||||
password: encryptedPassword
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ticket API Models
|
||||
|
||||
/// Ticket 请求结构体
|
||||
struct TicketAPIRequest: APIRequestProtocol {
|
||||
typealias Response = TicketResponse
|
||||
|
||||
let endpoint = "/oauth/ticket"
|
||||
let method: HTTPMethod = .POST
|
||||
let includeBaseParameters = true
|
||||
let queryParameters: [String: String]?
|
||||
let bodyParameters: [String: Any]? = nil
|
||||
let timeout: TimeInterval = 30.0
|
||||
let customHeaders: [String: String]?
|
||||
|
||||
/// 初始化 Ticket 请求
|
||||
/// - Parameters:
|
||||
/// - accessToken: OAuth 访问令牌
|
||||
/// - issueType: 签发类型,固定为"multi"
|
||||
/// - uid: 用户唯一标识,用于添加到请求头
|
||||
init(accessToken: String, issueType: String = "multi", uid: Int? = nil) {
|
||||
self.queryParameters = [
|
||||
"access_token": accessToken,
|
||||
"issue_type": issueType
|
||||
]
|
||||
|
||||
// 设置自定义请求头
|
||||
var headers: [String: String] = [:]
|
||||
if let uid = uid {
|
||||
headers["pub_uid"] = "\(uid)" // 转换为字符串
|
||||
}
|
||||
self.customHeaders = headers.isEmpty ? nil : headers
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticket 响应结构体
|
||||
struct TicketResponse: Codable, Equatable {
|
||||
let code: Int?
|
||||
let message: String?
|
||||
let data: TicketData?
|
||||
|
||||
/// 是否获取成功
|
||||
var isSuccess: Bool {
|
||||
return code == 200
|
||||
}
|
||||
|
||||
/// 错误消息(如果有)
|
||||
var errorMessage: String {
|
||||
return message ?? "Ticket 获取失败,请重试"
|
||||
}
|
||||
|
||||
/// 获取 Ticket 字符串
|
||||
var ticket: String? {
|
||||
return data?.tickets?.first?.ticket
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticket 数据结构体
|
||||
struct TicketData: Codable, Equatable {
|
||||
let tickets: [TicketInfo]?
|
||||
}
|
||||
|
||||
/// Ticket 信息结构体
|
||||
struct TicketInfo: Codable, Equatable {
|
||||
let ticket: String?
|
||||
}
|
||||
|
||||
// MARK: - Ticket Helper
|
||||
struct TicketHelper {
|
||||
|
||||
/// 创建 Ticket 请求
|
||||
/// - Parameters:
|
||||
/// - accessToken: OAuth 访问令牌
|
||||
/// - uid: 用户唯一标识
|
||||
/// - Returns: 配置好的 Ticket API 请求
|
||||
static func createTicketRequest(accessToken: String, uid: Int?) -> TicketAPIRequest {
|
||||
return TicketAPIRequest(accessToken: accessToken, uid: uid)
|
||||
}
|
||||
|
||||
/// 调试打印 Ticket 请求信息
|
||||
/// - Parameters:
|
||||
/// - accessToken: OAuth 访问令牌
|
||||
/// - uid: 用户唯一标识
|
||||
static func debugTicketRequest(accessToken: String, uid: Int?) {
|
||||
print("🎫 Ticket 请求调试信息")
|
||||
print(" AccessToken: \(accessToken)")
|
||||
print(" UID: \(uid?.description ?? "nil")")
|
||||
print(" Endpoint: /oauth/ticket")
|
||||
print(" Method: POST")
|
||||
print(" Headers: pub_uid = \(uid?.description ?? "nil")")
|
||||
print(" Parameters: access_token=\(accessToken), issue_type=multi")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 兼容旧的LoginResponse(如果需要)
|
||||
typealias LoginResponse = IDLoginResponse
|
262
yana/APIs/oauth flow.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# OAuth/Ticket 认证系统 API 文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了 YuMi 应用中 OAuth 认证和 Ticket 会话管理的完整流程。系统采用两阶段认证机制:
|
||||
1. **OAuth 阶段**:用户登录获取 `access_token`
|
||||
2. **Ticket 阶段**:使用 `access_token` 获取业务会话 `ticket`
|
||||
|
||||
## 认证流程架构
|
||||
|
||||
### 核心组件
|
||||
- **AccountInfoStorage**: 负责账户信息和 ticket 的本地存储
|
||||
- **HttpRequestHelper**: 网络请求管理,自动添加认证头
|
||||
- **Api+Login**: 登录相关 API 接口
|
||||
- **Api+Main**: Ticket 获取相关 API 接口
|
||||
|
||||
### 认证数据模型
|
||||
|
||||
#### AccountModel
|
||||
```objc
|
||||
@interface AccountModel : PIBaseModel
|
||||
@property (nonatomic, assign) NSString *uid; // 用户唯一标识
|
||||
@property (nonatomic, copy) NSString *jti; // JWT ID
|
||||
@property (nonatomic, copy) NSString *token_type; // Token 类型
|
||||
@property (nonatomic, copy) NSString *refresh_token; // 刷新令牌
|
||||
@property (nonatomic, copy) NSString *netEaseToken; // 网易云信令牌
|
||||
@property (nonatomic, copy) NSString *access_token; // OAuth 访问令牌
|
||||
@property (nonatomic, assign) NSNumber *expires_in; // 过期时间
|
||||
@end
|
||||
```
|
||||
|
||||
## API 接口详情
|
||||
|
||||
### 1. OAuth 登录接口
|
||||
|
||||
#### 1.1 手机验证码登录
|
||||
```objc
|
||||
+ (void)loginWithCode:(HttpRequestHelperCompletion)completion
|
||||
phone:(NSString *)phone
|
||||
code:(NSString *)code
|
||||
client_secret:(NSString *)client_secret
|
||||
version:(NSString *)version
|
||||
client_id:(NSString *)client_id
|
||||
grant_type:(NSString *)grant_type
|
||||
phoneAreaCode:(NSString *)phoneAreaCode;
|
||||
```
|
||||
|
||||
**接口路径**: `POST /oauth/token`
|
||||
|
||||
**请求参数**:
|
||||
| 参数名 | 类型 | 必填 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| phone | String | 是 | 手机号(DES加密) |
|
||||
| code | String | 是 | 验证码 |
|
||||
| client_secret | String | 是 | 客户端密钥,固定值:"uyzjdhds" |
|
||||
| version | String | 是 | 版本号,固定值:"1" |
|
||||
| client_id | String | 是 | 客户端ID,固定值:"erban-client" |
|
||||
| grant_type | String | 是 | 授权类型,验证码登录为:"sms_code" |
|
||||
| phoneAreaCode | String | 是 | 手机区号 |
|
||||
|
||||
**返回数据**: AccountModel 对象
|
||||
|
||||
#### 1.2 手机密码登录
|
||||
```objc
|
||||
+ (void)loginWithPassword:(HttpRequestHelperCompletion)completion
|
||||
phone:(NSString *)phone
|
||||
password:(NSString *)password
|
||||
client_secret:(NSString *)client_secret
|
||||
version:(NSString *)version
|
||||
client_id:(NSString *)client_id
|
||||
grant_type:(NSString *)grant_type;
|
||||
```
|
||||
|
||||
**接口路径**: `POST /oauth/token`
|
||||
|
||||
**请求参数**:
|
||||
| 参数名 | 类型 | 必填 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| phone | String | 是 | 手机号(DES加密) |
|
||||
| password | String | 是 | 密码(DES加密) |
|
||||
| client_secret | String | 是 | 客户端密钥 |
|
||||
| version | String | 是 | 版本号 |
|
||||
| client_id | String | 是 | 客户端ID |
|
||||
| grant_type | String | 是 | 授权类型,密码登录为:"password" |
|
||||
|
||||
#### 1.3 第三方登录
|
||||
```objc
|
||||
+ (void)loginWithThirdPart:(HttpRequestHelperCompletion)completion
|
||||
openid:(NSString *)openid
|
||||
unionid:(NSString *)unionid
|
||||
access_token:(NSString *)access_token
|
||||
type:(NSString *)type;
|
||||
```
|
||||
|
||||
**接口路径**: `POST /acc/third/login`
|
||||
|
||||
**请求参数**:
|
||||
| 参数名 | 类型 | 必填 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| openid | String | 是 | 第三方平台用户唯一标识 |
|
||||
| unionid | String | 是 | 第三方平台联合ID |
|
||||
| access_token | String | 是 | 第三方平台访问令牌 |
|
||||
| type | String | 是 | 第三方平台类型(1:Apple, 2:Facebook, 3:Google等) |
|
||||
|
||||
### 2. Ticket 获取接口
|
||||
|
||||
#### 2.1 获取 Ticket
|
||||
```objc
|
||||
+ (void)requestTicket:(HttpRequestHelperCompletion)completion
|
||||
access_token:(NSString *)accessToken
|
||||
issue_type:(NSString *)issueType;
|
||||
```
|
||||
|
||||
**接口路径**: `POST /oauth/ticket`
|
||||
|
||||
**请求参数**:
|
||||
| 参数名 | 类型 | 必填 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| access_token | String | 是 | OAuth 登录获取的访问令牌 |
|
||||
| issue_type | String | 是 | 签发类型,固定值:"multi" |
|
||||
|
||||
**返回数据**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"tickets": [
|
||||
{
|
||||
"ticket": "eyJhbGciOiJIUzI1NiJ9..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. HTTP 请求头配置
|
||||
|
||||
所有业务 API 请求都会自动添加以下请求头:
|
||||
|
||||
```objc
|
||||
// 在 HttpRequestHelper 中自动配置
|
||||
- (void)setupHeader {
|
||||
AFHTTPSessionManager *client = [HttpRequestHelper requestManager];
|
||||
|
||||
// 用户ID头
|
||||
if ([[AccountInfoStorage instance] getUid].length > 0) {
|
||||
[client.requestSerializer setValue:[[AccountInfoStorage instance] getUid]
|
||||
forHTTPHeaderField:@"pub_uid"];
|
||||
}
|
||||
|
||||
// Ticket 认证头
|
||||
if ([[AccountInfoStorage instance] getTicket].length > 0) {
|
||||
[client.requestSerializer setValue:[[AccountInfoStorage instance] getTicket]
|
||||
forHTTPHeaderField:@"pub_ticket"];
|
||||
}
|
||||
|
||||
// 其他公共头
|
||||
[client.requestSerializer setValue:[NSBundle uploadLanguageText]
|
||||
forHTTPHeaderField:@"Accept-Language"];
|
||||
[client.requestSerializer setValue:PI_App_Version
|
||||
forHTTPHeaderField:@"App-Version"];
|
||||
}
|
||||
```
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 完整登录流程示例
|
||||
|
||||
```objc
|
||||
// 1. 用户登录获取 access_token
|
||||
[Api loginWithCode:^(BaseModel * _Nullable data, NSInteger code, NSString * _Nullable msg) {
|
||||
if (code == 200) {
|
||||
// 保存账户信息
|
||||
AccountModel *accountModel = [AccountModel modelWithDictionary:data.data];
|
||||
[[AccountInfoStorage instance] saveAccountInfo:accountModel];
|
||||
|
||||
// 2. 使用 access_token 获取 ticket
|
||||
[Api requestTicket:^(BaseModel * _Nullable data, NSInteger code, NSString * _Nullable msg) {
|
||||
if (code == 200) {
|
||||
NSArray *tickets = [data.data valueForKey:@"tickets"];
|
||||
NSString *ticket = [tickets[0] valueForKey:@"ticket"];
|
||||
|
||||
// 保存 ticket
|
||||
[[AccountInfoStorage instance] saveTicket:ticket];
|
||||
|
||||
// 3. 登录成功,可以进行业务操作
|
||||
[self navigateToMainPage];
|
||||
}
|
||||
} access_token:accountModel.access_token issue_type:@"multi"];
|
||||
}
|
||||
} phone:encryptedPhone
|
||||
code:verificationCode
|
||||
client_secret:@"uyzjdhds"
|
||||
version:@"1"
|
||||
client_id:@"erban-client"
|
||||
grant_type:@"sms_code"
|
||||
phoneAreaCode:areaCode];
|
||||
```
|
||||
|
||||
### 自动登录流程
|
||||
|
||||
```objc
|
||||
- (void)autoLogin {
|
||||
// 检查本地是否有账户信息
|
||||
AccountModel *accountModel = [[AccountInfoStorage instance] getCurrentAccountInfo];
|
||||
if (accountModel == nil || accountModel.access_token == nil) {
|
||||
[self tokenInvalid]; // 跳转到登录页
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有有效的 ticket
|
||||
if ([[AccountInfoStorage instance] getTicket].length > 0) {
|
||||
[[self getView] autoLoginSuccess];
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 access_token 重新获取 ticket
|
||||
[Api requestTicket:^(BaseModel * _Nonnull data) {
|
||||
NSArray *tickets = [data.data valueForKey:@"tickets"];
|
||||
NSString *ticket = [tickets[0] valueForKey:@"ticket"];
|
||||
[[AccountInfoStorage instance] saveTicket:ticket];
|
||||
[[self getView] autoLoginSuccess];
|
||||
} fail:^(NSInteger code, NSString * _Nullable msg) {
|
||||
[self logout]; // ticket 获取失败,重新登录
|
||||
} access_token:accountModel.access_token issue_type:@"multi"];
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 401 未授权错误
|
||||
当接收到 401 状态码时,系统会自动处理:
|
||||
|
||||
```objc
|
||||
// 在 HttpRequestHelper 中
|
||||
if (response && response.statusCode == 401) {
|
||||
failure(response.statusCode, YMLocalizedString(@"HttpRequestHelper7"));
|
||||
// 通常需要重新登录
|
||||
}
|
||||
```
|
||||
|
||||
### Ticket 过期处理
|
||||
- Ticket 过期时服务器返回 401 错误
|
||||
- 客户端应该使用保存的 `access_token` 重新获取 ticket
|
||||
- 如果 `access_token` 也过期,则需要用户重新登录
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **数据加密**: 敏感信息(手机号、密码)使用 DES 加密传输
|
||||
2. **本地存储**:
|
||||
- `access_token` 存储在文件系统中
|
||||
- `ticket` 存储在内存中,应用重启需重新获取
|
||||
3. **请求头**: 所有业务请求自动携带 `pub_uid` 和 `pub_ticket` 头
|
||||
4. **错误处理**: 建立完善的 401 错误重试机制
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `YuMi/Structure/MVP/Model/AccountInfoStorage.h/m` - 账户信息存储管理
|
||||
- `YuMi/Modules/YMLogin/Api/Api+Login.h/m` - 登录相关接口
|
||||
- `YuMi/Modules/YMTabbar/Api/Api+Main.h/m` - Ticket 获取接口
|
||||
- `YuMi/Network/HttpRequestHelper.h/m` - 网络请求管理
|
||||
- `YuMi/Structure/MVP/Model/AccountModel.h/m` - 账户数据模型
|
1
yana/APIs/oauth flow.svg
Normal file
After Width: | Height: | Size: 31 KiB |
@@ -1,5 +1,5 @@
|
||||
import UIKit
|
||||
import NIMSDK
|
||||
//import NIMSDK
|
||||
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
@@ -11,20 +11,67 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// }
|
||||
|
||||
#if DEBUG
|
||||
// 网络诊断
|
||||
let testURL = URL(string: "http://beta.api.molistar.xyz/client/init")!
|
||||
let request = URLRequest(url: testURL)
|
||||
|
||||
print("🛠 原生URLSession测试开始")
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
print("""
|
||||
=== 网络诊断结果 ===
|
||||
响应状态码: \((response as? HTTPURLResponse)?.statusCode ?? -1)
|
||||
错误信息: \(error?.localizedDescription ?? "无")
|
||||
原始数据: \(data?.count ?? 0) bytes
|
||||
==================
|
||||
""")
|
||||
}.resume()
|
||||
// 🔍 DES加密已切换到OC版本
|
||||
// print("🔐 使用OC版本的DES加密")
|
||||
// DESEncryptOCTest.runInAppDelegate()
|
||||
|
||||
// 网络诊断 - 使用完整的登录参数测试
|
||||
// let testURL = URL(string: "http://192.168.10.211:8080/oauth/token")!
|
||||
// var request = URLRequest(url: testURL)
|
||||
// request.httpMethod = "POST"
|
||||
// request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
// request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
// request.setValue("zh-Hant", forHTTPHeaderField: "Accept-Language")
|
||||
//
|
||||
// // 添加完整的测试参数
|
||||
// let testParameters: [String: Any] = [
|
||||
// "ispType": "65535",
|
||||
// "phone": "3+TbIQYiwIk=",
|
||||
// "netType": 2,
|
||||
// "channel": "molistar_enterprise",
|
||||
// "version": "20.20.61",
|
||||
// "pub_sign": "2E7C50AA17A20B32A0023F20B7ECE108",
|
||||
// "osVersion": "16.4",
|
||||
// "deviceId": "b715b75715e3417c9c70e72bbe502c6c",
|
||||
// "grant_type": "password",
|
||||
// "os": "iOS",
|
||||
// "app": "youmi",
|
||||
// "password": "nTW/lEgupIQ=",
|
||||
// "client_id": "erban-client",
|
||||
// "lang": "zh-Hant-CN",
|
||||
// "client_secret": "uyzjdhds",
|
||||
// "Accept-Language": "zh-Hant",
|
||||
// "model": "iPhone XR",
|
||||
// "appVersion": "1.0.0"
|
||||
// ]
|
||||
//
|
||||
// do {
|
||||
// let jsonData = try JSONSerialization.data(withJSONObject: testParameters, options: .prettyPrinted)
|
||||
// request.httpBody = jsonData
|
||||
//
|
||||
// print("🛠 原生URLSession登录测试开始")
|
||||
// print("📍 测试端点: \(testURL.absoluteString)")
|
||||
// print("📦 请求参数: \(String(data: jsonData, encoding: .utf8) ?? "无法解析")")
|
||||
//
|
||||
// URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
// DispatchQueue.main.async {
|
||||
// let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
// let responseString = data != nil ? String(data: data!, encoding: .utf8) ?? "无法解析响应" : "无数据"
|
||||
//
|
||||
// print("""
|
||||
// === 网络诊断结果 ===
|
||||
// 🔗 URL: \(testURL.absoluteString)
|
||||
// 📊 响应状态码: \(statusCode)
|
||||
// ❌ 错误信息: \(error?.localizedDescription ?? "无")
|
||||
// 📦 原始数据: \(data?.count ?? 0) bytes
|
||||
// 📄 响应内容: \(responseString)
|
||||
// ==================
|
||||
// """)
|
||||
// }
|
||||
// }.resume()
|
||||
// } catch {
|
||||
// print("❌ JSON序列化失败: \(error.localizedDescription)")
|
||||
// }
|
||||
#endif
|
||||
|
||||
// NIMConfigurationManager.setupNimSDK()
|
||||
|
6
yana/Assets.xcassets/Login/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
21
yana/Assets.xcassets/Login/bg.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "bg@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/bg.imageset/bg@3x.png
vendored
Normal file
After Width: | Height: | Size: 1.2 MiB |
21
yana/Assets.xcassets/Login/email icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "切图 65@3x (1).png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/email icon.imageset/切图 65@3x (1).png
vendored
Normal file
After Width: | Height: | Size: 2.2 KiB |
21
yana/Assets.xcassets/Login/id icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "切图 65@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/id icon.imageset/切图 65@3x.png
vendored
Normal file
After Width: | Height: | Size: 3.1 KiB |
21
yana/Assets.xcassets/Login/logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "logo@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/logo.imageset/logo@3x.png
vendored
Normal file
After Width: | Height: | Size: 113 KiB |
21
yana/Assets.xcassets/Login/selected icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "勾选@3x (1).png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/selected icon.imageset/勾选@3x (1).png
vendored
Normal file
After Width: | Height: | Size: 2.2 KiB |
21
yana/Assets.xcassets/Login/top.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "top@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/top.imageset/top@3x.png
vendored
Normal file
After Width: | Height: | Size: 379 KiB |
21
yana/Assets.xcassets/Login/unselected icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "勾选@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/unselected icon.imageset/勾选@3x.png
vendored
Normal file
After Width: | Height: | Size: 1.6 KiB |
@@ -15,9 +15,22 @@ struct AppConfig {
|
||||
static var baseURL: String {
|
||||
switch current {
|
||||
case .development:
|
||||
// return "http://192.168.10.211:8080"
|
||||
return "http://beta.api.molistar.xyz"
|
||||
case .production:
|
||||
return "https://api.hfighting.com"
|
||||
return "https://api.epartylive.com"
|
||||
}
|
||||
}
|
||||
|
||||
/// Web页面路径前缀
|
||||
/// - development环境: "/molistar"
|
||||
/// - production环境: "/eparty"
|
||||
static var webPathPrefix: String {
|
||||
switch current {
|
||||
case .development:
|
||||
return "/molistar"
|
||||
case .production:
|
||||
return "/eparty"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,29 +47,32 @@ struct AppConfig {
|
||||
current = env
|
||||
}
|
||||
|
||||
// 添加调试配置
|
||||
// 网络调试配置
|
||||
static var enableNetworkDebug: Bool {
|
||||
#if DEBUG
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
switch current {
|
||||
case .development:
|
||||
return true
|
||||
case .production:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加服务器信任配置
|
||||
// 服务器信任配置
|
||||
static var serverTrustPolicies: [String: ServerTrustEvaluating] {
|
||||
#if DEBUG
|
||||
return ["beta.api.molistar.xyz": DisabledTrustEvaluator()]
|
||||
#else
|
||||
return ["api.hfighting.com": PublicKeysTrustEvaluator()]
|
||||
#endif
|
||||
switch current {
|
||||
case .development:
|
||||
return ["beta.api.molistar.xyz": DisabledTrustEvaluator()]
|
||||
case .production:
|
||||
return ["api.epartylive.com": PublicKeysTrustEvaluator()]
|
||||
}
|
||||
}
|
||||
|
||||
static var networkDebugEnabled: Bool {
|
||||
#if DEBUG
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
switch current {
|
||||
case .development:
|
||||
return true
|
||||
case .production:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -36,53 +36,51 @@ struct ContentView: View {
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
// 原有登录界面
|
||||
VStack {
|
||||
// 日志级别选择器
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("日志级别:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Picker("日志级别", selection: $selectedLogLevel) {
|
||||
Text("无日志").tag(APILogger.LogLevel.none)
|
||||
Text("基础日志").tag(APILogger.LogLevel.basic)
|
||||
Text("详细日志").tag(APILogger.LogLevel.detailed)
|
||||
WithPerceptionTracking {
|
||||
TabView(selection: $selectedTab) {
|
||||
// 原有登录界面
|
||||
VStack {
|
||||
// 日志级别选择器
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("日志级别:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Picker("日志级别", selection: $selectedLogLevel) {
|
||||
Text("无日志").tag(APILogger.LogLevel.none)
|
||||
Text("基础日志").tag(APILogger.LogLevel.basic)
|
||||
Text("详细日志").tag(APILogger.LogLevel.detailed)
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 20) {
|
||||
Text("yana")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
|
||||
VStack(spacing: 15) {
|
||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||
TextField("账号", text: viewStore.binding(
|
||||
get: \.account,
|
||||
send: { LoginFeature.Action.updateAccount($0) }
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 20) {
|
||||
Text("eparty")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
VStack(spacing: 15) {
|
||||
TextField("账号", text: Binding(
|
||||
get: { store.account },
|
||||
set: { store.send(.updateAccount($0)) }
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.autocorrectionDisabled(true)
|
||||
|
||||
SecureField("密码", text: viewStore.binding(
|
||||
get: \.password,
|
||||
send: { LoginFeature.Action.updatePassword($0) }
|
||||
SecureField("密码", text: Binding(
|
||||
get: { store.password },
|
||||
set: { store.send(.updatePassword($0)) }
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||
if let error = viewStore.error {
|
||||
.padding(.horizontal)
|
||||
|
||||
if let error = store.error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
@@ -92,114 +90,112 @@ struct ContentView: View {
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button(action: {
|
||||
viewStore.send(.login)
|
||||
store.send(.login)
|
||||
}) {
|
||||
HStack {
|
||||
if viewStore.isLoading {
|
||||
if store.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(viewStore.isLoading ? "登录中..." : "登录")
|
||||
Text(store.isLoading ? "登录中..." : "登录")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(viewStore.isLoading ? Color.gray : Color.blue)
|
||||
.background(store.isLoading ? Color.gray : Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(viewStore.isLoading || viewStore.account.isEmpty || viewStore.password.isEmpty)
|
||||
.disabled(store.isLoading || store.account.isEmpty || store.password.isEmpty)
|
||||
|
||||
WithViewStore(initStore, observe: { $0 }) { initViewStore in
|
||||
Button(action: {
|
||||
initViewStore.send(.initialize)
|
||||
}) {
|
||||
HStack {
|
||||
if initViewStore.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(initViewStore.isLoading ? "测试中..." : "测试初始化")
|
||||
Button(action: {
|
||||
initStore.send(.initialize)
|
||||
}) {
|
||||
HStack {
|
||||
if initStore.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(initViewStore.isLoading ? Color.gray : Color.green)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
Text(initStore.isLoading ? "测试中..." : "测试初始化")
|
||||
}
|
||||
.disabled(initViewStore.isLoading)
|
||||
|
||||
// API 测试结果显示区域
|
||||
if let response = initViewStore.response {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("API 测试结果:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("状态: \(response.status)")
|
||||
if let message = response.message {
|
||||
Text("消息: \(message)")
|
||||
}
|
||||
if let data = response.data {
|
||||
Text("版本: \(data.version ?? "未知")")
|
||||
Text("时间戳: \(data.timestamp ?? 0)")
|
||||
if let config = data.config {
|
||||
Text("配置:")
|
||||
ForEach(Array(config.keys), id: \.self) { key in
|
||||
Text(" \(key): \(config[key] ?? "")")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(initStore.isLoading ? Color.gray : Color.green)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(initStore.isLoading)
|
||||
|
||||
// API 测试结果显示区域
|
||||
if let response = initStore.response {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("API 测试结果:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("状态: \(response.status)")
|
||||
if let message = response.message {
|
||||
Text("消息: \(message)")
|
||||
}
|
||||
if let data = response.data {
|
||||
Text("版本: \(data.version ?? "未知")")
|
||||
Text("时间戳: \(data.timestamp ?? 0)")
|
||||
if let config = data.config {
|
||||
Text("配置:")
|
||||
ForEach(Array(config.keys), id: \.self) { key in
|
||||
Text(" \(key): \(config[key] ?? "")")
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
if let error = initViewStore.error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
.frame(maxHeight: 200)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
if let error = initStore.error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.tabItem {
|
||||
Label("登录", systemImage: "person.circle")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
// 新的 API 配置测试界面
|
||||
ConfigView(store: configStore)
|
||||
.padding()
|
||||
.tabItem {
|
||||
Label("API 测试", systemImage: "network")
|
||||
Label("登录", systemImage: "person.circle")
|
||||
}
|
||||
.tag(1)
|
||||
}
|
||||
.onChange(of: selectedLogLevel) { newValue in
|
||||
APILogger.logLevel = newValue
|
||||
.tag(0)
|
||||
|
||||
// 新的 API 配置测试界面
|
||||
ConfigView(store: configStore)
|
||||
.tabItem {
|
||||
Label("API 测试", systemImage: "network")
|
||||
}
|
||||
.tag(1)
|
||||
}
|
||||
.onChange(of: selectedLogLevel) { newValue in
|
||||
APILogger.logLevel = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,7 +5,7 @@ struct ConfigView: View {
|
||||
let store: StoreOf<ConfigFeature>
|
||||
|
||||
var body: some View {
|
||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||
WithPerceptionTracking {
|
||||
NavigationView {
|
||||
VStack(spacing: 20) {
|
||||
// 标题
|
||||
@@ -16,7 +16,7 @@ struct ConfigView: View {
|
||||
|
||||
// 状态显示
|
||||
Group {
|
||||
if viewStore.isLoading {
|
||||
if store.isLoading {
|
||||
VStack {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
@@ -26,7 +26,7 @@ struct ConfigView: View {
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.frame(height: 100)
|
||||
} else if let errorMessage = viewStore.errorMessage {
|
||||
} else if let errorMessage = store.errorMessage {
|
||||
VStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 40))
|
||||
@@ -43,13 +43,13 @@ struct ConfigView: View {
|
||||
.padding(.horizontal)
|
||||
|
||||
Button("清除错误") {
|
||||
viewStore.send(.clearError)
|
||||
store.send(.clearError)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.top)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
} else if let configData = viewStore.configData {
|
||||
} else if let configData = store.configData {
|
||||
// 配置数据显示
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
@@ -102,7 +102,7 @@ struct ConfigView: View {
|
||||
.cornerRadius(12)
|
||||
}
|
||||
|
||||
if let lastUpdated = viewStore.lastUpdated {
|
||||
if let lastUpdated = store.lastUpdated {
|
||||
Text("最后更新: \(lastUpdated, style: .time)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
@@ -130,21 +130,21 @@ struct ConfigView: View {
|
||||
// 操作按钮
|
||||
VStack(spacing: 12) {
|
||||
Button(action: {
|
||||
viewStore.send(.loadConfig)
|
||||
store.send(.loadConfig)
|
||||
}) {
|
||||
HStack {
|
||||
if viewStore.isLoading {
|
||||
if store.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
Text(viewStore.isLoading ? "加载中..." : "加载配置")
|
||||
Text(store.isLoading ? "加载中..." : "加载配置")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewStore.isLoading)
|
||||
.disabled(store.isLoading)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
|
||||
@@ -152,7 +152,7 @@ struct ConfigView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding()
|
||||
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
|
145
yana/Features/EMailLoginFeature.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
|
||||
@Reducer
|
||||
struct EMailLoginFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var email: String = ""
|
||||
var verificationCode: String = ""
|
||||
var isLoading: Bool = false
|
||||
var isCodeLoading: Bool = false
|
||||
var errorMessage: String? = nil
|
||||
var codeCountdown: Int = 0
|
||||
var isCodeButtonEnabled: Bool = true
|
||||
|
||||
// Debug模式下的默认值
|
||||
#if DEBUG
|
||||
init() {
|
||||
self.email = "85494536@gmail.com"
|
||||
self.verificationCode = ""
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
case emailChanged(String)
|
||||
case verificationCodeChanged(String)
|
||||
case getVerificationCodeTapped
|
||||
case loginButtonTapped(email: String, verificationCode: String)
|
||||
case forgotPasswordTapped
|
||||
case codeCountdownTick
|
||||
case setLoading(Bool)
|
||||
case setCodeLoading(Bool)
|
||||
case setError(String?)
|
||||
case startCodeCountdown
|
||||
case resetCodeCountdown
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .emailChanged(let email):
|
||||
state.email = email
|
||||
state.errorMessage = nil
|
||||
return .none
|
||||
|
||||
case .verificationCodeChanged(let code):
|
||||
state.verificationCode = code
|
||||
state.errorMessage = nil
|
||||
return .none
|
||||
|
||||
case .getVerificationCodeTapped:
|
||||
guard !state.email.isEmpty else {
|
||||
state.errorMessage = "email_login.email_required".localized
|
||||
return .none
|
||||
}
|
||||
|
||||
guard isValidEmail(state.email) else {
|
||||
state.errorMessage = "email_login.invalid_email".localized
|
||||
return .none
|
||||
}
|
||||
|
||||
state.isCodeLoading = true
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
state.errorMessage = "email_login.invalid_email".localized
|
||||
return .none
|
||||
}
|
||||
|
||||
state.isLoading = true
|
||||
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)")
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
209
yana/Features/IDLoginFeature.swift
Normal file
@@ -0,0 +1,209 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
|
||||
@Reducer
|
||||
struct IDLoginFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var userID: String = ""
|
||||
var password: String = ""
|
||||
var isPasswordVisible = false
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
// 新增:Ticket 相关状态
|
||||
var accessToken: String?
|
||||
var ticket: String?
|
||||
var isTicketLoading = false
|
||||
var ticketError: String?
|
||||
var loginStep: LoginStep = .initial
|
||||
var uid: Int? // 修改:保存用户 uid,类型改为Int
|
||||
|
||||
enum LoginStep: Equatable {
|
||||
case initial // 初始状态
|
||||
case authenticating // 正在进行 OAuth 认证
|
||||
case gettingTicket // 正在获取 Ticket
|
||||
case completed // 认证完成
|
||||
case failed // 认证失败
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
init() {
|
||||
// 移除测试用的硬编码凭据
|
||||
self.userID = ""
|
||||
self.password = ""
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
case togglePasswordVisibility
|
||||
case loginButtonTapped(userID: String, password: String)
|
||||
case forgotPasswordTapped
|
||||
case backButtonTapped
|
||||
case loginResponse(TaskResult<IDLoginResponse>)
|
||||
|
||||
// 新增:Ticket 相关 actions
|
||||
case requestTicket(accessToken: String)
|
||||
case ticketResponse(TaskResult<TicketResponse>)
|
||||
case clearTicketError
|
||||
case resetLogin
|
||||
}
|
||||
|
||||
@Dependency(\.apiService) var apiService
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .togglePasswordVisibility:
|
||||
state.isPasswordVisible.toggle()
|
||||
return .none
|
||||
|
||||
case let .loginButtonTapped(userID, password):
|
||||
state.userID = userID
|
||||
state.password = password
|
||||
state.isLoading = true
|
||||
state.errorMessage = nil
|
||||
state.ticketError = nil
|
||||
state.loginStep = .authenticating
|
||||
|
||||
// 实现真实的ID登录API调用
|
||||
return .run { send in
|
||||
do {
|
||||
// 使用LoginHelper创建加密的登录请求
|
||||
guard let loginRequest = LoginHelper.createIDLoginRequest(userID: userID, password: password) else {
|
||||
await send(.loginResponse(.failure(APIError.decodingError("加密失败"))))
|
||||
return
|
||||
}
|
||||
|
||||
// 发起登录请求
|
||||
let response = try await apiService.request(loginRequest)
|
||||
await send(.loginResponse(.success(response)))
|
||||
} catch {
|
||||
if let apiError = error as? APIError {
|
||||
await send(.loginResponse(.failure(apiError)))
|
||||
} else {
|
||||
await send(.loginResponse(.failure(APIError.unknown(error.localizedDescription))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .forgotPasswordTapped:
|
||||
// TODO: 处理忘记密码
|
||||
return .none
|
||||
|
||||
case .backButtonTapped:
|
||||
// 由父级处理返回逻辑
|
||||
return .none
|
||||
|
||||
case let .loginResponse(.success(response)):
|
||||
state.isLoading = false
|
||||
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)")
|
||||
}
|
||||
} else {
|
||||
state.errorMessage = response.errorMessage
|
||||
state.loginStep = .failed
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .loginResponse(.failure(error)):
|
||||
state.isLoading = false
|
||||
state.errorMessage = error.localizedDescription
|
||||
state.loginStep = .failed
|
||||
return .none
|
||||
|
||||
case let .requestTicket(accessToken):
|
||||
state.isTicketLoading = true
|
||||
state.ticketError = nil
|
||||
state.loginStep = .gettingTicket
|
||||
|
||||
return .run { [uid = state.uid] send in
|
||||
do {
|
||||
// 使用 TicketHelper 创建请求,传递 uid
|
||||
let ticketRequest = TicketHelper.createTicketRequest(accessToken: accessToken, uid: uid)
|
||||
let response = try await apiService.request(ticketRequest)
|
||||
await send(.ticketResponse(.success(response)))
|
||||
} catch {
|
||||
print("❌ ID登录 Ticket 获取失败: \(error)")
|
||||
await send(.ticketResponse(.failure(APIError.networkError(error.localizedDescription))))
|
||||
}
|
||||
}
|
||||
|
||||
case let .ticketResponse(.success(response)):
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: 触发导航到主界面
|
||||
|
||||
} else {
|
||||
state.ticketError = response.errorMessage
|
||||
state.loginStep = .failed
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .ticketResponse(.failure(error)):
|
||||
state.isTicketLoading = false
|
||||
state.ticketError = error.localizedDescription
|
||||
state.loginStep = .failed
|
||||
print("❌ ID 登录 Ticket 获取失败: \(error.localizedDescription)")
|
||||
return .none
|
||||
|
||||
case .clearTicketError:
|
||||
state.ticketError = nil
|
||||
return .none
|
||||
|
||||
case .resetLogin:
|
||||
state.isLoading = false
|
||||
state.isTicketLoading = false
|
||||
state.errorMessage = nil
|
||||
state.ticketError = nil
|
||||
state.accessToken = nil
|
||||
state.ticket = nil
|
||||
state.uid = nil // 清除 uid
|
||||
state.loginStep = .initial
|
||||
|
||||
// 清除本地存储的认证信息
|
||||
UserInfoManager.clearAllAuthenticationData()
|
||||
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,12 +1,6 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
|
||||
struct LoginResponse: Codable, Equatable {
|
||||
let status: String
|
||||
let message: String?
|
||||
let token: String?
|
||||
}
|
||||
|
||||
@Reducer
|
||||
struct LoginFeature {
|
||||
@ObservableState
|
||||
@@ -15,11 +9,29 @@ struct LoginFeature {
|
||||
var password: String = ""
|
||||
var isLoading = false
|
||||
var error: String?
|
||||
var idLoginState = IDLoginFeature.State()
|
||||
|
||||
// 新增:Ticket 相关状态
|
||||
var accessToken: String?
|
||||
var ticket: String?
|
||||
var isTicketLoading = false
|
||||
var ticketError: String?
|
||||
var loginStep: LoginStep = .initial
|
||||
var uid: Int? // 修改:保存用户 uid,类型改为Int
|
||||
|
||||
enum LoginStep: Equatable {
|
||||
case initial // 初始状态
|
||||
case authenticating // 正在进行 OAuth 认证
|
||||
case gettingTicket // 正在获取 Ticket
|
||||
case completed // 认证完成
|
||||
case failed // 认证失败
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
init() {
|
||||
self.account = "3184"
|
||||
self.password = "a0d5da073d14731cc7a01ecaa17b9174"
|
||||
// 移除测试用的硬编码凭据
|
||||
self.account = ""
|
||||
self.password = ""
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -28,56 +40,168 @@ struct LoginFeature {
|
||||
case updateAccount(String)
|
||||
case updatePassword(String)
|
||||
case login
|
||||
case loginResponse(TaskResult<LoginResponse>)
|
||||
case loginResponse(TaskResult<IDLoginResponse>)
|
||||
case idLogin(IDLoginFeature.Action)
|
||||
|
||||
// 新增:Ticket 相关 actions
|
||||
case requestTicket(accessToken: String)
|
||||
case ticketResponse(TaskResult<TicketResponse>)
|
||||
case clearTicketError
|
||||
case resetLogin
|
||||
}
|
||||
|
||||
@Dependency(\.apiService) var apiService
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
// Reduce { state, action in
|
||||
// switch action {
|
||||
// case let .updateAccount(account):
|
||||
// state.account = account
|
||||
// return .none
|
||||
//
|
||||
// case let .updatePassword(password):
|
||||
// state.password = password
|
||||
// return .none
|
||||
//
|
||||
// case .login:
|
||||
// state.isLoading = true
|
||||
// state.error = nil
|
||||
//
|
||||
// let loginBody = [
|
||||
// "account": state.account,
|
||||
// "password": state.password
|
||||
// ]
|
||||
//
|
||||
// return .run { send in
|
||||
// do {
|
||||
// let response: LoginResponse = try await APIClientManager.shared.post(
|
||||
// path: APIConstants.Endpoints.login,
|
||||
// body: loginBody,
|
||||
// headers: APIConstants.defaultHeaders
|
||||
// )
|
||||
// await send(.loginResponse(.success(response)))
|
||||
// } catch {
|
||||
// await send(.loginResponse(.failure(error)))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// case let .loginResponse(.success(response)):
|
||||
// state.isLoading = false
|
||||
// if response.status == "success" {
|
||||
// // TODO: 处理登录成功,保存 token 等
|
||||
// } else {
|
||||
// state.error = response.message ?? "登录失败"
|
||||
// }
|
||||
// return .none
|
||||
//
|
||||
// case let .loginResponse(.failure(error)):
|
||||
// state.isLoading = false
|
||||
// state.error = error.localizedDescription
|
||||
// return .none
|
||||
// }
|
||||
// }
|
||||
Scope(state: \.idLoginState, action: \.idLogin) {
|
||||
IDLoginFeature()
|
||||
}
|
||||
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case let .updateAccount(account):
|
||||
state.account = account
|
||||
return .none
|
||||
|
||||
case let .updatePassword(password):
|
||||
state.password = password
|
||||
return .none
|
||||
|
||||
case .login:
|
||||
state.isLoading = true
|
||||
state.error = nil
|
||||
state.ticketError = nil
|
||||
state.loginStep = .authenticating
|
||||
|
||||
// 实现登录逻辑(使用account和password)
|
||||
return .run { [account = state.account, password = state.password] send in
|
||||
do {
|
||||
// 使用LoginHelper创建加密的登录请求
|
||||
guard let loginRequest = LoginHelper.createIDLoginRequest(userID: account, password: password) else {
|
||||
await send(.loginResponse(.failure(APIError.decodingError("加密失败"))))
|
||||
return
|
||||
}
|
||||
|
||||
// 发起登录请求
|
||||
let response = try await apiService.request(loginRequest)
|
||||
await send(.loginResponse(.success(response)))
|
||||
} catch {
|
||||
if let apiError = error as? APIError {
|
||||
await send(.loginResponse(.failure(apiError)))
|
||||
} else {
|
||||
await send(.loginResponse(.failure(APIError.unknown(error.localizedDescription))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case let .loginResponse(.success(response)):
|
||||
state.isLoading = false
|
||||
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)")
|
||||
}
|
||||
} else {
|
||||
state.error = response.errorMessage
|
||||
state.loginStep = .failed
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .loginResponse(.failure(error)):
|
||||
state.isLoading = false
|
||||
state.error = error.localizedDescription
|
||||
state.loginStep = .failed
|
||||
return .none
|
||||
|
||||
case let .requestTicket(accessToken):
|
||||
state.isTicketLoading = true
|
||||
state.ticketError = nil
|
||||
state.loginStep = .gettingTicket
|
||||
|
||||
return .run { [uid = state.uid] send in
|
||||
do {
|
||||
// 使用 TicketHelper 创建请求,传递 uid
|
||||
let ticketRequest = TicketHelper.createTicketRequest(accessToken: accessToken, uid: uid)
|
||||
let response = try await apiService.request(ticketRequest)
|
||||
await send(.ticketResponse(.success(response)))
|
||||
} catch {
|
||||
print("❌ Ticket 获取失败: \(error)")
|
||||
await send(.ticketResponse(.failure(APIError.networkError(error.localizedDescription))))
|
||||
}
|
||||
}
|
||||
|
||||
case let .ticketResponse(.success(response)):
|
||||
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 中没有用户信息,由具体的登录页面传递
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: 触发导航到主界面
|
||||
|
||||
} else {
|
||||
state.ticketError = response.errorMessage
|
||||
state.loginStep = .failed
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .ticketResponse(.failure(error)):
|
||||
state.isTicketLoading = false
|
||||
state.ticketError = error.localizedDescription
|
||||
state.loginStep = .failed
|
||||
print("❌ Ticket 获取失败: \(error.localizedDescription)")
|
||||
return .none
|
||||
|
||||
case .clearTicketError:
|
||||
state.ticketError = nil
|
||||
return .none
|
||||
|
||||
case .resetLogin:
|
||||
state.isLoading = false
|
||||
state.isTicketLoading = false
|
||||
state.error = nil
|
||||
state.ticketError = nil
|
||||
state.accessToken = nil
|
||||
state.ticket = nil
|
||||
state.uid = nil // 清除 uid
|
||||
state.loginStep = .initial
|
||||
|
||||
// 清除本地存储的认证信息
|
||||
UserInfoManager.clearAllAuthenticationData()
|
||||
|
||||
return .none
|
||||
|
||||
case .idLogin:
|
||||
// IDLogin动作由子feature处理
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
39
yana/Features/SplashFeature.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
|
||||
@Reducer
|
||||
struct SplashFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var isLoading = true
|
||||
var shouldShowMainApp = false
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
case onAppear
|
||||
case splashFinished
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .onAppear:
|
||||
state.isLoading = true
|
||||
state.shouldShowMainApp = false
|
||||
|
||||
// 1秒延迟后显示主应用 (iOS 15.5+ 兼容)
|
||||
return .run { send in
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000) // 1秒 = 1,000,000,000 纳秒
|
||||
await send(.splashFinished)
|
||||
}
|
||||
|
||||
case .splashFinished:
|
||||
state.isLoading = false
|
||||
state.shouldShowMainApp = true
|
||||
// 发送通知
|
||||
NotificationCenter.default.post(name: .splashFinished, object: nil)
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
BIN
yana/Fonts/Bayon-Regular.ttf
Normal file
64
yana/Fonts/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 字体文件使用指南
|
||||
|
||||
## 字体文件位置
|
||||
请将 **Bayon-Regular.ttf** 字体文件放置在此文件夹中。
|
||||
|
||||
## 添加步骤
|
||||
|
||||
### 1. 获取字体文件
|
||||
- 从 Google Fonts 下载 Bayon 字体:https://fonts.google.com/specimen/Bayon
|
||||
- 或从设计师提供的字体文件中获取 `Bayon-Regular.ttf`
|
||||
|
||||
### 2. 添加到项目
|
||||
1. 将 `Bayon-Regular.ttf` 文件拖放到此 `Fonts` 文件夹中
|
||||
2. 在 Xcode 中,确保文件被添加到项目的 Target 中
|
||||
3. 检查 `Info.plist` 中已经配置了 `UIAppFonts` 数组
|
||||
|
||||
### 3. 验证字体是否正确加载
|
||||
在 `AppDelegate.swift` 中添加调试代码:
|
||||
```swift
|
||||
#if DEBUG
|
||||
FontManager.printAllAvailableFonts()
|
||||
// 检查 Bayon 字体是否可用
|
||||
print("Bayon 字体可用:\(FontManager.isFontAvailable(.bayonRegular))")
|
||||
#endif
|
||||
```
|
||||
|
||||
## 当前配置状态
|
||||
|
||||
### ✅ 已完成:
|
||||
- [x] Info.plist 配置完成
|
||||
- [x] FontManager 工具类创建完成
|
||||
- [x] LoginView 中 E-PARTI 文本已应用 Bayon 字体
|
||||
- [x] 字体适配与屏幕尺寸兼容
|
||||
|
||||
### ⏳ 待完成:
|
||||
- [ ] 添加 Bayon-Regular.ttf 字体文件到项目中
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 方法1: 使用 FontManager(推荐)
|
||||
```swift
|
||||
Text("E-PARTI")
|
||||
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
|
||||
```
|
||||
|
||||
### 方法2: 使用 View Extension
|
||||
```swift
|
||||
Text("E-PARTI")
|
||||
.adaptedCustomFont(.bayonRegular, designSize: 56)
|
||||
```
|
||||
|
||||
### 方法3: 直接指定大小
|
||||
```swift
|
||||
Text("E-PARTI")
|
||||
.customFont(.bayonRegular, size: 56)
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
如果字体未生效,请检查:
|
||||
1. 字体文件是否正确添加到项目 Target 中
|
||||
2. Info.plist 中的字体文件名是否正确
|
||||
3. 字体文件名与代码中使用的名称是否一致
|
||||
4. 运行调试代码确认字体是否被系统识别
|
@@ -9,5 +9,9 @@
|
||||
</dict>
|
||||
<key>NSWiFiUsageDescription</key>
|
||||
<string>应用需要访问 Wi-Fi 信息以提供网络相关功能</string>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>Bayon-Regular.ttf</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
63
yana/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="s0d-6b-0kx">
|
||||
<objects>
|
||||
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="bg" translatesAutoresizingMaskIntoConstraints="NO" id="Mom-Je-A43">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
</imageView>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="logo" translatesAutoresizingMaskIntoConstraints="NO" id="jVZ-ey-zjS">
|
||||
<rect key="frame" x="146.66666666666666" y="200" width="100" height="100"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="100" id="61A-Xv-MlD"/>
|
||||
<constraint firstAttribute="height" constant="100" id="NWJ-mJ-K2O"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="E-Parti" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="GrU-nK-tAY">
|
||||
<rect key="frame" x="138" y="332" width="117" height="48"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="40"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="jVZ-ey-zjS" firstAttribute="centerX" secondItem="Mom-Je-A43" secondAttribute="centerX" id="Atv-LB-aNW"/>
|
||||
<constraint firstItem="Mom-Je-A43" firstAttribute="leading" secondItem="vDu-zF-Fre" secondAttribute="leading" id="fQ2-yj-nze"/>
|
||||
<constraint firstItem="Mom-Je-A43" firstAttribute="top" secondItem="5EZ-qb-Rvc" secondAttribute="top" id="gVA-6N-OG4"/>
|
||||
<constraint firstItem="GrU-nK-tAY" firstAttribute="centerY" secondItem="Mom-Je-A43" secondAttribute="centerY" constant="-70" id="j81-qa-9vS"/>
|
||||
<constraint firstAttribute="bottom" secondItem="Mom-Je-A43" secondAttribute="bottom" id="lBn-me-aJu"/>
|
||||
<constraint firstItem="Mom-Je-A43" firstAttribute="trailing" secondItem="vDu-zF-Fre" secondAttribute="trailing" id="lka-KN-fEy"/>
|
||||
<constraint firstItem="jVZ-ey-zjS" firstAttribute="top" secondItem="Mom-Je-A43" secondAttribute="top" constant="200" id="nCq-oK-mTB"/>
|
||||
<constraint firstItem="GrU-nK-tAY" firstAttribute="centerX" secondItem="Mom-Je-A43" secondAttribute="centerX" id="sZE-bZ-0Xj"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-208.3969465648855" y="-13.380281690140846"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="bg" width="375" height="812"/>
|
||||
<image name="logo" width="100" height="100"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
@@ -1,35 +0,0 @@
|
||||
import NIMSDK
|
||||
import NECoreKit
|
||||
import NECoreIM2Kit
|
||||
import NEChatKit
|
||||
import NEChatUIKit
|
||||
|
||||
struct NIMConfigurationManager {
|
||||
|
||||
static func setupNimSDK() {
|
||||
let option = configureNIMSDKOption()
|
||||
setupSDK(with: option)
|
||||
setupChatSDK(with: option)
|
||||
}
|
||||
|
||||
static func setupSDK(with option: NIMSDKOption) {
|
||||
NIMSDK.shared().register(with: option)
|
||||
NIMSDKConfig.shared().shouldConsiderRevokedMessageUnreadCount = true
|
||||
NIMSDKConfig.shared().shouldSyncStickTopSessionInfos = true
|
||||
}
|
||||
|
||||
static func setupChatSDK(with option: NIMSDKOption) {
|
||||
let v2Option = V2NIMSDKOption()
|
||||
v2Option.enableV2CloudConversation = false
|
||||
// TODO: 修复 IMKitClient API 调用
|
||||
// IMKitClient.shared.setupIM2(option, v2Option)
|
||||
print("⚠️ NIM SDK 配置暂时被注释,需要修复 IMKitClient API")
|
||||
}
|
||||
|
||||
static func configureNIMSDKOption() -> NIMSDKOption {
|
||||
let option = NIMSDKOption()
|
||||
option.appKey = "79bc37000f4018a2a24ea9dc6ca08d32"
|
||||
option.apnsCername = "pikoDevelopPush"
|
||||
return option
|
||||
}
|
||||
}
|
@@ -1,127 +0,0 @@
|
||||
import Foundation
|
||||
import NIMSDK
|
||||
|
||||
// MARK: - 网络状态通知
|
||||
extension Notification.Name {
|
||||
static let NIMNetworkStateChanged = Notification.Name("NIMNetworkStateChangedNotification")
|
||||
static let NIMTokenExpired = Notification.Name("NIMTokenExpiredNotification")
|
||||
}
|
||||
|
||||
@objc
|
||||
@objcMembers
|
||||
final class NIMSessionManager: NSObject {
|
||||
|
||||
static let shared = NIMSessionManager()
|
||||
|
||||
// MARK: - 登录管理
|
||||
func autoLogin(account: String, token: String, completion: @escaping (Error?) -> Void) {
|
||||
NIMSDK.shared().v2LoginService.add(self)
|
||||
let data = NIMAutoLoginData()
|
||||
data.account = account
|
||||
data.token = token
|
||||
data.forcedMode = false
|
||||
NIMSDK.shared().loginManager.autoLogin(data)
|
||||
}
|
||||
|
||||
func login(account: String, token: String, completion: @escaping (Error?) -> Void) {
|
||||
NIMSDK.shared().loginManager.login(account, token: token) { error in
|
||||
if error == nil {
|
||||
self.registerObservers()
|
||||
}
|
||||
completion(error)
|
||||
}
|
||||
}
|
||||
|
||||
func logout() {
|
||||
NIMSDK.shared().loginManager.logout { _ in
|
||||
self.removeObservers()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 消息监听
|
||||
private func registerObservers() {
|
||||
// 在 autoLogin 方法中
|
||||
// NIMSDK.shared().v2LoginService.add(self as! V2NIMLoginServiceDelegate)
|
||||
|
||||
// 在 registerObservers 方法中
|
||||
// NIMSDK.shared().v2LoginService.add(self as! V2NIMLoginServiceDelegate)
|
||||
|
||||
// 在 removeObservers 方法中
|
||||
// NIMSDK.shared().v2LoginService.remove(self as! V2NIMLoginServiceDelegate)
|
||||
NIMSDK.shared().chatManager.add(self)
|
||||
NIMSDK.shared().loginManager.add(self)
|
||||
}
|
||||
|
||||
private func removeObservers() {
|
||||
NIMSDK.shared().v2LoginService.remove(self)
|
||||
NIMSDK.shared().chatManager.remove(self)
|
||||
NIMSDK.shared().loginManager.remove(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NIMChatManagerDelegate
|
||||
extension NIMSessionManager: NIMChatManagerDelegate {
|
||||
func onRecvMessages(_ messages: [NIMMessage]) {
|
||||
NotificationCenter.default.post(
|
||||
name: .NIMDidReceiveMessage,
|
||||
object: messages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NIMLoginManagerDelegate
|
||||
extension NIMSessionManager: NIMLoginManagerDelegate {
|
||||
func onLogin(_ step: NIMLoginStep) {
|
||||
NotificationCenter.default.post(
|
||||
name: .NIMLoginStateChanged,
|
||||
object: step
|
||||
)
|
||||
}
|
||||
|
||||
func onAutoLoginFailed(_ error: Error) {
|
||||
if (error as NSError).code == 302 {
|
||||
NotificationCenter.default.post(name: .NIMTokenExpired, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 通知定义
|
||||
extension Notification.Name {
|
||||
static let NIMDidReceiveMessage = Notification.Name("NIMDidReceiveMessageNotification")
|
||||
static let NIMLoginStateChanged = Notification.Name("NIMLoginStateChangedNotification")
|
||||
}
|
||||
|
||||
// MARK: - NIMV2LoginServiceDelegate
|
||||
extension NIMSessionManager: V2NIMLoginListener {
|
||||
func onLoginStatus(_ status: V2NIMLoginStatus) {
|
||||
|
||||
}
|
||||
|
||||
func onLoginFailed(_ error: V2NIMError) {
|
||||
|
||||
}
|
||||
|
||||
func onKickedOffline(_ detail: V2NIMKickedOfflineDetail) {
|
||||
|
||||
}
|
||||
|
||||
func onLoginClientChanged(
|
||||
_ change: V2NIMLoginClientChange,
|
||||
clients: [V2NIMLoginClient]?
|
||||
) {
|
||||
|
||||
}
|
||||
// @objc func onLoginProcess(step: NIMV2LoginStep) {
|
||||
// NotificationCenter.default.post(
|
||||
// name: .NIMV2LoginStateChanged,
|
||||
// object: step
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// @objc func onKickOut(result: NIMKickOutResult) {
|
||||
// NotificationCenter.default.post(
|
||||
// name: .NIMKickOutNotification,
|
||||
// object: result
|
||||
// )
|
||||
// }
|
||||
}
|
59
yana/Resources/Localizable.strings
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
yana
|
||||
|
||||
Created on 2024.
|
||||
英文本地化文件
|
||||
*/
|
||||
|
||||
// MARK: - 登录界面
|
||||
"login.id_login" = "ID Login";
|
||||
"login.email_login" = "Email Login";
|
||||
"login.app_title" = "E-PARTI";
|
||||
"login.agreement_policy" = "Agree to the \"User Service Agreement\" and \"Privacy Policy\"";
|
||||
"login.agreement" = "User Service Agreement";
|
||||
"login.policy" = "Privacy Policy";
|
||||
|
||||
// MARK: - 通用按钮
|
||||
"common.login" = "Login";
|
||||
"common.register" = "Register";
|
||||
"common.cancel" = "Cancel";
|
||||
"common.confirm" = "Confirm";
|
||||
"common.ok" = "OK";
|
||||
|
||||
// MARK: - 错误信息
|
||||
"error.network" = "Network Error";
|
||||
"error.invalid_input" = "Invalid Input";
|
||||
"error.login_failed" = "Login Failed";
|
||||
|
||||
// MARK: - 占位符文本
|
||||
"placeholder.email" = "Enter your email";
|
||||
"placeholder.password" = "Enter your password";
|
||||
"placeholder.username" = "Enter your username";
|
||||
"placeholder.enter_id" = "Please enter ID";
|
||||
"placeholder.enter_password" = "Please enter password";
|
||||
|
||||
// MARK: - ID登录页面
|
||||
"id_login.title" = "ID Login";
|
||||
"id_login.forgot_password" = "Forgot Password?";
|
||||
"id_login.login_button" = "Login";
|
||||
"id_login.logging_in" = "Logging in...";
|
||||
|
||||
// MARK: - 邮箱登录页面
|
||||
"email_login.title" = "Email Login";
|
||||
"email_login.email_required" = "Please enter email";
|
||||
"email_login.invalid_email" = "Please enter a valid email address";
|
||||
"email_login.fields_required" = "Please enter email and verification code";
|
||||
"email_login.get_code" = "Get";
|
||||
"email_login.resend_code" = "Resend";
|
||||
"email_login.code_sent" = "Verification code sent";
|
||||
"email_login.login_button" = "Login";
|
||||
"email_login.logging_in" = "Logging in...";
|
||||
"placeholder.enter_email" = "Please enter email";
|
||||
"placeholder.enter_verification_code" = "Please enter verification code";
|
||||
|
||||
// MARK: - 验证和错误信息
|
||||
"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";
|
59
yana/Resources/zh-Hans.lproj/Localizable.strings
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Localizable.strings
|
||||
yana
|
||||
|
||||
Created on 2024.
|
||||
中文简体本地化文件
|
||||
*/
|
||||
|
||||
// MARK: - 登录界面
|
||||
"login.id_login" = "ID 登录";
|
||||
"login.email_login" = "邮箱登录";
|
||||
"login.app_title" = "E-PARTI";
|
||||
"login.agreement_policy" = "同意《用戶服務協議》和《隱私政策》";
|
||||
"login.agreement" = "《用戶服務協議》";
|
||||
"login.policy" = "《隱私政策》";
|
||||
|
||||
// MARK: - 通用按钮
|
||||
"common.login" = "登录";
|
||||
"common.register" = "注册";
|
||||
"common.cancel" = "取消";
|
||||
"common.confirm" = "确认";
|
||||
"common.ok" = "确定";
|
||||
|
||||
// MARK: - 错误信息
|
||||
"error.network" = "网络错误";
|
||||
"error.invalid_input" = "输入无效";
|
||||
"error.login_failed" = "登录失败";
|
||||
|
||||
// MARK: - 占位符文本
|
||||
"placeholder.email" = "请输入邮箱";
|
||||
"placeholder.password" = "请输入密码";
|
||||
"placeholder.username" = "请输入用户名";
|
||||
"placeholder.enter_id" = "请输入ID";
|
||||
"placeholder.enter_password" = "请输入密码";
|
||||
|
||||
// MARK: - ID登录页面
|
||||
"id_login.title" = "ID 登录";
|
||||
"id_login.forgot_password" = "忘记密码?";
|
||||
"id_login.login_button" = "登录";
|
||||
"id_login.logging_in" = "登录中...";
|
||||
|
||||
// MARK: - 邮箱登录页面
|
||||
"email_login.title" = "邮箱登录";
|
||||
"email_login.email_required" = "请输入邮箱";
|
||||
"email_login.invalid_email" = "请输入有效的邮箱地址";
|
||||
"email_login.fields_required" = "请输入邮箱和验证码";
|
||||
"email_login.get_code" = "获取验证码";
|
||||
"email_login.resend_code" = "重新发送";
|
||||
"email_login.code_sent" = "验证码已发送";
|
||||
"email_login.login_button" = "登录";
|
||||
"email_login.logging_in" = "登录中...";
|
||||
"placeholder.enter_email" = "请输入邮箱";
|
||||
"placeholder.enter_verification_code" = "请输入验证码";
|
||||
|
||||
// MARK: - 验证和错误信息
|
||||
"validation.id_required" = "请输入您的ID";
|
||||
"validation.password_required" = "请输入您的密码";
|
||||
"error.encryption_failed" = "加密失败,请重试";
|
||||
"error.login_failed" = "登录失败,请检查您的凭据";
|
26
yana/Utils/Extensions/Color+Hex.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Color Hex Extension
|
||||
extension Color {
|
||||
/// 使用十六进制值创建颜色
|
||||
/// - Parameter hex: 十六进制颜色值,格式为 0xRRGGBB
|
||||
/// - Example: Color(hex: 0x313131)
|
||||
init(hex: UInt32) {
|
||||
let red = Double((hex >> 16) & 0xFF) / 255.0
|
||||
let green = Double((hex >> 8) & 0xFF) / 255.0
|
||||
let blue = Double(hex & 0xFF) / 255.0
|
||||
self.init(red: red, green: green, blue: blue)
|
||||
}
|
||||
|
||||
/// 使用十六进制值和透明度创建颜色
|
||||
/// - Parameters:
|
||||
/// - hex: 十六进制颜色值,格式为 0xRRGGBB
|
||||
/// - alpha: 透明度,范围 0.0-1.0
|
||||
/// - Example: Color(hex: 0x313131, alpha: 0.8)
|
||||
init(hex: UInt32, alpha: Double) {
|
||||
let red = Double((hex >> 16) & 0xFF) / 255.0
|
||||
let green = Double((hex >> 8) & 0xFF) / 255.0
|
||||
let blue = Double(hex & 0xFF) / 255.0
|
||||
self.init(red: red, green: green, blue: blue, opacity: alpha)
|
||||
}
|
||||
}
|
83
yana/Utils/Extensions/String+HashTest.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
/// 字符串哈希方法测试工具
|
||||
/// 用于验证 MD5 和 SHA256 方法的正确性
|
||||
struct StringHashTest {
|
||||
|
||||
/// 测试哈希方法
|
||||
static func runTests() {
|
||||
print("🧪 开始测试字符串哈希方法...")
|
||||
|
||||
let testStrings = [
|
||||
"hello world",
|
||||
"test123",
|
||||
"key=rpbs6us1m8r2j9g6u06ff2bo18orwaya",
|
||||
"phone=encrypted_phone&password=encrypted_password&client_id=erban-client&key=rpbs6us1m8r2j9g6u06ff2bo18orwaya"
|
||||
]
|
||||
|
||||
for testString in testStrings {
|
||||
print("\n📝 测试字符串: \"\(testString)\"")
|
||||
|
||||
// 测试 MD5
|
||||
let md5Result = testString.md5()
|
||||
print(" MD5: \(md5Result)")
|
||||
|
||||
// 测试 SHA256 (iOS 13+)
|
||||
if #available(iOS 13.0, *) {
|
||||
let sha256Result = testString.sha256()
|
||||
print(" SHA256: \(sha256Result)")
|
||||
} else {
|
||||
print(" SHA256: 不支持 (需要 iOS 13+)")
|
||||
}
|
||||
}
|
||||
|
||||
print("\n✅ 哈希方法测试完成")
|
||||
}
|
||||
|
||||
/// 验证已知的哈希值
|
||||
static func verifyKnownHashes() {
|
||||
print("\n🔍 验证已知哈希值...")
|
||||
|
||||
// 验证 "hello world" 的 MD5 应该是 "5d41402abc4b2a76b9719d911017c592"
|
||||
let testString = "hello world"
|
||||
let expectedMD5 = "5d41402abc4b2a76b9719d911017c592"
|
||||
let actualMD5 = testString.md5()
|
||||
|
||||
if actualMD5 == expectedMD5 {
|
||||
print("✅ MD5 验证通过: \(actualMD5)")
|
||||
} else {
|
||||
print("❌ MD5 验证失败:")
|
||||
print(" 期望: \(expectedMD5)")
|
||||
print(" 实际: \(actualMD5)")
|
||||
}
|
||||
|
||||
// 验证 SHA256
|
||||
if #available(iOS 13.0, *) {
|
||||
let expectedSHA256 = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||
let actualSHA256 = testString.sha256()
|
||||
|
||||
if actualSHA256 == expectedSHA256 {
|
||||
print("✅ SHA256 验证通过: \(actualSHA256)")
|
||||
} else {
|
||||
print("❌ SHA256 验证失败:")
|
||||
print(" 期望: \(expectedSHA256)")
|
||||
print(" 实际: \(actualSHA256)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 使用示例
|
||||
/*
|
||||
|
||||
// 在适当的地方调用测试
|
||||
StringHashTest.runTests()
|
||||
StringHashTest.verifyKnownHashes()
|
||||
|
||||
// 或者在开发时快速测试
|
||||
print("Test MD5:", "hello".md5())
|
||||
if #available(iOS 13.0, *) {
|
||||
print("Test SHA256:", "hello".sha256())
|
||||
}
|
||||
|
||||
*/
|
39
yana/Utils/Extensions/String+MD5.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import CommonCrypto
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - String Hash Extensions
|
||||
extension String {
|
||||
/// 计算字符串的SHA256哈希值(推荐使用)
|
||||
/// - Returns: SHA256哈希值的小写十六进制字符串
|
||||
@available(iOS 13.0, *)
|
||||
func sha256() -> String {
|
||||
let data = Data(self.utf8)
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.compactMap { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// 计算字符串的MD5哈希值(已弃用,仅用于兼容性)
|
||||
///
|
||||
/// ⚠️ 警告:MD5在iOS 13.0后已被弃用,因为它在加密学上是不安全的
|
||||
/// 建议使用 sha256() 方法替代
|
||||
///
|
||||
/// - Returns: MD5哈希值的小写十六进制字符串
|
||||
func md5() -> String {
|
||||
if #available(iOS 13.0, *) {
|
||||
// iOS 13+ 使用 CryptoKit 的 Insecure.MD5
|
||||
let data = Data(self.utf8)
|
||||
let digest = Insecure.MD5.hash(data: data)
|
||||
return digest.compactMap { String(format: "%02x", $0) }.joined()
|
||||
} else {
|
||||
// iOS 13 以下使用 CommonCrypto
|
||||
let data = Data(self.utf8)
|
||||
let hash = data.withUnsafeBytes { bytes -> [UInt8] in
|
||||
var hash = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
|
||||
CC_MD5(bytes.baseAddress, CC_LONG(data.count), &hash)
|
||||
return hash
|
||||
}
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
}
|
21
yana/Utils/Extensions/View+Placeholder.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - View Extension for Placeholder
|
||||
extension View {
|
||||
/// 为TextField和SecureField添加占位符功能
|
||||
/// - Parameters:
|
||||
/// - shouldShow: 是否显示占位符
|
||||
/// - alignment: 占位符对齐方式
|
||||
/// - placeholder: 占位符视图构建器
|
||||
/// - Returns: 带有占位符的视图
|
||||
func placeholder<Content: View>(
|
||||
when shouldShow: Bool,
|
||||
alignment: Alignment = .leading,
|
||||
@ViewBuilder placeholder: () -> Content) -> some View {
|
||||
|
||||
ZStack(alignment: alignment) {
|
||||
placeholder().opacity(shouldShow ? 1 : 0)
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
110
yana/Utils/FontManager.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 字体管理工具类
|
||||
/// 统一管理项目中使用的自定义字体
|
||||
struct FontManager {
|
||||
|
||||
// MARK: - 自定义字体名称
|
||||
enum CustomFont: String, CaseIterable {
|
||||
case bayonRegular = "Bayon-Regular"
|
||||
|
||||
/// 字体的显示名称
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .bayonRegular:
|
||||
return "Bayon Regular"
|
||||
}
|
||||
}
|
||||
|
||||
/// 字体文件名(不包含扩展名)
|
||||
var fileName: String {
|
||||
return self.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 字体创建方法
|
||||
|
||||
/// 创建自定义字体
|
||||
/// - Parameters:
|
||||
/// - customFont: 自定义字体类型
|
||||
/// - size: 字体大小
|
||||
/// - Returns: Font 对象
|
||||
static func font(_ customFont: CustomFont, size: CGFloat) -> Font {
|
||||
return Font.custom(customFont.rawValue, size: size)
|
||||
}
|
||||
|
||||
/// 创建适配屏幕的自定义字体
|
||||
/// - Parameters:
|
||||
/// - customFont: 自定义字体类型
|
||||
/// - designSize: 设计稿中的字体大小
|
||||
/// - screenWidth: 当前屏幕宽度
|
||||
/// - Returns: Font 对象
|
||||
static func adaptedFont(_ customFont: CustomFont, designSize: CGFloat, for screenWidth: CGFloat) -> Font {
|
||||
let adaptedSize = ScreenAdapter.fontSize(designSize, for: screenWidth)
|
||||
return Font.custom(customFont.rawValue, size: adaptedSize)
|
||||
}
|
||||
|
||||
/// 检查字体是否可用
|
||||
/// - Parameter customFont: 自定义字体类型
|
||||
/// - Returns: 字体是否可用
|
||||
static func isFontAvailable(_ customFont: CustomFont) -> Bool {
|
||||
let fontNames = UIFont.familyNames
|
||||
.flatMap { UIFont.fontNames(forFamilyName: $0) }
|
||||
|
||||
return fontNames.contains(customFont.rawValue)
|
||||
}
|
||||
|
||||
/// 获取所有可用的字体列表(调试用)
|
||||
/// - Returns: 所有可用字体名称的数组
|
||||
static func getAllAvailableFonts() -> [String] {
|
||||
return UIFont.familyNames
|
||||
.flatMap { family in
|
||||
UIFont.fontNames(forFamilyName: family)
|
||||
.map { _ in "\(family): \(String(describing: font))" }
|
||||
}
|
||||
.sorted()
|
||||
}
|
||||
|
||||
/// 打印所有可用字体(调试用)
|
||||
static func printAllAvailableFonts() {
|
||||
print("=== 所有可用字体 ===")
|
||||
for font in getAllAvailableFonts() {
|
||||
print(font)
|
||||
}
|
||||
print("==================")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI View Extension
|
||||
extension View {
|
||||
/// 应用自定义字体
|
||||
/// - Parameters:
|
||||
/// - customFont: 自定义字体类型
|
||||
/// - size: 字体大小
|
||||
/// - Returns: 应用了自定义字体的视图
|
||||
func customFont(_ customFont: FontManager.CustomFont, size: CGFloat) -> some View {
|
||||
self.font(FontManager.font(customFont, size: size))
|
||||
}
|
||||
|
||||
/// 应用适配屏幕的自定义字体
|
||||
/// - Parameters:
|
||||
/// - customFont: 自定义字体类型
|
||||
/// - designSize: 设计稿中的字体大小
|
||||
/// - Returns: 应用了适配字体的视图修饰器
|
||||
func adaptedCustomFont(_ customFont: FontManager.CustomFont, designSize: CGFloat) -> some View {
|
||||
self.modifier(AdaptedCustomFontModifier(customFont: customFont, designSize: designSize))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewModifier
|
||||
struct AdaptedCustomFontModifier: ViewModifier {
|
||||
let customFont: FontManager.CustomFont
|
||||
let designSize: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
GeometryReader { geometry in
|
||||
content
|
||||
.font(FontManager.adaptedFont(customFont, designSize: designSize, for: geometry.size.width))
|
||||
}
|
||||
}
|
||||
}
|
135
yana/Utils/LocalizationManager.swift
Normal file
@@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// 多语言管理工具类
|
||||
/// 提供便捷的本地化字符串获取和语言切换功能
|
||||
///
|
||||
/// 默认语言策略:
|
||||
/// - 应用全局默认语言为英文,不依赖系统语言设置
|
||||
/// - 用户可通过语言设置界面手动切换到其他支持的语言
|
||||
/// - 用户的语言选择会保存在UserDefaults中,下次启动时保持
|
||||
class LocalizationManager: ObservableObject {
|
||||
|
||||
// MARK: - 单例
|
||||
static let shared = LocalizationManager()
|
||||
|
||||
// MARK: - 支持的语言
|
||||
enum SupportedLanguage: String, CaseIterable {
|
||||
case english = "en"
|
||||
case chineseSimplified = "zh-Hans"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .english:
|
||||
return "English"
|
||||
case .chineseSimplified:
|
||||
return "简体中文"
|
||||
}
|
||||
}
|
||||
|
||||
var localizedDisplayName: String {
|
||||
switch self {
|
||||
case .english:
|
||||
return "English"
|
||||
case .chineseSimplified:
|
||||
return "简体中文"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 当前语言
|
||||
@Published var currentLanguage: SupportedLanguage {
|
||||
didSet {
|
||||
UserDefaults.standard.set(currentLanguage.rawValue, forKey: "AppLanguage")
|
||||
// 通知视图更新
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
// 从 UserDefaults 读取保存的语言设置
|
||||
let savedLanguage = UserDefaults.standard.string(forKey: "AppLanguage") ?? ""
|
||||
self.currentLanguage = SupportedLanguage(rawValue: savedLanguage) ?? .english
|
||||
|
||||
// 如果没有保存过语言设置,使用系统首选语言
|
||||
if savedLanguage.isEmpty {
|
||||
self.currentLanguage = Self.getSystemPreferredLanguage()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 本地化方法
|
||||
|
||||
/// 获取本地化字符串
|
||||
/// - Parameters:
|
||||
/// - key: 本地化 key
|
||||
/// - arguments: 格式化参数
|
||||
/// - Returns: 本地化后的字符串
|
||||
func localizedString(_ key: String, arguments: CVarArg...) -> String {
|
||||
let format = getLocalizedString(for: key)
|
||||
if arguments.isEmpty {
|
||||
return format
|
||||
} else {
|
||||
return String(format: format, arguments: arguments)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取本地化字符串(私有方法)
|
||||
private func getLocalizedString(for key: String) -> String {
|
||||
guard let path = Bundle.main.path(forResource: currentLanguage.rawValue, ofType: "lproj"),
|
||||
let bundle = Bundle(path: path) else {
|
||||
// 如果找不到对应语言包,返回 key 本身
|
||||
return NSLocalizedString(key, comment: "")
|
||||
}
|
||||
|
||||
return NSLocalizedString(key, bundle: bundle, comment: "")
|
||||
}
|
||||
|
||||
// MARK: - 语言切换
|
||||
|
||||
/// 切换到指定语言
|
||||
/// - Parameter language: 目标语言
|
||||
func switchLanguage(to language: SupportedLanguage) {
|
||||
currentLanguage = language
|
||||
}
|
||||
|
||||
/// 获取系统首选语言
|
||||
/// 注意:应用全局默认语言已设置为英文,用户可通过设置手动切换语言
|
||||
private static func getSystemPreferredLanguage() -> SupportedLanguage {
|
||||
// 全局默认语言设置为英文
|
||||
// 用户仍可通过语言设置界面切换到其他支持的语言
|
||||
return .english
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI Extensions
|
||||
extension View {
|
||||
/// 应用本地化字符串
|
||||
/// - Parameter key: 本地化 key
|
||||
/// - Returns: 带有本地化文本的视图
|
||||
func localized(_ key: String) -> some View {
|
||||
self.modifier(LocalizedTextModifier(key: key))
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地化文本修饰器
|
||||
struct LocalizedTextModifier: ViewModifier {
|
||||
let key: String
|
||||
@ObservedObject private var localizationManager = LocalizationManager.shared
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 便捷方法
|
||||
extension String {
|
||||
/// 获取本地化字符串
|
||||
var localized: String {
|
||||
return LocalizationManager.shared.localizedString(self)
|
||||
}
|
||||
|
||||
/// 获取本地化字符串(带参数)
|
||||
func localized(arguments: CVarArg...) -> String {
|
||||
return LocalizationManager.shared.localizedString(self, arguments: arguments)
|
||||
}
|
||||
}
|
114
yana/Utils/ScreenAdapter.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 屏幕适配工具类
|
||||
/// 基于设计稿尺寸进行等比例缩放,确保在不同设备上保持一致的视觉效果
|
||||
struct ScreenAdapter {
|
||||
|
||||
// MARK: - 设计稿基准尺寸
|
||||
/// 设计稿宽度基准 (iPhone 14 Pro)
|
||||
static let designWidth: CGFloat = 393
|
||||
/// 设计稿高度基准 (iPhone 14 Pro)
|
||||
static let designHeight: CGFloat = 852
|
||||
|
||||
// MARK: - 适配方法
|
||||
|
||||
/// 根据设计稿宽度计算适配后的宽度
|
||||
/// - Parameters:
|
||||
/// - designValue: 设计稿中的宽度值
|
||||
/// - screenWidth: 当前屏幕宽度
|
||||
/// - Returns: 适配后的宽度值
|
||||
static func width(_ designValue: CGFloat, for screenWidth: CGFloat) -> CGFloat {
|
||||
return designValue * (screenWidth / designWidth)
|
||||
}
|
||||
|
||||
/// 根据设计稿高度计算适配后的高度
|
||||
/// - Parameters:
|
||||
/// - designValue: 设计稿中的高度值
|
||||
/// - screenHeight: 当前屏幕高度
|
||||
/// - Returns: 适配后的高度值
|
||||
static func height(_ designValue: CGFloat, for screenHeight: CGFloat) -> CGFloat {
|
||||
return designValue * (screenHeight / designHeight)
|
||||
}
|
||||
|
||||
/// 根据设计稿字体大小计算适配后的字体大小
|
||||
/// - Parameters:
|
||||
/// - designFontSize: 设计稿中的字体大小
|
||||
/// - screenWidth: 当前屏幕宽度
|
||||
/// - Returns: 适配后的字体大小
|
||||
static func fontSize(_ designFontSize: CGFloat, for screenWidth: CGFloat) -> CGFloat {
|
||||
return designFontSize * (screenWidth / designWidth)
|
||||
}
|
||||
|
||||
/// 计算适配比例 (基于宽度)
|
||||
/// - Parameter screenWidth: 当前屏幕宽度
|
||||
/// - Returns: 宽度适配比例
|
||||
static func widthRatio(for screenWidth: CGFloat) -> CGFloat {
|
||||
return screenWidth / designWidth
|
||||
}
|
||||
|
||||
/// 计算适配比例 (基于高度)
|
||||
/// - Parameter screenHeight: 当前屏幕高度
|
||||
/// - Returns: 高度适配比例
|
||||
static func heightRatio(for screenHeight: CGFloat) -> CGFloat {
|
||||
return screenHeight / designHeight
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI View Extension
|
||||
extension View {
|
||||
/// 根据设计稿尺寸适配宽度
|
||||
/// - Parameter designValue: 设计稿中的宽度值
|
||||
/// - Returns: 带有适配宽度的视图修饰器
|
||||
func adaptedWidth(_ designValue: CGFloat) -> some View {
|
||||
self.modifier(AdaptedWidthModifier(designValue: designValue))
|
||||
}
|
||||
|
||||
/// 根据设计稿尺寸适配高度
|
||||
/// - Parameter designValue: 设计稿中的高度值
|
||||
/// - Returns: 带有适配高度的视图修饰器
|
||||
func adaptedHeight(_ designValue: CGFloat) -> some View {
|
||||
self.modifier(AdaptedHeightModifier(designValue: designValue))
|
||||
}
|
||||
|
||||
/// 根据设计稿尺寸适配字体大小
|
||||
/// - Parameter designFontSize: 设计稿中的字体大小
|
||||
/// - Returns: 带有适配字体的视图修饰器
|
||||
func adaptedFont(_ designFontSize: CGFloat, weight: Font.Weight = .regular) -> some View {
|
||||
self.modifier(AdaptedFontModifier(designFontSize: designFontSize, weight: weight))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewModifiers
|
||||
struct AdaptedWidthModifier: ViewModifier {
|
||||
let designValue: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
GeometryReader { geometry in
|
||||
content
|
||||
.frame(width: ScreenAdapter.width(designValue, for: geometry.size.width))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AdaptedHeightModifier: ViewModifier {
|
||||
let designValue: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
GeometryReader { geometry in
|
||||
content
|
||||
.padding(.top, ScreenAdapter.height(designValue, for: geometry.size.height))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AdaptedFontModifier: ViewModifier {
|
||||
let designFontSize: CGFloat
|
||||
let weight: Font.Weight
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
GeometryReader { geometry in
|
||||
content
|
||||
.font(.system(size: ScreenAdapter.fontSize(designFontSize, for: geometry.size.width), weight: weight))
|
||||
}
|
||||
}
|
||||
}
|
54
yana/Utils/ScreenAdapterExample.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
/// ScreenAdapter 使用示例
|
||||
/// 展示如何在 SwiftUI 视图中使用屏幕适配工具类
|
||||
struct ScreenAdapterExample: View {
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 20) {
|
||||
|
||||
// 方法1: 直接使用 ScreenAdapter 静态方法
|
||||
Text("方法1: 直接调用")
|
||||
.font(.system(size: ScreenAdapter.fontSize(16, for: geometry.size.width)))
|
||||
.padding(.leading, ScreenAdapter.width(20, for: geometry.size.width))
|
||||
.padding(.top, ScreenAdapter.height(50, for: geometry.size.height))
|
||||
|
||||
// 方法2: 使用 View Extension (推荐)
|
||||
Text("方法2: View Extension")
|
||||
.adaptedFont(16)
|
||||
.adaptedHeight(50)
|
||||
|
||||
// 方法3: 使用比例计算
|
||||
Text("方法3: 比例计算")
|
||||
.font(.system(size: 16 * ScreenAdapter.widthRatio(for: geometry.size.width)))
|
||||
.padding(.top, 50 * ScreenAdapter.heightRatio(for: geometry.size.height))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 使用建议
|
||||
/*
|
||||
|
||||
推荐使用顺序:
|
||||
|
||||
1. View Extension (最简洁)
|
||||
.adaptedFont(16)
|
||||
.adaptedHeight(20)
|
||||
.adaptedWidth(100)
|
||||
|
||||
2. 直接调用静态方法 (灵活性高)
|
||||
.font(.system(size: ScreenAdapter.fontSize(16, for: geometry.size.width)))
|
||||
.padding(.top, ScreenAdapter.height(20, for: geometry.size.height))
|
||||
|
||||
3. 比例计算 (自定义场景)
|
||||
let ratio = ScreenAdapter.heightRatio(for: geometry.size.height)
|
||||
.padding(.top, 20 * ratio)
|
||||
|
||||
*/
|
||||
|
||||
#Preview {
|
||||
ScreenAdapterExample()
|
||||
}
|
19
yana/Utils/Security/AESUtils.h
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// AESUtils.h
|
||||
// YUMI
|
||||
//
|
||||
// Created by YUMI on 2023/2/13.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface AESUtils : NSObject
|
||||
//MARK: AES加解密
|
||||
+ (NSString *)aesEncrypt:(NSString *)sourceStr;
|
||||
|
||||
+ (NSString *)aesDecrypt:(NSString *)secretStr;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
151
yana/Utils/Security/AESUtils.m
Normal file
@@ -0,0 +1,151 @@
|
||||
//
|
||||
// AESUtils.m
|
||||
// YUMI
|
||||
//
|
||||
// Created by YUMI on 2023/2/13.
|
||||
//
|
||||
|
||||
#import "AESUtils.h"
|
||||
#import <CommonCrypto/CommonCrypto.h>
|
||||
|
||||
#define GL_AES_KEY @"aef01238765abcdeaaageggbeggsded"
|
||||
#define GL_AES_IV @"edgcdgrtc"
|
||||
@implementation AESUtils
|
||||
//MARK: AES加解密相关 start
|
||||
+ (NSString *)aesEncrypt:(NSString *)sourceStr {
|
||||
if (!sourceStr) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
//秘钥
|
||||
char keyPtr[kCCKeySizeAES256 + 1];
|
||||
bzero(keyPtr, sizeof(keyPtr));
|
||||
[GL_AES_KEY getCString:keyPtr maxLength:sizeof(keyPtr) encoding:NSUTF8StringEncoding];
|
||||
|
||||
//向量
|
||||
char ivPtr[kCCBlockSizeAES128 + 1];
|
||||
bzero(ivPtr, sizeof(ivPtr));
|
||||
[GL_AES_IV getCString:ivPtr maxLength:sizeof(ivPtr) encoding:NSUTF8StringEncoding];
|
||||
|
||||
NSData *sourceData = [sourceStr dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSUInteger dataLength = [sourceData length];
|
||||
size_t buffersize = dataLength + kCCBlockSizeAES128;
|
||||
void *buffer = malloc(buffersize);
|
||||
size_t numBytesEncrypted = 0;
|
||||
/*
|
||||
//CBC模式
|
||||
kCCOptionPKCS7Padding
|
||||
//ECB模式
|
||||
kCCOptionPKCS7Padding | kCCOptionECBMode
|
||||
*/
|
||||
CCCryptorStatus cryptStatus = CCCrypt(kCCEncrypt,
|
||||
kCCAlgorithmAES128,
|
||||
kCCOptionPKCS7Padding,
|
||||
keyPtr,
|
||||
kCCBlockSizeAES128,
|
||||
ivPtr,//ECB模式下可以为NULL
|
||||
[sourceData bytes],
|
||||
dataLength,
|
||||
buffer,
|
||||
buffersize,
|
||||
&numBytesEncrypted);
|
||||
|
||||
if (cryptStatus == kCCSuccess) {
|
||||
NSData *encryptData = [NSData dataWithBytesNoCopy:buffer length:numBytesEncrypted];
|
||||
//对加密后的二进制数据进行base64转码
|
||||
//return [encryptData base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed];
|
||||
|
||||
//转换为16进制字符串
|
||||
NSMutableString *output = [NSMutableString stringWithCapacity:encryptData.length * 2];
|
||||
if (encryptData && encryptData.length > 0) {
|
||||
Byte *datas = (Byte*)[encryptData bytes];
|
||||
for(int i = 0; i < encryptData.length; i++){
|
||||
[output appendFormat:@"%02x", datas[i]];
|
||||
}
|
||||
}
|
||||
return output;
|
||||
|
||||
} else {
|
||||
free(buffer);
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
+ (NSString *)aesDecrypt:(NSString *)secretStr {
|
||||
if (!secretStr) {
|
||||
return nil;
|
||||
}
|
||||
// //先对加密的字符串进行base64解码
|
||||
NSData *decodeData = [[NSData alloc] initWithBase64EncodedString:secretStr options:NSDataBase64DecodingIgnoreUnknownCharacters];
|
||||
//先对加密的字符串进行16进制解码
|
||||
// NSData *decodeData = [self convertHexStrToData:secretStr];
|
||||
|
||||
//秘钥
|
||||
char keyPtr[kCCKeySizeAES256 + 1];
|
||||
bzero(keyPtr, sizeof(keyPtr));
|
||||
[GL_AES_KEY getCString:keyPtr maxLength:sizeof(keyPtr) encoding:NSUTF8StringEncoding];
|
||||
|
||||
//向量
|
||||
char ivPtr[kCCBlockSizeAES128 + 1];
|
||||
bzero(ivPtr, sizeof(ivPtr));
|
||||
[GL_AES_IV getCString:ivPtr maxLength:sizeof(ivPtr) encoding:NSUTF8StringEncoding];
|
||||
|
||||
NSUInteger dataLength = [decodeData length];
|
||||
size_t bufferSize = dataLength + kCCBlockSizeAES128;
|
||||
void *buffer = malloc(bufferSize);
|
||||
size_t numBytesDecrypted = 0;
|
||||
/*
|
||||
//CBC模式
|
||||
kCCOptionPKCS7Padding
|
||||
//ECB模式
|
||||
kCCOptionPKCS7Padding | kCCOptionECBMode
|
||||
*/
|
||||
CCCryptorStatus cryptStatus = CCCrypt(kCCDecrypt,
|
||||
kCCAlgorithmAES128,
|
||||
kCCOptionPKCS7Padding,
|
||||
keyPtr,
|
||||
kCCBlockSizeAES128,
|
||||
ivPtr,//ECB模式下可以为NULL
|
||||
[decodeData bytes],
|
||||
dataLength,
|
||||
buffer,
|
||||
bufferSize,
|
||||
&numBytesDecrypted);
|
||||
if (cryptStatus == kCCSuccess) {
|
||||
NSData *data = [NSData dataWithBytesNoCopy:buffer length:numBytesDecrypted];
|
||||
NSString *result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||
return result;
|
||||
} else {
|
||||
free(buffer);
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
// 16进制转NSData
|
||||
+ (NSData *)convertHexStrToData:(NSString *)str {
|
||||
if (!str || [str length] == 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSMutableData *hexData = [[NSMutableData alloc] initWithCapacity:20];
|
||||
NSRange range;
|
||||
if ([str length] % 2 == 0) {
|
||||
range = NSMakeRange(0, 2);
|
||||
} else {
|
||||
range = NSMakeRange(0, 1);
|
||||
}
|
||||
for (NSInteger i = range.location; i < [str length]; i += 2) {
|
||||
unsigned int anInt;
|
||||
NSString *hexCharStr = [str substringWithRange:range];
|
||||
NSScanner *scanner = [[NSScanner alloc] initWithString:hexCharStr];
|
||||
|
||||
[scanner scanHexInt:&anInt];
|
||||
NSData *entity = [[NSData alloc] initWithBytes:&anInt length:1];
|
||||
[hexData appendData:entity];
|
||||
|
||||
range.location += range.length;
|
||||
range.length = 2;
|
||||
}
|
||||
return hexData;
|
||||
}
|
||||
@end
|
16
yana/Utils/Security/Base64.h
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// Base64.h
|
||||
// YMhatFramework
|
||||
//
|
||||
// Created by chenran on 2017/5/4.
|
||||
// Copyright © 2017年 chenran. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@interface Base64 : NSObject
|
||||
|
||||
+(NSString *)encode:(NSData *)data;
|
||||
+(NSData *)decode:(NSString *)dataString;
|
||||
|
||||
@end
|
133
yana/Utils/Security/Base64.m
Normal file
@@ -0,0 +1,133 @@
|
||||
//
|
||||
// Base64.m
|
||||
// YMhatFramework
|
||||
//
|
||||
// Created by chenran on 2017/5/4.
|
||||
// Copyright © 2017年 chenran. All rights reserved.
|
||||
//
|
||||
|
||||
#import "Base64.h"
|
||||
|
||||
@interface Base64()
|
||||
+(int)char2Int:(char)c;
|
||||
@end
|
||||
|
||||
@implementation Base64
|
||||
|
||||
static const char encodingTable[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
+(NSString *)encode:(NSData *)data
|
||||
{
|
||||
if (data.length == 0)
|
||||
return nil;
|
||||
|
||||
char *characters = malloc(data.length * 3 / 2);
|
||||
|
||||
if (characters == NULL)
|
||||
return nil;
|
||||
|
||||
int end = data.length - 3;
|
||||
int index = 0;
|
||||
int charCount = 0;
|
||||
int n = 0;
|
||||
|
||||
while (index <= end) {
|
||||
int d = (((int)(((char *)[data bytes])[index]) & 0x0ff) << 16)
|
||||
| (((int)(((char *)[data bytes])[index + 1]) & 0x0ff) << 8)
|
||||
| ((int)(((char *)[data bytes])[index + 2]) & 0x0ff);
|
||||
|
||||
characters[charCount++] = encodingTable[(d >> 18) & 63];
|
||||
characters[charCount++] = encodingTable[(d >> 12) & 63];
|
||||
characters[charCount++] = encodingTable[(d >> 6) & 63];
|
||||
characters[charCount++] = encodingTable[d & 63];
|
||||
|
||||
index += 3;
|
||||
|
||||
if(n++ >= 14)
|
||||
{
|
||||
n = 0;
|
||||
characters[charCount++] = ' ';
|
||||
}
|
||||
}
|
||||
|
||||
if(index == data.length - 2)
|
||||
{
|
||||
int d = (((int)(((char *)[data bytes])[index]) & 0x0ff) << 16)
|
||||
| (((int)(((char *)[data bytes])[index + 1]) & 255) << 8);
|
||||
characters[charCount++] = encodingTable[(d >> 18) & 63];
|
||||
characters[charCount++] = encodingTable[(d >> 12) & 63];
|
||||
characters[charCount++] = encodingTable[(d >> 6) & 63];
|
||||
characters[charCount++] = '=';
|
||||
}
|
||||
else if(index == data.length - 1)
|
||||
{
|
||||
int d = ((int)(((char *)[data bytes])[index]) & 0x0ff) << 16;
|
||||
characters[charCount++] = encodingTable[(d >> 18) & 63];
|
||||
characters[charCount++] = encodingTable[(d >> 12) & 63];
|
||||
characters[charCount++] = '=';
|
||||
characters[charCount++] = '=';
|
||||
}
|
||||
NSString * rtnStr = [[NSString alloc] initWithBytesNoCopy:characters length:charCount encoding:NSUTF8StringEncoding freeWhenDone:YES];
|
||||
return rtnStr;
|
||||
|
||||
}
|
||||
|
||||
+(NSData *)decode:(NSString *)data
|
||||
{
|
||||
if(data == nil || data.length <= 0) {
|
||||
return nil;
|
||||
}
|
||||
NSMutableData *rtnData = [[NSMutableData alloc]init];
|
||||
int slen = data.length;
|
||||
int index = 0;
|
||||
while (true) {
|
||||
while (index < slen && [data characterAtIndex:index] <= ' ') {
|
||||
index++;
|
||||
}
|
||||
if (index >= slen || index + 3 >= slen) {
|
||||
break;
|
||||
}
|
||||
|
||||
int byte = ([self char2Int:[data characterAtIndex:index]] << 18) + ([self char2Int:[data characterAtIndex:index + 1]] << 12) + ([self char2Int:[data characterAtIndex:index + 2]] << 6) + [self char2Int:[data characterAtIndex:index + 3]];
|
||||
Byte temp1 = (byte >> 16) & 255;
|
||||
[rtnData appendBytes:&temp1 length:1];
|
||||
if([data characterAtIndex:index + 2] == '=') {
|
||||
break;
|
||||
}
|
||||
Byte temp2 = (byte >> 8) & 255;
|
||||
[rtnData appendBytes:&temp2 length:1];
|
||||
if([data characterAtIndex:index + 3] == '=') {
|
||||
break;
|
||||
}
|
||||
Byte temp3 = byte & 255;
|
||||
[rtnData appendBytes:&temp3 length:1];
|
||||
index += 4;
|
||||
|
||||
}
|
||||
return rtnData;
|
||||
}
|
||||
|
||||
+(int)char2Int:(char)c
|
||||
{
|
||||
if (c >= 'A' && c <= 'Z') {
|
||||
return c - 65;
|
||||
} else if (c >= 'a' && c <= 'z') {
|
||||
return c - 97 + 26;
|
||||
} else if (c >= '0' && c <= '9') {
|
||||
return c - 48 + 26 + 26;
|
||||
} else {
|
||||
switch(c) {
|
||||
case '+':
|
||||
return 62;
|
||||
case '/':
|
||||
return 63;
|
||||
case '=':
|
||||
return 0;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@end
|
16
yana/Utils/Security/DESEncrypt.h
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// DESEncrypt.h
|
||||
// YMhatFramework
|
||||
//
|
||||
// Created by chenran on 2017/5/4.
|
||||
// Copyright © 2017年 chenran. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@interface DESEncrypt : NSObject
|
||||
//加密方法
|
||||
+(NSString *) encryptUseDES:(NSString *)plainText key:(NSString *)key;
|
||||
//解密方法
|
||||
+(NSString *) decryptUseDES:(NSString *)cipherText key:(NSString *)key;
|
||||
@end
|
63
yana/Utils/Security/DESEncrypt.m
Normal file
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// DESEncrypt.m
|
||||
// YMhatFramework
|
||||
//
|
||||
// Created by chenran on 2017/5/4.
|
||||
// Copyright © 2017年 chenran. All rights reserved.
|
||||
//
|
||||
|
||||
#import "DESEncrypt.h"
|
||||
#import <CommonCrypto/CommonCrypto.h>
|
||||
#import "Base64.h"
|
||||
|
||||
@implementation DESEncrypt : NSObject
|
||||
|
||||
const Byte iv[] = {1,2,3,4,5,6,7,8};
|
||||
|
||||
#pragma mark- 加密算法
|
||||
+(NSString *) encryptUseDES:(NSString *)plainText key:(NSString *)key
|
||||
{
|
||||
NSString *ciphertext = nil;
|
||||
NSData *textData = [plainText dataUsingEncoding:NSUTF8StringEncoding];
|
||||
NSUInteger dataLength = [textData length];
|
||||
unsigned char buffer[200000];
|
||||
memset(buffer, 0, sizeof(char));
|
||||
size_t numBytesEncrypted = 0;
|
||||
CCCryptorStatus cryptStatus = CCCrypt(kCCEncrypt, kCCAlgorithmDES,
|
||||
kCCOptionPKCS7Padding|kCCOptionECBMode,
|
||||
[key UTF8String], kCCKeySizeDES,
|
||||
iv,
|
||||
[textData bytes], dataLength,
|
||||
buffer, 200000,
|
||||
&numBytesEncrypted);
|
||||
if (cryptStatus == kCCSuccess) {
|
||||
NSData *data = [NSData dataWithBytes:buffer length:(NSUInteger)numBytesEncrypted];
|
||||
ciphertext = [Base64 encode:data];
|
||||
}
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
#pragma mark- 解密算法
|
||||
+(NSString *)decryptUseDES:(NSString *)cipherText key:(NSString *)key
|
||||
{
|
||||
NSString *plaintext = nil;
|
||||
NSData *cipherdata = [Base64 decode:cipherText];
|
||||
unsigned char buffer[200000];
|
||||
memset(buffer, 0, sizeof(char));
|
||||
size_t numBytesDecrypted = 0;
|
||||
// kCCOptionPKCS7Padding|kCCOptionECBMode 最主要在这步
|
||||
CCCryptorStatus cryptStatus = CCCrypt(kCCDecrypt, kCCAlgorithmDES,
|
||||
kCCOptionPKCS7Padding|kCCOptionECBMode,
|
||||
[key UTF8String], kCCKeySizeDES,
|
||||
iv,
|
||||
[cipherdata bytes], [cipherdata length],
|
||||
buffer, 200000,
|
||||
&numBytesDecrypted);
|
||||
if(cryptStatus == kCCSuccess) {
|
||||
NSData *plaindata = [NSData dataWithBytes:buffer length:(NSUInteger)numBytesDecrypted];
|
||||
plaintext = [[NSString alloc]initWithData:plaindata encoding:NSUTF8StringEncoding];
|
||||
}
|
||||
return plaintext;
|
||||
}
|
||||
@end
|
||||
|
51
yana/Utils/Security/DESEncryptOCTest.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// OC版本DES加密测试
|
||||
struct DESEncryptOCTest {
|
||||
|
||||
/// 测试 OC 版本的 DES 加密功能
|
||||
static func testOCDESEncryption() {
|
||||
print("🧪 开始测试 OC 版本的 DES 加密...")
|
||||
print(String(repeating: "=", count: 50))
|
||||
|
||||
let key = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
let testCases = [
|
||||
"test123",
|
||||
"hello world",
|
||||
"password123",
|
||||
"sample_data",
|
||||
"encrypt_test"
|
||||
]
|
||||
|
||||
for testCase in testCases {
|
||||
if let encrypted = DESEncrypt.encryptUseDES(testCase, key: key) {
|
||||
print("✅ 加密成功:")
|
||||
print(" 原文: \"\(testCase)\"")
|
||||
print(" 密文: \(encrypted)")
|
||||
|
||||
// 测试解密
|
||||
if let decrypted = DESEncrypt.decryptUseDES(encrypted, key: key) {
|
||||
let isMatch = decrypted == testCase
|
||||
print(" 解密: \"\(decrypted)\" \(isMatch ? "✅" : "❌")")
|
||||
} else {
|
||||
print(" 解密: 失败 ❌")
|
||||
}
|
||||
} else {
|
||||
print("❌ 加密失败: \"\(testCase)\"")
|
||||
}
|
||||
print()
|
||||
}
|
||||
|
||||
print(String(repeating: "=", count: 50))
|
||||
print("🏁 OC版本DES加密测试完成")
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension DESEncryptOCTest {
|
||||
/// 在 AppDelegate 中调用此方法进行测试
|
||||
static func runInAppDelegate() {
|
||||
DESEncryptOCTest.testOCDESEncryption()
|
||||
}
|
||||
}
|
||||
#endif
|
43
yana/Views/AppRootView.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
|
||||
struct AppRootView: View {
|
||||
@State private var shouldShowMainApp = false
|
||||
|
||||
let splashStore = Store(
|
||||
initialState: SplashFeature.State()
|
||||
) {
|
||||
SplashFeature()
|
||||
}
|
||||
|
||||
let loginStore = Store(
|
||||
initialState: LoginFeature.State()
|
||||
) {
|
||||
LoginFeature()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if shouldShowMainApp {
|
||||
// 登录界面
|
||||
LoginView(store: loginStore)
|
||||
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
|
||||
} else {
|
||||
// 启动画面
|
||||
SplashView(store: splashStore)
|
||||
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
|
||||
.onReceive(NotificationCenter.default.publisher(for: .splashFinished)) { _ in
|
||||
shouldShowMainApp = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let splashFinished = Notification.Name("splashFinished")
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AppRootView()
|
||||
}
|
59
yana/Views/Components/LoginButton.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Login Button Component
|
||||
struct LoginButton: View {
|
||||
let iconName: String
|
||||
let iconColor: Color
|
||||
let title: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
ZStack {
|
||||
// 背景
|
||||
Color.white
|
||||
.cornerRadius(28)
|
||||
|
||||
// 居中的文本
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.frame(alignment: .center)
|
||||
.foregroundColor(Color(hex: 0x313131))
|
||||
|
||||
// 左侧图标
|
||||
HStack {
|
||||
Image(systemName: iconName)
|
||||
.foregroundColor(iconColor)
|
||||
.font(.system(size: 30))
|
||||
.padding(.leading, 33)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.frame(height: 56)
|
||||
.padding(.horizontal, 29)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 20) {
|
||||
LoginButton(
|
||||
iconName: "person.circle.fill",
|
||||
iconColor: .green,
|
||||
title: "ID Login"
|
||||
) {
|
||||
// Preview action
|
||||
}
|
||||
|
||||
LoginButton(
|
||||
iconName: "envelope.fill",
|
||||
iconColor: .blue,
|
||||
title: "Email Login"
|
||||
) {
|
||||
// Preview action
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.2))
|
||||
}
|
88
yana/Views/Components/UserAgreementView.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - User Agreement View Component
|
||||
struct UserAgreementView: View {
|
||||
@Binding var isAgreed: Bool
|
||||
let onUserServiceTapped: () -> Void
|
||||
let onPrivacyPolicyTapped: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
// 左侧勾选按钮
|
||||
Button(action: {
|
||||
isAgreed.toggle()
|
||||
}) {
|
||||
Image(systemName: isAgreed ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundColor(isAgreed ? Color(hex: 0x8A4FFF) : Color(hex: 0x666666))
|
||||
}
|
||||
|
||||
// 右侧富文本
|
||||
Text(createAttributedText())
|
||||
.font(.system(size: 14))
|
||||
.multilineTextAlignment(.leading)
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
if url.absoluteString == "user-service-agreement" {
|
||||
onUserServiceTapped()
|
||||
return .handled
|
||||
} else if url.absoluteString == "privacy-policy" {
|
||||
onPrivacyPolicyTapped()
|
||||
return .handled
|
||||
}
|
||||
return .systemAction
|
||||
})
|
||||
}
|
||||
.frame(maxWidth: .infinity) // 占满可用宽度
|
||||
.padding(.horizontal, 29) // 与登录按钮保持一致的边距
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
private func createAttributedText() -> AttributedString {
|
||||
var attributedString = AttributedString("login.agreement_policy".localized)
|
||||
|
||||
// 设置默认颜色
|
||||
attributedString.foregroundColor = Color(hex: 0x666666)
|
||||
|
||||
// 找到并设置 "用户协议" 的样式和链接
|
||||
if let userServiceRange = attributedString.range(of: "login.agreement".localized) {
|
||||
attributedString[userServiceRange].foregroundColor = Color(hex: 0x8A4FFF)
|
||||
attributedString[userServiceRange].underlineStyle = .single
|
||||
attributedString[userServiceRange].link = URL(string: "user-service-agreement")
|
||||
}
|
||||
|
||||
// 找到并设置 "隐私政策" 的样式和链接
|
||||
if let privacyPolicyRange = attributedString.range(of: "login.policy".localized) {
|
||||
attributedString[privacyPolicyRange].foregroundColor = Color(hex: 0x8A4FFF)
|
||||
attributedString[privacyPolicyRange].underlineStyle = .single
|
||||
attributedString[privacyPolicyRange].link = URL(string: "privacy-policy")
|
||||
}
|
||||
|
||||
return attributedString
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 20) {
|
||||
UserAgreementView(
|
||||
isAgreed: .constant(true),
|
||||
onUserServiceTapped: {
|
||||
print("User Service Agreement tapped")
|
||||
},
|
||||
onPrivacyPolicyTapped: {
|
||||
print("Privacy Policy tapped")
|
||||
}
|
||||
)
|
||||
|
||||
UserAgreementView(
|
||||
isAgreed: .constant(true),
|
||||
onUserServiceTapped: {
|
||||
print("User Service Agreement tapped")
|
||||
},
|
||||
onPrivacyPolicyTapped: {
|
||||
print("Privacy Policy tapped")
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
55
yana/Views/Components/WebView.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
|
||||
// MARK: - Web View Component
|
||||
struct WebView: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> SFSafariViewController {
|
||||
let config = SFSafariViewController.Configuration()
|
||||
config.entersReaderIfAvailable = false
|
||||
config.barCollapsingEnabled = true
|
||||
|
||||
let safariViewController = SFSafariViewController(url: url, configuration: config)
|
||||
safariViewController.preferredBarTintColor = UIColor.systemBackground
|
||||
safariViewController.preferredControlTintColor = UIColor.systemBlue
|
||||
|
||||
return safariViewController
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
|
||||
// Safari View Controller 不需要更新
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Web View Modifier
|
||||
extension View {
|
||||
/// 显示 Web 页面的修饰符
|
||||
/// - Parameters:
|
||||
/// - isPresented: 是否显示的绑定变量
|
||||
/// - url: 要显示的 URL
|
||||
/// - Returns: 修饰后的视图
|
||||
func webView(isPresented: Binding<Bool>, url: URL?) -> some View {
|
||||
self.sheet(isPresented: isPresented) {
|
||||
if let url = url {
|
||||
WebView(url: url)
|
||||
} else {
|
||||
Text("无法加载页面")
|
||||
.foregroundColor(.red)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
Button("打开网页") {
|
||||
// 预览时不执行任何操作
|
||||
}
|
||||
}
|
||||
.webView(
|
||||
isPresented: .constant(true),
|
||||
url: URL(string: "https://www.apple.com")
|
||||
)
|
||||
}
|
214
yana/Views/EMailLoginView.swift
Normal file
@@ -0,0 +1,214 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
|
||||
struct EMailLoginView: View {
|
||||
let store: StoreOf<EMailLoginFeature>
|
||||
let onBack: () -> Void
|
||||
|
||||
// 使用本地@State管理UI状态
|
||||
@State private var email: String = ""
|
||||
@State private var verificationCode: String = ""
|
||||
|
||||
// 计算登录按钮是否可用
|
||||
private var isLoginButtonEnabled: Bool {
|
||||
return !store.isLoading && !email.isEmpty && !verificationCode.isEmpty
|
||||
}
|
||||
|
||||
// 计算获取验证码按钮文本
|
||||
private var getCodeButtonText: String {
|
||||
if store.isCodeLoading {
|
||||
return ""
|
||||
} else if store.codeCountdown > 0 {
|
||||
return "\(store.codeCountdown)S"
|
||||
} else {
|
||||
return "email_login.get_code".localized
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// 背景图片 - 使用与登录页面相同的"bg"
|
||||
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("email_login.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("placeholder.enter_email".localized)
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
.padding(.horizontal, 24)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
|
||||
// 验证码输入框
|
||||
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("placeholder.enter_verification_code".localized)
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
// 获取验证码按钮
|
||||
Button(action: {
|
||||
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: 18)
|
||||
.fill(Color.white.opacity(store.isCodeButtonEnabled && !email.isEmpty ? 0.2 : 0.1))
|
||||
)
|
||||
}
|
||||
.disabled(!store.isCodeButtonEnabled || email.isEmpty || store.isCodeLoading)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Spacer()
|
||||
.frame(height: 60)
|
||||
|
||||
// 登录按钮
|
||||
Button(action: {
|
||||
// 发送登录action时传递本地状态
|
||||
store.send(.loginButtonTapped(email: email, verificationCode: verificationCode))
|
||||
}) {
|
||||
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.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
Text(store.isLoading ? "email_login.logging_in".localized : "email_login.login_button".localized)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
.frame(height: 56)
|
||||
}
|
||||
.disabled(store.isLoading || email.isEmpty || verificationCode.isEmpty)
|
||||
.opacity(isLoginButtonEnabled ? 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 {
|
||||
// 初始化时同步TCA状态到本地状态
|
||||
email = store.email
|
||||
verificationCode = store.verificationCode
|
||||
|
||||
#if DEBUG
|
||||
// Debug环境下,确保默认数据已加载
|
||||
if email.isEmpty {
|
||||
email = "85494536@gmail.com"
|
||||
}
|
||||
if verificationCode.isEmpty {
|
||||
verificationCode = "784544"
|
||||
}
|
||||
print("🐛 Debug模式: 默认邮箱=\(email), 默认验证码=\(verificationCode)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
EMailLoginView(
|
||||
store: Store(
|
||||
initialState: EMailLoginFeature.State()
|
||||
) {
|
||||
EMailLoginFeature()
|
||||
},
|
||||
onBack: {}
|
||||
)
|
||||
}
|
205
yana/Views/IDLoginView.swift
Normal file
@@ -0,0 +1,205 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
|
||||
struct IDLoginView: View {
|
||||
let store: StoreOf<IDLoginFeature>
|
||||
let onBack: () -> Void
|
||||
|
||||
// 使用本地@State管理UI状态
|
||||
@State private var userID: String = ""
|
||||
@State private var password: String = ""
|
||||
@State private var isPasswordVisible: Bool = false
|
||||
|
||||
// 计算登录按钮是否可用
|
||||
private var isLoginButtonEnabled: Bool {
|
||||
return !store.isLoading && !userID.isEmpty && !password.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// 背景图片 - 使用与登录页面相同的"bg"
|
||||
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("id_login.title".localized)
|
||||
.font(.system(size: 28, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.bottom, 80)
|
||||
|
||||
// 输入框区域
|
||||
VStack(spacing: 24) {
|
||||
// ID 输入框
|
||||
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: $userID) // 使用SwiftUI的绑定
|
||||
.placeholder(when: userID.isEmpty) {
|
||||
Text("placeholder.enter_id".localized)
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
.padding(.horizontal, 24)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
|
||||
// 密码输入框
|
||||
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 isPasswordVisible {
|
||||
TextField("", text: $password) // 使用SwiftUI的绑定
|
||||
.placeholder(when: password.isEmpty) {
|
||||
Text("placeholder.enter_password".localized)
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
} else {
|
||||
SecureField("", text: $password) // 使用SwiftUI的绑定
|
||||
.placeholder(when: password.isEmpty) {
|
||||
Text("placeholder.enter_password".localized)
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
isPasswordVisible.toggle()
|
||||
}) {
|
||||
Image(systemName: isPasswordVisible ? "eye.slash" : "eye")
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.font(.system(size: 18))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
// Forgot Password 链接
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
store.send(.forgotPasswordTapped)
|
||||
}) {
|
||||
Text("id_login.forgot_password".localized)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 16)
|
||||
|
||||
Spacer()
|
||||
.frame(height: 60)
|
||||
|
||||
// 登录按钮
|
||||
Button(action: {
|
||||
// 发送登录action时传递本地状态
|
||||
store.send(.loginButtonTapped(userID: userID, password: password))
|
||||
}) {
|
||||
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.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
Text(store.isLoading ? "id_login.logging_in".localized : "id_login.login_button".localized)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
.frame(height: 56)
|
||||
}
|
||||
.disabled(store.isLoading || userID.isEmpty || password.isEmpty)
|
||||
.opacity(isLoginButtonEnabled ? 1.0 : 0.5) // 透明度50%当条件不满足时
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
// 错误信息
|
||||
if let errorMessage = store.errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.red)
|
||||
.padding(.top, 16)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// 初始化时同步TCA状态到本地状态
|
||||
userID = store.userID
|
||||
password = store.password
|
||||
isPasswordVisible = store.isPasswordVisible
|
||||
|
||||
#if DEBUG
|
||||
// 移除测试用的硬编码凭据
|
||||
print("🐛 Debug模式: 已移除硬编码测试凭据")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
IDLoginView(
|
||||
store: Store(
|
||||
initialState: IDLoginFeature.State()
|
||||
) {
|
||||
IDLoginFeature()
|
||||
},
|
||||
onBack: {}
|
||||
)
|
||||
}
|
96
yana/Views/LanguageSettingsView.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
|
||||
struct LanguageSettingsView: View {
|
||||
@ObservedObject private var localizationManager = LocalizationManager.shared
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
init(isPresented: Binding<Bool> = .constant(true)) {
|
||||
self._isPresented = isPresented
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Section {
|
||||
ForEach(LocalizationManager.SupportedLanguage.allCases, id: \.rawValue) { language in
|
||||
LanguageRow(
|
||||
language: language,
|
||||
isSelected: localizationManager.currentLanguage == language
|
||||
) {
|
||||
localizationManager.switchLanguage(to: language)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("选择语言 / Select Language")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text("当前语言 / Current Language")
|
||||
.font(.body)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(localizationManager.currentLanguage.localizedDisplayName)
|
||||
.font(.body)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
} header: {
|
||||
Text("语言信息 / Language Info")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("语言设置 / Language")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("返回 / Back") {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LanguageRow: View {
|
||||
let language: LocalizationManager.SupportedLanguage
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(language.localizedDisplayName)
|
||||
.font(.body)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(language.displayName)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.blue)
|
||||
.font(.system(size: 20))
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
LanguageSettingsView(isPresented: .constant(true))
|
||||
}
|
160
yana/Views/LoginView.swift
Normal file
@@ -0,0 +1,160 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
|
||||
// PreferenceKey 用于传递图片高度
|
||||
struct ImageHeightPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginView: View {
|
||||
let store: StoreOf<LoginFeature>
|
||||
@State private var topImageHeight: CGFloat = 120 // 默认值
|
||||
@ObservedObject private var localizationManager = LocalizationManager.shared
|
||||
@State private var showLanguageSettings = false
|
||||
@State private var isAgreedToTerms = true
|
||||
@State private var showUserAgreement = false
|
||||
@State private var showPrivacyPolicy = false
|
||||
@State private var showIDLogin = false // 使用SwiftUI的@State管理导航
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// 使用与 splash 相同的背景图片
|
||||
Image("bg")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.ignoresSafeArea(.all)
|
||||
VStack(spacing: 0) {
|
||||
// 上半部分的"top"图片
|
||||
ZStack {
|
||||
Image("top")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, -100)
|
||||
.background(
|
||||
GeometryReader { topImageGeometry in
|
||||
Color.clear.preference(key: ImageHeightPreferenceKey.self, value: topImageGeometry.size.height)
|
||||
}
|
||||
)
|
||||
// E-PARTI 文本,底部对齐"top"图片底部,间距20
|
||||
HStack {
|
||||
Text("login.app_title".localized)
|
||||
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
|
||||
.foregroundColor(.white)
|
||||
.padding(.leading, 20)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, max(0, topImageHeight - 100)) // top图片高度 - 140
|
||||
|
||||
// 语言切换按钮(右上角)- 仅在 Debug 环境下显示
|
||||
#if DEBUG
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
showLanguageSettings = true
|
||||
}) {
|
||||
Image(systemName: "globe")
|
||||
.frame(width: 40, height: 40)
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.white)
|
||||
.background(Color.black.opacity(0.3))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
#endif
|
||||
|
||||
VStack(spacing: 24) {
|
||||
// ID Login 按钮
|
||||
LoginButton(
|
||||
iconName: "person.circle.fill",
|
||||
iconColor: .green,
|
||||
title: "login.id_login".localized
|
||||
) {
|
||||
showIDLogin = true // 直接设置SwiftUI状态
|
||||
}
|
||||
// Email Login 按钮
|
||||
LoginButton(
|
||||
iconName: "envelope.fill",
|
||||
iconColor: .blue,
|
||||
title: "login.email_login".localized
|
||||
) {
|
||||
// TODO: 处理Email登录
|
||||
}
|
||||
}.padding(.top, max(0, topImageHeight+140))
|
||||
}
|
||||
.onPreferenceChange(ImageHeightPreferenceKey.self) { imageHeight in
|
||||
topImageHeight = imageHeight
|
||||
}
|
||||
|
||||
// 间距,使登录按钮区域顶部距离"top"图片底部40pt
|
||||
Spacer()
|
||||
.frame(height: 120)
|
||||
|
||||
// 用户协议组件
|
||||
UserAgreementView(
|
||||
isAgreed: $isAgreedToTerms,
|
||||
onUserServiceTapped: {
|
||||
showUserAgreement = true
|
||||
},
|
||||
onPrivacyPolicyTapped: {
|
||||
showPrivacyPolicy = true
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 140)
|
||||
}
|
||||
|
||||
// 隐藏的NavigationLink - 使用纯SwiftUI方式
|
||||
NavigationLink(
|
||||
destination: IDLoginView(
|
||||
store: store.scope(
|
||||
state: \.idLoginState,
|
||||
action: \.idLogin
|
||||
),
|
||||
onBack: {
|
||||
showIDLogin = false // 直接设置SwiftUI状态
|
||||
}
|
||||
)
|
||||
.navigationBarHidden(true),
|
||||
isActive: $showIDLogin // 使用SwiftUI的绑定
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.sheet(isPresented: $showLanguageSettings) {
|
||||
LanguageSettingsView(isPresented: $showLanguageSettings)
|
||||
}
|
||||
.webView(
|
||||
isPresented: $showUserAgreement,
|
||||
url: APIConfiguration.webURL(for: .userAgreement)
|
||||
)
|
||||
.webView(
|
||||
isPresented: $showPrivacyPolicy,
|
||||
url: APIConfiguration.webURL(for: .privacyPolicy)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LoginView(
|
||||
store: Store(
|
||||
initialState: LoginFeature.State()
|
||||
) {
|
||||
LoginFeature()
|
||||
}
|
||||
)
|
||||
}
|
49
yana/Views/SplashView.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
|
||||
struct SplashView: View {
|
||||
let store: StoreOf<SplashFeature>
|
||||
|
||||
var body: some View {
|
||||
WithPerceptionTracking {
|
||||
ZStack {
|
||||
// 背景图片 - 全屏显示
|
||||
Image("bg")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.ignoresSafeArea(.all)
|
||||
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
.frame(height: 200) // 与 storyboard 中的约束对应
|
||||
|
||||
// Logo 图片 - 100x100
|
||||
Image("logo")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
// 应用标题 - 白色,40pt字体
|
||||
Text("E-Parti")
|
||||
.font(.system(size: 40, weight: .regular))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
store.send(.onAppear)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SplashView(
|
||||
store: Store(
|
||||
initialState: SplashFeature.State()
|
||||
) {
|
||||
SplashFeature()
|
||||
}
|
||||
)
|
||||
}
|
@@ -2,3 +2,10 @@
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
|
||||
// DES 加密相关 OC 文件
|
||||
#import "DESEncrypt.h"
|
||||
#import "Base64.h"
|
||||
|
||||
// AES 加密相关 OC 文件
|
||||
#import "AESUtils.h"
|
||||
|
||||
|
@@ -1,8 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.external-accessory.wireless-configuration</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<dict/>
|
||||
</plist>
|
||||
|
@@ -12,25 +12,21 @@ import ComposableArchitecture
|
||||
struct yanaApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
init() {
|
||||
// 禁用SwiftUI Previews调试日志 (仅在DEBUG模式下)
|
||||
#if DEBUG
|
||||
// 减少SwiftUI Previews相关的调试输出
|
||||
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == nil {
|
||||
// 不是在Previews环境中运行
|
||||
}
|
||||
#endif
|
||||
|
||||
print("🛠 原生URLSession测试开始")
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView(
|
||||
store: Store(
|
||||
initialState: LoginFeature.State()
|
||||
) {
|
||||
LoginFeature()
|
||||
},
|
||||
initStore: Store(
|
||||
initialState: InitFeature.State()
|
||||
) {
|
||||
InitFeature()
|
||||
},
|
||||
configStore: Store(
|
||||
initialState: ConfigFeature.State()
|
||||
) {
|
||||
ConfigFeature()
|
||||
}
|
||||
)
|
||||
AppRootView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|