diff --git a/.cursor/rules/swift-tca-architecture-guidelines.mdc b/.cursor/rules/swift-tca-architecture-guidelines.mdc index ca4e919..c64d79b 100644 --- a/.cursor/rules/swift-tca-architecture-guidelines.mdc +++ b/.cursor/rules/swift-tca-architecture-guidelines.mdc @@ -1,7 +1,5 @@ --- -description: -globs: -alwaysApply: true +alwaysApply: false --- # TCA Architecture Guidelines - Use The Composable Architecture (TCA) for state management and side effect handling. diff --git a/Package.swift b/Package.swift index 52dbc35..ea74478 100644 --- a/Package.swift +++ b/Package.swift @@ -5,8 +5,8 @@ import PackageDescription let package = Package( name: "yana", platforms: [ - .iOS(.v17), - .macOS(.v14) + .iOS(.v15), + .macOS(.v12) ], products: [ .library( diff --git a/yana.xcodeproj/project.pbxproj b/yana.xcodeproj/project.pbxproj index 37b637a..a7a88a0 100644 --- a/yana.xcodeproj/project.pbxproj +++ b/yana.xcodeproj/project.pbxproj @@ -10,7 +10,7 @@ 4C4C91092DE85A3E00384527 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */; }; 4C4C910B2DE85A4F00384527 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */; }; 4C4C91302DE864F000384527 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 4C4C912F2DE864F000384527 /* ComposableArchitecture */; }; - 856EF8A28776CEF6CE595B76 /* Pods_yana.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8529F57AF9337F626C670ED /* Pods_yana.framework */; }; + DF26704F4C1F2ABA0405A0C3 /* Pods_yana.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9DBA38268C435BE06F39AE2 /* Pods_yana.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -24,13 +24,13 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 0977E1E6E883533DD125CAD4 /* Pods-yana.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-yana.debug.xcconfig"; path = "Target Support Files/Pods-yana/Pods-yana.debug.xcconfig"; sourceTree = ""; }; 4C3E651F2DB61F7A00E5A455 /* yana.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = yana.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4C4C8FBD2DE5AF9200384527 /* yanaAPITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = yanaAPITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; 4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; - 977CD030E95CB064179F3A1B /* Pods-yana.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-yana.release.xcconfig"; path = "Target Support Files/Pods-yana/Pods-yana.release.xcconfig"; sourceTree = ""; }; - D8529F57AF9337F626C670ED /* Pods_yana.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_yana.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A5E34AF461FA7ADC3DFB5276 /* Pods-yana.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-yana.debug.xcconfig"; path = "Target Support Files/Pods-yana/Pods-yana.debug.xcconfig"; sourceTree = ""; }; + E9DBA38268C435BE06F39AE2 /* Pods_yana.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_yana.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + EB8184AF9789E3C79FC90DBB /* Pods-yana.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-yana.release.xcconfig"; path = "Target Support Files/Pods-yana/Pods-yana.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -47,8 +47,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = yanaAPITests; sourceTree = ""; }; @@ -68,9 +66,9 @@ buildActionMask = 2147483647; files = ( 4C4C91092DE85A3E00384527 /* SystemConfiguration.framework in Frameworks */, - 856EF8A28776CEF6CE595B76 /* Pods_yana.framework in Frameworks */, 4C4C91302DE864F000384527 /* ComposableArchitecture in Frameworks */, 4C4C910B2DE85A4F00384527 /* CoreFoundation.framework in Frameworks */, + DF26704F4C1F2ABA0405A0C3 /* Pods_yana.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -87,7 +85,6 @@ 4C3E65162DB61F7A00E5A455 = { isa = PBXGroup; children = ( - 4C4C8FE72DE6F05300384527 /* tools */, 4C55BD992DB64C3C0021505D /* yana */, 4C4C8FBE2DE5AF9200384527 /* yanaAPITests */, 4C3E65202DB61F7A00E5A455 /* Products */, @@ -105,19 +102,12 @@ name = Products; sourceTree = ""; }; - 4C4C8FE72DE6F05300384527 /* tools */ = { - isa = PBXGroup; - children = ( - ); - path = tools; - sourceTree = ""; - }; 556C2003CCDA5AC2C56882D0 /* Frameworks */ = { isa = PBXGroup; children = ( 4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */, 4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */, - D8529F57AF9337F626C670ED /* Pods_yana.framework */, + E9DBA38268C435BE06F39AE2 /* Pods_yana.framework */, ); name = Frameworks; sourceTree = ""; @@ -125,8 +115,8 @@ 87A8B7A8B4E2D53BA55B66D1 /* Pods */ = { isa = PBXGroup; children = ( - 0977E1E6E883533DD125CAD4 /* Pods-yana.debug.xcconfig */, - 977CD030E95CB064179F3A1B /* Pods-yana.release.xcconfig */, + A5E34AF461FA7ADC3DFB5276 /* Pods-yana.debug.xcconfig */, + EB8184AF9789E3C79FC90DBB /* Pods-yana.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -148,12 +138,12 @@ isa = PBXNativeTarget; buildConfigurationList = 4C3E652A2DB61F7B00E5A455 /* Build configuration list for PBXNativeTarget "yana" */; buildPhases = ( - E0BDB6E67FEFE696E7D48CE4 /* [CP] Check Pods Manifest.lock */, + 5EE242260A8B893DC4D6B6DD /* [CP] Check Pods Manifest.lock */, 4C4C90522DE6FCF700384527 /* Headers */, 4C3E651B2DB61F7A00E5A455 /* Sources */, 4C3E651C2DB61F7A00E5A455 /* Frameworks */, 4C3E651D2DB61F7A00E5A455 /* Resources */, - 80E012C82442392F08611AA3 /* [CP] Embed Pods Frameworks */, + A9AAC370C902C50E37521C40 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -213,6 +203,7 @@ knownRegions = ( en, Base, + "zh-Hans", ); mainGroup = 4C3E65162DB61F7A00E5A455; minimizedProjectReferenceProxies = 1; @@ -248,24 +239,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 80E012C82442392F08611AA3 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - E0BDB6E67FEFE696E7D48CE4 /* [CP] Check Pods Manifest.lock */ = { + 5EE242260A8B893DC4D6B6DD /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -287,6 +261,27 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + A9AAC370C902C50E37521C40 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -438,10 +433,11 @@ }; 4C3E652B2DB61F7B00E5A455 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 0977E1E6E883533DD125CAD4 /* Pods-yana.debug.xcconfig */; + baseConfigurationReference = A5E34AF461FA7ADC3DFB5276 /* Pods-yana.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = yana/yana.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -462,11 +458,12 @@ "\"${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers\"", ); INFOPLIST_FILE = yana/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = EParti; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "此App将可发现和连接到您所用网络上的设备。"; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“MoliStar”需要您的同意,才可以进行定位服务,访问网络状态"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“eparty”需要您的同意,才可以进行定位服务,访问网络状态"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.6; @@ -474,7 +471,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 20.20.61; + MARKETING_VERSION = 1.0.0; OTHER_LIBTOOLFLAGS = "-framework \"SystemConfiguration\""; PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yana.yana; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -491,10 +488,11 @@ }; 4C3E652C2DB61F7B00E5A455 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 977CD030E95CB064179F3A1B /* Pods-yana.release.xcconfig */; + baseConfigurationReference = EB8184AF9789E3C79FC90DBB /* Pods-yana.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = yana/yana.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -515,11 +513,12 @@ "\"${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers\"", ); INFOPLIST_FILE = yana/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = EParti; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "此App将可发现和连接到您所用网络上的设备。"; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“MoliStar”需要您的同意,才可以进行定位服务,访问网络状态"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“eparty”需要您的同意,才可以进行定位服务,访问网络状态"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.6; @@ -527,7 +526,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 20.20.61; + MARKETING_VERSION = 1.0.0; OTHER_LIBTOOLFLAGS = "-framework \"SystemConfiguration\""; PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yana.yana; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index 47d6a64..0445d31 100644 --- a/yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -164,5 +164,21 @@ landmarkType = "7"> + + + + diff --git a/yana/APIs/API rule.md b/yana/APIs/API rule.md index e8a4884..1863dcb 100644 --- a/yana/APIs/API rule.md +++ b/yana/APIs/API rule.md @@ -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 请求的安全性、稳定性和高性能,为应用提供了可靠的网络服务基础。 \ No newline at end of file +这种设计确保了 API 请求的安全性、稳定性和高性能,为应用提供了可靠的网络服务基础。 diff --git a/yana/APIs/APIConstants.swift b/yana/APIs/APIConstants.swift index 035c248..a39bbe3 100644 --- a/yana/APIs/APIConstants.swift +++ b/yana/APIs/APIConstants.swift @@ -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 diff --git a/yana/APIs/APIEndpoints.swift b/yana/APIs/APIEndpoints.swift index 366b149..ee4217b 100644 --- a/yana/APIs/APIEndpoints.swift +++ b/yana/APIs/APIEndpoints.swift @@ -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(如果存在) diff --git a/yana/APIs/APIModels.swift b/yana/APIs/APIModels.swift index d6ccdd5..24417b7 100644 --- a/yana/APIs/APIModels.swift +++ b/yana/APIs/APIModels.swift @@ -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: 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 diff --git a/yana/APIs/APIService.swift b/yana/APIs/APIService.swift index 5bc14bb..2ab8a6e 100644 --- a/yana/APIs/APIService.swift +++ b/yana/APIs/APIService.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) } diff --git a/yana/APIs/LoginModels.swift b/yana/APIs/LoginModels.swift new file mode 100644 index 0000000..10afda4 --- /dev/null +++ b/yana/APIs/LoginModels.swift @@ -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 diff --git a/yana/APIs/oauth flow.md b/yana/APIs/oauth flow.md new file mode 100644 index 0000000..7ccc3fa --- /dev/null +++ b/yana/APIs/oauth flow.md @@ -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` - 账户数据模型 \ No newline at end of file diff --git a/yana/APIs/oauth flow.svg b/yana/APIs/oauth flow.svg new file mode 100644 index 0000000..55c0c24 --- /dev/null +++ b/yana/APIs/oauth flow.svg @@ -0,0 +1 @@ +本地存储业务API服务器OAuth服务器客户端应用用户本地存储业务API服务器OAuth服务器客户端应用用户OAuth/Ticket 认证流程业务API调用Ticket过期处理Access Token过期输入手机号/密码DES加密敏感信息POST /oauth/token(phone, code/password, client_secret)返回 access_token保存 AccountModelPOST /oauth/ticket(access_token, issue_type)返回 ticket保存 ticket (内存)自动添加请求头pub_uid, pub_ticket业务API请求正常响应业务API请求401 未授权获取 access_tokenPOST /oauth/ticket(access_token, issue_type)返回新 ticket更新 ticket重试业务API请求正常响应POST /oauth/ticket(过期的access_token)401 Token过期跳转登录页面 \ No newline at end of file diff --git a/yana/AppDelegate.swift b/yana/AppDelegate.swift index c9cf127..6fc26df 100644 --- a/yana/AppDelegate.swift +++ b/yana/AppDelegate.swift @@ -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() diff --git a/yana/Assets.xcassets/Login/Contents.json b/yana/Assets.xcassets/Login/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/yana/Assets.xcassets/Login/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/yana/Assets.xcassets/Login/bg.imageset/Contents.json b/yana/Assets.xcassets/Login/bg.imageset/Contents.json new file mode 100644 index 0000000..85a447c --- /dev/null +++ b/yana/Assets.xcassets/Login/bg.imageset/Contents.json @@ -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 + } +} diff --git a/yana/Assets.xcassets/Login/bg.imageset/bg@3x.png b/yana/Assets.xcassets/Login/bg.imageset/bg@3x.png new file mode 100644 index 0000000..aeed734 Binary files /dev/null and b/yana/Assets.xcassets/Login/bg.imageset/bg@3x.png differ diff --git a/yana/Assets.xcassets/Login/email icon.imageset/Contents.json b/yana/Assets.xcassets/Login/email icon.imageset/Contents.json new file mode 100644 index 0000000..bb4f25c --- /dev/null +++ b/yana/Assets.xcassets/Login/email icon.imageset/Contents.json @@ -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 + } +} diff --git a/yana/Assets.xcassets/Login/email icon.imageset/切图 65@3x (1).png b/yana/Assets.xcassets/Login/email icon.imageset/切图 65@3x (1).png new file mode 100644 index 0000000..67f7f28 Binary files /dev/null and b/yana/Assets.xcassets/Login/email icon.imageset/切图 65@3x (1).png differ diff --git a/yana/Assets.xcassets/Login/id icon.imageset/Contents.json b/yana/Assets.xcassets/Login/id icon.imageset/Contents.json new file mode 100644 index 0000000..3458ed7 --- /dev/null +++ b/yana/Assets.xcassets/Login/id icon.imageset/Contents.json @@ -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 + } +} diff --git a/yana/Assets.xcassets/Login/id icon.imageset/切图 65@3x.png b/yana/Assets.xcassets/Login/id icon.imageset/切图 65@3x.png new file mode 100644 index 0000000..4eb3914 Binary files /dev/null and b/yana/Assets.xcassets/Login/id icon.imageset/切图 65@3x.png differ diff --git a/yana/Assets.xcassets/Login/logo.imageset/Contents.json b/yana/Assets.xcassets/Login/logo.imageset/Contents.json new file mode 100644 index 0000000..b1d66c1 --- /dev/null +++ b/yana/Assets.xcassets/Login/logo.imageset/Contents.json @@ -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 + } +} diff --git a/yana/Assets.xcassets/Login/logo.imageset/logo@3x.png b/yana/Assets.xcassets/Login/logo.imageset/logo@3x.png new file mode 100644 index 0000000..22df418 Binary files /dev/null and b/yana/Assets.xcassets/Login/logo.imageset/logo@3x.png differ diff --git a/yana/Assets.xcassets/Login/selected icon.imageset/Contents.json b/yana/Assets.xcassets/Login/selected icon.imageset/Contents.json new file mode 100644 index 0000000..44ba855 --- /dev/null +++ b/yana/Assets.xcassets/Login/selected icon.imageset/Contents.json @@ -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 + } +} diff --git a/yana/Assets.xcassets/Login/selected icon.imageset/勾选@3x (1).png b/yana/Assets.xcassets/Login/selected icon.imageset/勾选@3x (1).png new file mode 100644 index 0000000..6593660 Binary files /dev/null and b/yana/Assets.xcassets/Login/selected icon.imageset/勾选@3x (1).png differ diff --git a/yana/Assets.xcassets/Login/top.imageset/Contents.json b/yana/Assets.xcassets/Login/top.imageset/Contents.json new file mode 100644 index 0000000..c5cd054 --- /dev/null +++ b/yana/Assets.xcassets/Login/top.imageset/Contents.json @@ -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 + } +} diff --git a/yana/Assets.xcassets/Login/top.imageset/top@3x.png b/yana/Assets.xcassets/Login/top.imageset/top@3x.png new file mode 100644 index 0000000..d305063 Binary files /dev/null and b/yana/Assets.xcassets/Login/top.imageset/top@3x.png differ diff --git a/yana/Assets.xcassets/Login/unselected icon.imageset/Contents.json b/yana/Assets.xcassets/Login/unselected icon.imageset/Contents.json new file mode 100644 index 0000000..b072bb6 --- /dev/null +++ b/yana/Assets.xcassets/Login/unselected icon.imageset/Contents.json @@ -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 + } +} diff --git a/yana/Assets.xcassets/Login/unselected icon.imageset/勾选@3x.png b/yana/Assets.xcassets/Login/unselected icon.imageset/勾选@3x.png new file mode 100644 index 0000000..398e432 Binary files /dev/null and b/yana/Assets.xcassets/Login/unselected icon.imageset/勾选@3x.png differ diff --git a/yana/Configs/AppConfig.swift b/yana/Configs/AppConfig.swift index 5c5e89d..fe28aa5 100644 --- a/yana/Configs/AppConfig.swift +++ b/yana/Configs/AppConfig.swift @@ -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 + } } -} \ No newline at end of file +} diff --git a/yana/ContentView.swift b/yana/ContentView.swift index d3d9dab..87db961 100644 --- a/yana/ContentView.swift +++ b/yana/ContentView.swift @@ -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 + } } } } diff --git a/yana/Features/ConfigView.swift b/yana/Features/ConfigView.swift index c93b8bf..bdf864e 100644 --- a/yana/Features/ConfigView.swift +++ b/yana/Features/ConfigView.swift @@ -5,7 +5,7 @@ struct ConfigView: View { let store: StoreOf 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) diff --git a/yana/Features/EMailLoginFeature.swift b/yana/Features/EMailLoginFeature.swift new file mode 100644 index 0000000..869a24b --- /dev/null +++ b/yana/Features/EMailLoginFeature.swift @@ -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 { + 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) + } +} \ No newline at end of file diff --git a/yana/Features/IDLoginFeature.swift b/yana/Features/IDLoginFeature.swift new file mode 100644 index 0000000..f05fcac --- /dev/null +++ b/yana/Features/IDLoginFeature.swift @@ -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) + + // 新增:Ticket 相关 actions + case requestTicket(accessToken: String) + case ticketResponse(TaskResult) + case clearTicketError + case resetLogin + } + + @Dependency(\.apiService) var apiService + + var body: some ReducerOf { + 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 + } + } + } +} diff --git a/yana/Features/LoginFeature.swift b/yana/Features/LoginFeature.swift index 812e5e1..b0399ea 100644 --- a/yana/Features/LoginFeature.swift +++ b/yana/Features/LoginFeature.swift @@ -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) + case loginResponse(TaskResult) + case idLogin(IDLoginFeature.Action) + + // 新增:Ticket 相关 actions + case requestTicket(accessToken: String) + case ticketResponse(TaskResult) + case clearTicketError + case resetLogin } + @Dependency(\.apiService) var apiService + var body: some ReducerOf { -// 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 + } + } } } diff --git a/yana/Features/SplashFeature.swift b/yana/Features/SplashFeature.swift new file mode 100644 index 0000000..0322f11 --- /dev/null +++ b/yana/Features/SplashFeature.swift @@ -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 { + 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 + } + } + } +} \ No newline at end of file diff --git a/yana/Fonts/Bayon-Regular.ttf b/yana/Fonts/Bayon-Regular.ttf new file mode 100644 index 0000000..369b719 Binary files /dev/null and b/yana/Fonts/Bayon-Regular.ttf differ diff --git a/yana/Fonts/README.md b/yana/Fonts/README.md new file mode 100644 index 0000000..140f954 --- /dev/null +++ b/yana/Fonts/README.md @@ -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. 运行调试代码确认字体是否被系统识别 \ No newline at end of file diff --git a/yana/Info.plist b/yana/Info.plist index 5451af2..d5b7ef5 100644 --- a/yana/Info.plist +++ b/yana/Info.plist @@ -9,5 +9,9 @@ NSWiFiUsageDescription 应用需要访问 Wi-Fi 信息以提供网络相关功能 + UIAppFonts + + Bayon-Regular.ttf + diff --git a/yana/LaunchScreen.storyboard b/yana/LaunchScreen.storyboard new file mode 100644 index 0000000..8f1885b --- /dev/null +++ b/yana/LaunchScreen.storyboard @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/yana/Managers/NIMConfigurationManager.swift b/yana/Managers/NIMConfigurationManager.swift deleted file mode 100644 index 184d683..0000000 --- a/yana/Managers/NIMConfigurationManager.swift +++ /dev/null @@ -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 - } -} diff --git a/yana/Managers/NIMSessionManager.swift b/yana/Managers/NIMSessionManager.swift deleted file mode 100644 index 16287f1..0000000 --- a/yana/Managers/NIMSessionManager.swift +++ /dev/null @@ -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 -// ) -// } -} diff --git a/yana/Resources/Localizable.strings b/yana/Resources/Localizable.strings new file mode 100644 index 0000000..b85e582 --- /dev/null +++ b/yana/Resources/Localizable.strings @@ -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"; diff --git a/yana/Resources/zh-Hans.lproj/Localizable.strings b/yana/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..ffc3e45 --- /dev/null +++ b/yana/Resources/zh-Hans.lproj/Localizable.strings @@ -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" = "登录失败,请检查您的凭据"; diff --git a/yana/Utils/Extensions/Color+Hex.swift b/yana/Utils/Extensions/Color+Hex.swift new file mode 100644 index 0000000..3548eb2 --- /dev/null +++ b/yana/Utils/Extensions/Color+Hex.swift @@ -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) + } +} \ No newline at end of file diff --git a/yana/Utils/Extensions/String+HashTest.swift b/yana/Utils/Extensions/String+HashTest.swift new file mode 100644 index 0000000..b1721cc --- /dev/null +++ b/yana/Utils/Extensions/String+HashTest.swift @@ -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()) + } + + */ \ No newline at end of file diff --git a/yana/Utils/Extensions/String+MD5.swift b/yana/Utils/Extensions/String+MD5.swift new file mode 100644 index 0000000..ef01bc6 --- /dev/null +++ b/yana/Utils/Extensions/String+MD5.swift @@ -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() + } + } +} \ No newline at end of file diff --git a/yana/Utils/Extensions/View+Placeholder.swift b/yana/Utils/Extensions/View+Placeholder.swift new file mode 100644 index 0000000..32bd109 --- /dev/null +++ b/yana/Utils/Extensions/View+Placeholder.swift @@ -0,0 +1,21 @@ +import SwiftUI + +// MARK: - View Extension for Placeholder +extension View { + /// 为TextField和SecureField添加占位符功能 + /// - Parameters: + /// - shouldShow: 是否显示占位符 + /// - alignment: 占位符对齐方式 + /// - placeholder: 占位符视图构建器 + /// - Returns: 带有占位符的视图 + func placeholder( + when shouldShow: Bool, + alignment: Alignment = .leading, + @ViewBuilder placeholder: () -> Content) -> some View { + + ZStack(alignment: alignment) { + placeholder().opacity(shouldShow ? 1 : 0) + self + } + } +} \ No newline at end of file diff --git a/yana/Utils/FontManager.swift b/yana/Utils/FontManager.swift new file mode 100644 index 0000000..c290ea7 --- /dev/null +++ b/yana/Utils/FontManager.swift @@ -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)) + } + } +} diff --git a/yana/Utils/LocalizationManager.swift b/yana/Utils/LocalizationManager.swift new file mode 100644 index 0000000..c194a77 --- /dev/null +++ b/yana/Utils/LocalizationManager.swift @@ -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) + } +} \ No newline at end of file diff --git a/yana/Utils/ScreenAdapter.swift b/yana/Utils/ScreenAdapter.swift new file mode 100644 index 0000000..2399106 --- /dev/null +++ b/yana/Utils/ScreenAdapter.swift @@ -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)) + } + } +} \ No newline at end of file diff --git a/yana/Utils/ScreenAdapterExample.swift b/yana/Utils/ScreenAdapterExample.swift new file mode 100644 index 0000000..3b67b8b --- /dev/null +++ b/yana/Utils/ScreenAdapterExample.swift @@ -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() +} \ No newline at end of file diff --git a/yana/Utils/Security/AESUtils.h b/yana/Utils/Security/AESUtils.h new file mode 100644 index 0000000..1bf73dc --- /dev/null +++ b/yana/Utils/Security/AESUtils.h @@ -0,0 +1,19 @@ +// +// AESUtils.h +// YUMI +// +// Created by YUMI on 2023/2/13. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface AESUtils : NSObject +//MARK: AES加解密 ++ (NSString *)aesEncrypt:(NSString *)sourceStr; + ++ (NSString *)aesDecrypt:(NSString *)secretStr; +@end + +NS_ASSUME_NONNULL_END diff --git a/yana/Utils/Security/AESUtils.m b/yana/Utils/Security/AESUtils.m new file mode 100644 index 0000000..af7ba8b --- /dev/null +++ b/yana/Utils/Security/AESUtils.m @@ -0,0 +1,151 @@ +// +// AESUtils.m +// YUMI +// +// Created by YUMI on 2023/2/13. +// + +#import "AESUtils.h" +#import + +#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 diff --git a/yana/Utils/Security/Base64.h b/yana/Utils/Security/Base64.h new file mode 100644 index 0000000..f0c90df --- /dev/null +++ b/yana/Utils/Security/Base64.h @@ -0,0 +1,16 @@ +// +// Base64.h +// YMhatFramework +// +// Created by chenran on 2017/5/4. +// Copyright © 2017年 chenran. All rights reserved. +// + +#import + +@interface Base64 : NSObject + ++(NSString *)encode:(NSData *)data; ++(NSData *)decode:(NSString *)dataString; + +@end diff --git a/yana/Utils/Security/Base64.m b/yana/Utils/Security/Base64.m new file mode 100644 index 0000000..1f74c46 --- /dev/null +++ b/yana/Utils/Security/Base64.m @@ -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 diff --git a/yana/Utils/Security/DESEncrypt.h b/yana/Utils/Security/DESEncrypt.h new file mode 100644 index 0000000..9056a7e --- /dev/null +++ b/yana/Utils/Security/DESEncrypt.h @@ -0,0 +1,16 @@ +// +// DESEncrypt.h +// YMhatFramework +// +// Created by chenran on 2017/5/4. +// Copyright © 2017年 chenran. All rights reserved. +// + +#import + +@interface DESEncrypt : NSObject +//加密方法 ++(NSString *) encryptUseDES:(NSString *)plainText key:(NSString *)key; +//解密方法 ++(NSString *) decryptUseDES:(NSString *)cipherText key:(NSString *)key; +@end diff --git a/yana/Utils/Security/DESEncrypt.m b/yana/Utils/Security/DESEncrypt.m new file mode 100644 index 0000000..18a3a3b --- /dev/null +++ b/yana/Utils/Security/DESEncrypt.m @@ -0,0 +1,63 @@ +// +// DESEncrypt.m +// YMhatFramework +// +// Created by chenran on 2017/5/4. +// Copyright © 2017年 chenran. All rights reserved. +// + +#import "DESEncrypt.h" +#import +#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 + diff --git a/yana/Utils/Security/DESEncryptOCTest.swift b/yana/Utils/Security/DESEncryptOCTest.swift new file mode 100644 index 0000000..8d27e99 --- /dev/null +++ b/yana/Utils/Security/DESEncryptOCTest.swift @@ -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 \ No newline at end of file diff --git a/yana/Views/AppRootView.swift b/yana/Views/AppRootView.swift new file mode 100644 index 0000000..7b06459 --- /dev/null +++ b/yana/Views/AppRootView.swift @@ -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() +} \ No newline at end of file diff --git a/yana/Views/Components/LoginButton.swift b/yana/Views/Components/LoginButton.swift new file mode 100644 index 0000000..4698a89 --- /dev/null +++ b/yana/Views/Components/LoginButton.swift @@ -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)) +} \ No newline at end of file diff --git a/yana/Views/Components/UserAgreementView.swift b/yana/Views/Components/UserAgreementView.swift new file mode 100644 index 0000000..ddea471 --- /dev/null +++ b/yana/Views/Components/UserAgreementView.swift @@ -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)) +} diff --git a/yana/Views/Components/WebView.swift b/yana/Views/Components/WebView.swift new file mode 100644 index 0000000..823601d --- /dev/null +++ b/yana/Views/Components/WebView.swift @@ -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, 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") + ) +} \ No newline at end of file diff --git a/yana/Views/EMailLoginView.swift b/yana/Views/EMailLoginView.swift new file mode 100644 index 0000000..aba714d --- /dev/null +++ b/yana/Views/EMailLoginView.swift @@ -0,0 +1,214 @@ +import SwiftUI +import ComposableArchitecture + +struct EMailLoginView: View { + let store: StoreOf + 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: {} + ) +} \ No newline at end of file diff --git a/yana/Views/IDLoginView.swift b/yana/Views/IDLoginView.swift new file mode 100644 index 0000000..1c45671 --- /dev/null +++ b/yana/Views/IDLoginView.swift @@ -0,0 +1,205 @@ +import SwiftUI +import ComposableArchitecture + +struct IDLoginView: View { + let store: StoreOf + 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: {} + ) +} diff --git a/yana/Views/LanguageSettingsView.swift b/yana/Views/LanguageSettingsView.swift new file mode 100644 index 0000000..0afad22 --- /dev/null +++ b/yana/Views/LanguageSettingsView.swift @@ -0,0 +1,96 @@ +import SwiftUI +import ComposableArchitecture + +struct LanguageSettingsView: View { + @ObservedObject private var localizationManager = LocalizationManager.shared + @Binding var isPresented: Bool + + init(isPresented: Binding = .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)) +} \ No newline at end of file diff --git a/yana/Views/LoginView.swift b/yana/Views/LoginView.swift new file mode 100644 index 0000000..8ff3f88 --- /dev/null +++ b/yana/Views/LoginView.swift @@ -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 + @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() + } + ) +} diff --git a/yana/Views/SplashView.swift b/yana/Views/SplashView.swift new file mode 100644 index 0000000..8f62245 --- /dev/null +++ b/yana/Views/SplashView.swift @@ -0,0 +1,49 @@ +import SwiftUI +import ComposableArchitecture + +struct SplashView: View { + let store: StoreOf + + 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() + } + ) +} \ No newline at end of file diff --git a/yana/yana-Bridging-Header.h b/yana/yana-Bridging-Header.h index 1b2cb5d..e01a367 100644 --- a/yana/yana-Bridging-Header.h +++ b/yana/yana-Bridging-Header.h @@ -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" + diff --git a/yana/yana.entitlements b/yana/yana.entitlements index c9a86ce..0c67376 100644 --- a/yana/yana.entitlements +++ b/yana/yana.entitlements @@ -1,8 +1,5 @@ - - com.apple.external-accessory.wireless-configuration - - + diff --git a/yana/yanaApp.swift b/yana/yanaApp.swift index 48045f2..a38d1c4 100644 --- a/yana/yanaApp.swift +++ b/yana/yanaApp.swift @@ -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() } } } diff --git a/yanaAPITests/yanaAPITests.swift b/yanaAPITests/yanaAPITests.swift index 41eaba1..0394312 100644 --- a/yanaAPITests/yanaAPITests.swift +++ b/yanaAPITests/yanaAPITests.swift @@ -33,36 +33,44 @@ final class yanaAPITests: XCTestCase { } } - func testClientInit_Success() { - let expectation = self.expectation(description: "clientInit success") - API.clientInit { result in - switch result { - case .success(let data): - XCTAssertNotNil(data) - // 可根据实际返回内容进一步断言 - case .failure(let error): - XCTFail("Expected success, got error: \(error)") - } - expectation.fulfill() - } - waitForExpectations(timeout: 5, handler: nil) + func testIDLoginRequest_Creation() { + // 测试ID登录请求的创建 + let userID = "399113" + let password = "a123456" + + let request = LoginHelper.createIDLoginRequest(userID: userID, password: password) + XCTAssertNotNil(request, "登录请求应该创建成功") + XCTAssertEqual(request?.endpoint, "/oauth/token", "端点应该正确") + XCTAssertEqual(request?.method, .POST, "请求方法应该是POST") } - - func testClientInit_Failure() { - // 可通过mock或断网等方式测试失败场景 - // 这里只做结构示例 - let expectation = self.expectation(description: "clientInit failure") - // 假设API支持注入baseURL或mock - API.clientInit { result in - switch result { - case .success(_): - // 若期望失败则此处应fail - XCTFail("Expected failure, got success") - case .failure(let error): - XCTAssertNotNil(error) - } - expectation.fulfill() - } - waitForExpectations(timeout: 5, handler: nil) + + func testIDLoginResponse_Success() { + // 测试登录响应的成功解析 + let successResponse = IDLoginResponse( + status: "success", + message: "登录成功", + code: 200, + data: IDLoginData( + accessToken: "test_token", + refreshToken: "refresh_token", + tokenType: "Bearer", + expiresIn: 3600, + scope: "read write", + userInfo: UserInfo( + userId: "123", + username: "testuser", + nickname: "Test User", + avatar: nil, + email: "test@example.com", + phone: "399113", + status: "active", + createTime: "2024-01-01", + updateTime: "2024-01-01" + ) + ) + ) + + XCTAssertTrue(successResponse.isSuccess, "响应应该标记为成功") + XCTAssertEqual(successResponse.data?.accessToken, "test_token", "访问令牌应该正确") } }