3 Commits

Author SHA1 Message Date
edwinQQQ
6084ade9ea 补充重置密码功能 2025-07-10 14:30:52 +08:00
edwinQQQ
e45ad3bad5 feat: 增强邮箱登录功能和密码恢复流程
- 更新邮箱登录相关功能,新增邮箱验证码获取和登录API端点。
- 添加AccountModel以管理用户认证信息,支持会话票据的存储和更新。
- 实现密码恢复功能,支持通过邮箱获取验证码和重置密码。
- 增加本地化支持,更新相关字符串以适应新功能。
- 引入ValidationHelper以验证邮箱和密码格式,确保用户输入的有效性。
- 更新视图以支持邮箱登录和密码恢复的用户交互。
2025-07-10 14:00:58 +08:00
edwinQQQ
c470dba79c feat: 更新项目配置和功能模块
- 修改Package.swift以支持iOS 15和macOS 12。
- 更新swift-tca-architecture-guidelines.mdc中的alwaysApply设置为false。
- 注释掉AppDelegate中的NIMSDK导入,移除不再使用的NIMConfigurationManager和NIMSessionManager文件。
- 添加新的API相关文件,包括EMailLoginFeature、IDLoginFeature和相关视图,增强登录功能。
- 更新APIConstants和APIEndpoints以反映新的API路径。
- 添加本地化支持文件,包含英文和中文简体的本地化字符串。
- 新增字体管理和安全工具类,支持AES和DES加密。
- 更新Xcode项目配置,调整版本号和启动画面设置。
2025-07-09 16:14:19 +08:00
80 changed files with 5904 additions and 523 deletions

View File

@@ -6,7 +6,7 @@ alwaysApply: true
# CONTEXT
I am a native Chinese speaker who has just begun learning Swift 6 and Xcode 16, and I am enthusiastic about exploring new technologies. I wish to receive advice using the latest tools and
I am a native Chinese speaker who has just begun learning Swift 6 and Xcode 15+, and I am enthusiastic about exploring new technologies. I wish to receive advice using the latest tools and
seek step-by-step guidance to fully understand the implementation process. Since many excellent code resources are in English, I hope my questions can be thoroughly understood. Therefore,
I would like the AI assistant to think and reason in English, then translate the English responses into Chinese for me.
@@ -42,7 +42,7 @@ alwaysApply: true
# AUDIENCE
The target audience is me—a native Chinese developer eager to learn Swift 6 and Xcode 16, seeking guidance and advice on utilizing the latest technologies.
The target audience is me—a native Chinese developer eager to learn Swift 6 and Xcode 15+, seeking guidance and advice on utilizing the latest technologies.
---

View File

@@ -7,6 +7,7 @@ alwaysApply: true
# Architechture
- Use TCA(The Composable Architecture) architecture with SwiftUI & Swift
- Don't use TCA for UI Navigation
# Code Structure
- Use Swift's latest features and protocol-oriented programming

View File

@@ -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.

View File

@@ -5,8 +5,8 @@ import PackageDescription
let package = Package(
name: "yana",
platforms: [
.iOS(.v17),
.macOS(.v14)
.iOS(.v15),
.macOS(.v12)
],
products: [
.library(

View File

@@ -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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -47,8 +47,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = yanaAPITests;
sourceTree = "<group>";
};
@@ -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 = "<group>";
};
4C4C8FE72DE6F05300384527 /* tools */ = {
isa = PBXGroup;
children = (
);
path = tools;
sourceTree = "<group>";
};
556C2003CCDA5AC2C56882D0 /* Frameworks */ = {
isa = PBXGroup;
children = (
4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */,
4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */,
D8529F57AF9337F626C670ED /* Pods_yana.framework */,
E9DBA38268C435BE06F39AE2 /* Pods_yana.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -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 = "<group>";
@@ -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)";

View File

@@ -164,5 +164,69 @@
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "4019681E-F608-434E-96C2-9DE87CC71147"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Configs/AppConfig.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "16"
endingLineNumber = "16"
landmarkName = "baseURL"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "CF5E29EE-0D89-4141-9696-9587D243115B"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Features/IDLoginFeature.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "104"
endingLineNumber = "104"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "057A0951-B4B1-4417-85B8-1D1C3962D30A"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Features/IDLoginFeature.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "161"
endingLineNumber = "161"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "F36191A2-34B7-4321-80B7-1A80A7479E32"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Features/LoginFeature.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "154"
endingLineNumber = "154"
landmarkName = "body"
landmarkType = "24">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>

View File

@@ -15,7 +15,7 @@
| 环境 | 地址 | 说明 |
|------|------|------|
| 生产环境 | `https://api.hfighting.com` | 正式服务器 |
| 生产环境 | `https://api.epartylive.com` | 正式服务器 |
| 测试环境 | `http://beta.api.molistar.xyz` | 开发测试服务器 |
| 图片服务 | `https://image.hfighting.com` | 静态资源服务器 |
@@ -177,4 +177,4 @@ YuMi iOS 项目的 API 架构设计了完整的网络请求体系,包含:
- 🛠️ **开发支持**: 环境切换、错误追踪、调试日志
- 🏗️ **架构清晰**: 模块化设计、统一管理、易于维护
这种设计确保了 API 请求的安全性、稳定性和高性能,为应用提供了可靠的网络服务基础。
这种设计确保了 API 请求的安全性、稳定性和高性能,为应用提供了可靠的网络服务基础。

View File

@@ -3,17 +3,13 @@ import Foundation
/// API
///
/// API
/// -
/// -
/// - API
/// -
///
/// APIConfiguration
/// baseURLAppConfig
/// 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

View File

@@ -16,8 +16,12 @@ 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"
case emailGetCode = "/email/getCode" //
// Web
case userAgreement = "/modules/rule/protocol.html"
case privacyPolicy = "/modules/rule/privacy-wap.html"
var path: String {
return self.rawValue
@@ -39,10 +43,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 +90,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

View File

@@ -35,6 +35,10 @@ enum APIError: Error, Equatable {
case httpError(statusCode: Int, message: String?)
case timeout
case resourceTooLarge
case encryptionFailed //
case invalidResponse //
case ticketFailed //
case custom(String) //
case unknown(String)
var localizedDescription: String {
@@ -53,6 +57,14 @@ enum APIError: Error, Equatable {
return "请求超时"
case .resourceTooLarge:
return "响应数据过大"
case .encryptionFailed:
return "数据加密失败"
case .invalidResponse:
return "服务器响应无效"
case .ticketFailed:
return "获取会话票据失败"
case .custom(let message):
return message
case .unknown(let message):
return "未知错误: \(message)"
}
@@ -118,7 +130,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 +143,7 @@ struct BaseRequest: Codable {
//
#if DEBUG
self.channel = "TestFlight"
self.channel = "molistar_enterprise"
#else
self.channel = "appstore"
#endif
@@ -186,9 +198,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 +218,7 @@ struct NetworkTypeDetector {
static func getCurrentNetworkType() -> Int {
// WiFi = 2, = 1
//
return 1 //
return 2 //
}
}
@@ -224,16 +237,208 @@ 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"
static let accountModel = "account_model" // AccountModel
}
// 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)
clearAccountModel() // AccountModel
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
}
// MARK: - Account Model Management
/// AccountModel
/// - Parameter accountModel:
static func saveAccountModel(_ accountModel: AccountModel) {
do {
let data = try JSONEncoder().encode(accountModel)
userDefaults.set(data, forKey: StorageKeys.accountModel)
userDefaults.synchronize()
//
if let uid = accountModel.uid {
saveUserId(uid)
}
if let accessToken = accountModel.accessToken {
saveAccessToken(accessToken)
}
if let ticket = accountModel.ticket {
saveTicket(ticket)
}
print("💾 AccountModel 保存成功")
} catch {
print("❌ AccountModel 保存失败: \(error)")
}
}
/// AccountModel
/// - Returns: nil
static func getAccountModel() -> AccountModel? {
guard let data = userDefaults.data(forKey: StorageKeys.accountModel) else {
return nil
}
do {
return try JSONDecoder().decode(AccountModel.self, from: data)
} catch {
print("❌ AccountModel 解析失败: \(error)")
return nil
}
}
/// AccountModel ticket
/// - Parameter ticket:
static func updateAccountModelTicket(_ ticket: String) {
guard var accountModel = getAccountModel() else {
print("❌ 无法更新 ticketAccountModel 不存在")
return
}
accountModel.ticket = ticket
saveAccountModel(accountModel)
saveTicket(ticket) // ticket
}
/// AccountModel
/// - Returns:
static func hasValidAccountModel() -> Bool {
guard let accountModel = getAccountModel() else {
return false
}
return accountModel.hasValidAuthentication
}
/// AccountModel
static func clearAccountModel() {
userDefaults.removeObject(forKey: StorageKeys.accountModel)
userDefaults.synchronize()
print("🗑️ AccountModel 已清除")
}
}
@@ -267,6 +472,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 +481,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 +492,5 @@ struct APIResponse<T: Codable>: Codable {
let code: Int?
}
// MARK: - String MD5 Extension
extension String {
func md5() -> String {
let data = Data(self.utf8)
let hash = data.withUnsafeBytes { bytes -> [UInt8] in
var hash = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
CC_MD5(bytes.baseAddress, CC_LONG(data.count), &hash)
return hash
}
return hash.map { String(format: "%02x", $0) }.joined()
}
}
// CommonCrypto
import CommonCrypto
// String+MD5 Utils/Extensions/String+MD5.swift

View File

@@ -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)
}

423
yana/APIs/LoginModels.swift Normal file
View File

@@ -0,0 +1,423 @@
import Foundation
// MARK: - Account Model
///
/// oauth/token oauth/ticket
/// OC AccountModel
struct AccountModel: Codable, Equatable {
let uid: String? //
let jti: String? // JWT ID
let tokenType: String? // Token (bearer)
let refreshToken: String? //
let netEaseToken: String? //
let accessToken: String? // OAuth 访
let expiresIn: Int? //
let scope: String? //
var ticket: String? // oauth/ticket
enum CodingKeys: String, CodingKey {
case uid
case jti
case tokenType = "token_type"
case refreshToken = "refresh_token"
case netEaseToken
case accessToken = "access_token"
case expiresIn = "expires_in"
case scope
case ticket
}
///
var hasValidAuthentication: Bool {
return accessToken != nil && !accessToken!.isEmpty
}
///
var hasValidSession: Bool {
return hasValidAuthentication && ticket != nil && !ticket!.isEmpty
}
/// IDLoginData AccountModel
/// - Parameter loginData:
/// - Returns: AccountModel nil
static func from(loginData: IDLoginData) -> AccountModel? {
// accessToken uid
guard let accessToken = loginData.accessToken,
let uid = loginData.uid else {
return nil
}
return AccountModel(
uid: String(uid),
jti: loginData.jti,
tokenType: loginData.tokenType,
refreshToken: loginData.refreshToken,
netEaseToken: loginData.netEaseToken,
accessToken: accessToken,
expiresIn: loginData.expiresIn,
scope: loginData.scope,
ticket: nil // oauth/ticket
)
}
/// ticket
/// - Parameter ticket: oauth/ticket
/// - Returns: AccountModel
func withTicket(_ ticket: String) -> AccountModel {
var updatedModel = self
updatedModel.ticket = ticket
return updatedModel
}
}
// MARK: - ID Login Request Model
struct IDLoginAPIRequest: APIRequestProtocol {
typealias Response = IDLoginResponse
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: DESID/
/// - 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: APInil
static func createIDLoginRequest(userID: String, password: String) -> IDLoginAPIRequest? {
// 使DESID
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
// MARK: - Email Verification Code Models
///
struct EmailGetCodeRequest: APIRequestProtocol {
typealias Response = EmailGetCodeResponse
let endpoint = APIEndpoint.emailGetCode.path
let method: HTTPMethod = .POST
let includeBaseParameters = true
let queryParameters: [String: String]?
let bodyParameters: [String: Any]? = nil
let timeout: TimeInterval = 30.0
///
/// - Parameters:
/// - emailAddress: DES
/// - type: 1=/
init(emailAddress: String, type: Int = 1) {
self.queryParameters = [
"emailAddress": emailAddress,
"type": String(type)
]
}
}
///
struct EmailGetCodeResponse: Codable, Equatable {
let status: String?
let message: String?
let code: Int?
let data: String? //
///
var isSuccess: Bool {
return code == 200 || status?.lowercased() == "success"
}
///
var errorMessage: String {
return message ?? "验证码发送失败,请重试"
}
}
///
struct EmailLoginRequest: APIRequestProtocol {
typealias Response = IDLoginResponse // ID
let endpoint = APIEndpoint.login.path
let method: HTTPMethod = .POST
let includeBaseParameters = true
let queryParameters: [String: String]?
let bodyParameters: [String: Any]? = nil
let timeout: TimeInterval = 30.0
///
/// - Parameters:
/// - email: DES
/// - code:
/// - clientSecret: "uyzjdhds"
/// - version: "1"
/// - clientId: ID"erban-client"
/// - grantType: "email"
init(email: String, code: String, clientSecret: String = "uyzjdhds", version: String = "1", clientId: String = "erban-client", grantType: String = "email") {
self.queryParameters = [
"email": email,
"code": code,
"client_secret": clientSecret,
"version": version,
"client_id": clientId,
"grant_type": grantType
]
}
}
// MARK: - Email Login Helper
extension LoginHelper {
///
/// - Parameter email:
/// - Returns: APInil
static func createEmailGetCodeRequest(email: String) -> EmailGetCodeRequest? {
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
print("❌ 邮箱DES加密失败")
return nil
}
print("🔐 邮箱DES加密成功")
print(" 原始邮箱: \(email)")
print(" 加密邮箱: \(encryptedEmail)")
return EmailGetCodeRequest(emailAddress: email, type: 1)
}
///
/// - Parameters:
/// - email:
/// - code:
/// - Returns: APInil
static func createEmailLoginRequest(email: String, code: String) -> EmailLoginRequest? {
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
print("❌ 邮箱DES加密失败")
return nil
}
print("🔐 邮箱验证码登录DES加密成功")
print(" 原始邮箱: \(email)")
print(" 加密邮箱: \(encryptedEmail)")
print(" 验证码: \(code)")
return EmailLoginRequest(email: encryptedEmail, code: code)
}
}

View File

@@ -0,0 +1,434 @@
# 邮箱验证码登录流程文档
## 概述
本文档详细描述了 YuMi iOS 应用中 `LoginTypesViewController``LoginDisplayType_email` 模式下的邮箱验证码登录流程。该流程实现了基于邮箱和验证码的用户认证机制。
## 系统架构
### 核心组件
- **LoginTypesViewController**: 登录类型控制器,负责 UI 展示和用户交互
- **LoginPresenter**: 登录业务逻辑处理器,负责与 API 交互
- **LoginInputItemView**: 输入组件,提供邮箱和验证码输入界面
- **Api+Login**: 登录相关 API 接口封装
- **AccountInfoStorage**: 账户信息本地存储管理
### 数据模型
#### LoginDisplayType 枚举
```objc
typedef NS_ENUM(NSUInteger, LoginDisplayType) {
LoginDisplayType_id, // ID 登录
LoginDisplayType_email, // 邮箱登录 ✓
LoginDisplayType_phoneNum, // 手机号登录
LoginDisplayType_email_forgetPassword, // 邮箱忘记密码
LoginDisplayType_phoneNum_forgetPassword, // 手机号忘记密码
};
```
#### LoginInputType 枚举
```objc
typedef NS_ENUM(NSUInteger, LoginInputType) {
LoginInputType_email, // 邮箱输入
LoginInputType_verificationCode, // 验证码输入
LoginInputType_login, // 登录按钮
// ... 其他类型
};
```
#### GetSmsType 验证码类型
```objc
typedef NS_ENUM(NSUInteger, GetSmsType) {
GetSmsType_Regist = 1, // 注册(邮箱登录使用此类型)
GetSmsType_Login = 2, // 登录
GetSmsType_Reset_Password = 3, // 重设密码
// ... 其他类型
};
```
## 登录流程详解
### 1. 界面初始化流程
#### 1.1 控制器初始化
```objc
// 在 LoginViewController 中点击邮箱登录按钮
- (void)didTapEntrcyButton:(UIButton *)sender {
if (sender.tag == LoginType_Email) {
LoginTypesViewController *vc = [[LoginTypesViewController alloc] init];
[self.navigationController pushViewController:vc animated:YES];
[vc updateLoginType:LoginDisplayType_email]; // 设置为邮箱登录模式
}
}
```
#### 1.2 输入区域设置
```objc
- (void)setupEmailInputArea {
[self setupInpuArea:LoginInputType_email // 第一行:邮箱输入
second:LoginInputType_verificationCode // 第二行:验证码输入
third:LoginInputType_none // 第三行:无
action:LoginInputType_login // 操作按钮:登录
showForgetPassword:NO]; // 不显示忘记密码
}
```
#### 1.3 UI 组件配置
- **第一行输入框**: 邮箱地址输入
- 占位符: "请输入邮箱地址"
- 键盘类型: `UIKeyboardTypeEmailAddress`
- 回调: `handleFirstInputContentUpdate`
- **第二行输入框**: 验证码输入
- 占位符: "请输入验证码"
- 键盘类型: `UIKeyboardTypeDefault`
- 附带"获取验证码"按钮
- 回调: `handleSecondInputContentUpdate`
### 2. 验证码获取流程
#### 2.1 用户交互触发
```objc
// 用户点击"获取验证码"按钮
[self.secondLineInputView setHandleItemAction:^(LoginInputType inputType) {
if (inputType == LoginInputType_verificationCode) {
if (self.type == LoginDisplayType_email) {
[self handleTapGetMailVerificationCode];
}
}
}];
```
#### 2.2 邮箱验证码获取处理
```objc
- (void)handleTapGetMailVerificationCode {
NSString *email = [self.firstLineInputView inputContent];
// 邮箱地址验证
if (email.length == 0) {
[self.secondLineInputView endVerificationCountDown];
return;
}
// 调用 Presenter 发送验证码
[self.presenter sendMailVerificationCode:email type:GetSmsType_Regist];
}
```
#### 2.3 Presenter 层处理
```objc
- (void)sendMailVerificationCode:(NSString *)emailAddress type:(NSInteger)type {
// DES 加密邮箱地址
NSString *desEmail = [DESEncrypt encryptUseDES:emailAddress
key:KeyWithType(KeyType_PasswordEncode)];
@kWeakify(self);
[Api emailGetCode:[self createHttpCompletion:^(BaseModel *data) {
@kStrongify(self);
if ([[self getView] respondsToSelector:@selector(emailCodeSucess:type:)]) {
[[self getView] emailCodeSucess:@"" type:type];
}
} fail:^(NSInteger code, NSString *msg) {
@kStrongify(self);
if ([[self getView] respondsToSelector:@selector(emailCodeFailure)]) {
[[self getView] emailCodeFailure];
}
} showLoading:YES errorToast:YES]
emailAddress:desEmail
type:@(type)];
}
```
#### 2.4 API 接口调用
```objc
+ (void)emailGetCode:(HttpRequestHelperCompletion)completion
emailAddress:(NSString *)emailAddress
type:(NSNumber *)type {
[self makeRequest:@"email/getCode"
method:HttpRequestHelperMethodPOST
completion:completion, __FUNCTION__, emailAddress, type, nil];
}
```
**API 详情**:
- **接口路径**: `POST /email/getCode`
- **请求参数**:
- `emailAddress`: 邮箱地址DES 加密)
- `type`: 验证码类型1=注册)
#### 2.5 获取验证码成功处理
```objc
- (void)emailCodeSucess:(NSString *)message type:(GetSmsType)type {
[self showSuccessToast:YMLocalizedString(@"XPLoginPhoneViewController2")]; // "验证码已发送"
[self.secondLineInputView startVerificationCountDown]; // 开始倒计时
[self.secondLineInputView displayKeyboard]; // 显示键盘
}
```
#### 2.6 获取验证码失败处理
```objc
- (void)emailCodeFailure {
[self.secondLineInputView endVerificationCountDown]; // 结束倒计时
}
```
### 3. 邮箱登录流程
#### 3.1 登录按钮状态检查
```objc
- (void)checkActionButtonStatus {
switch (self.type) {
case LoginDisplayType_email: {
NSString *accountString = [self.firstLineInputView inputContent]; // 邮箱
NSString *codeString = [self.secondLineInputView inputContent]; // 验证码
// 只有当邮箱和验证码都不为空时才启用登录按钮
if (![NSString isEmpty:accountString] && ![NSString isEmpty:codeString]) {
self.bottomActionButton.enabled = YES;
} else {
self.bottomActionButton.enabled = NO;
}
}
break;
}
}
```
#### 3.2 登录按钮点击处理
```objc
- (void)didTapActionButton {
[self.view endEditing:true];
switch (self.type) {
case LoginDisplayType_email: {
// 调用 Presenter 进行邮箱登录
[self.presenter loginWithEmail:[self.firstLineInputView inputContent]
code:[self.secondLineInputView inputContent]];
}
break;
}
}
```
#### 3.3 Presenter 层登录处理
```objc
- (void)loginWithEmail:(NSString *)email code:(NSString *)code {
// DES 加密邮箱地址
NSString *desMail = [DESEncrypt encryptUseDES:email
key:KeyWithType(KeyType_PasswordEncode)];
@kWeakify(self);
[Api loginWithCode:[self createHttpCompletion:^(BaseModel *data) {
@kStrongify(self);
// 解析账户模型
AccountModel *accountModel = [AccountModel modelWithDictionary:data.data];
// 保存账户信息
if (accountModel && accountModel.access_token.length > 0) {
[[AccountInfoStorage instance] saveAccountInfo:accountModel];
}
// 通知登录成功
if ([[self getView] respondsToSelector:@selector(loginSuccess)]) {
[[self getView] loginSuccess];
}
} fail:^(NSInteger code, NSString *msg) {
@kStrongify(self);
[[self getView] loginFailWithMsg:msg];
} errorToast:NO]
email:desMail
code:code
client_secret:clinet_s // 客户端密钥
version:@"1"
client_id:@"erban-client"
grant_type:@"email"]; // 邮箱登录类型
}
```
#### 3.4 API 接口调用
```objc
+ (void)loginWithCode:(HttpRequestHelperCompletion)completion
email:(NSString *)email
code:(NSString *)code
client_secret:(NSString *)client_secret
version:(NSString *)version
client_id:(NSString *)client_id
grant_type:(NSString *)grant_type {
NSString *fang = [NSString stringFromBase64String:@"b2F1dGgvdG9rZW4="]; // oauth/token
[self makeRequest:fang
method:HttpRequestHelperMethodPOST
completion:completion, __FUNCTION__, email, code, client_secret,
version, client_id, grant_type, nil];
}
```
**API 详情**:
- **接口路径**: `POST /oauth/token`
- **请求参数**:
- `email`: 邮箱地址DES 加密)
- `code`: 验证码
- `client_secret`: 客户端密钥
- `version`: 版本号 "1"
- `client_id`: 客户端ID "erban-client"
- `grant_type`: 授权类型 "email"
#### 3.5 登录成功处理
```objc
- (void)loginSuccess {
[self showSuccessToast:YMLocalizedString(@"XPLoginPhoneViewController1")]; // "登录成功"
[PILoginManager loginWithVC:self isLoginPhone:NO]; // 执行登录后续处理
}
```
#### 3.6 登录失败处理
```objc
- (void)loginFailWithMsg:(NSString *)msg {
[self showSuccessToast:msg]; // 显示错误信息
}
```
## 数据流时序图
```mermaid
sequenceDiagram
participant User as 用户
participant VC as LoginTypesViewController
participant IV as LoginInputItemView
participant P as LoginPresenter
participant API as Api+Login
participant Storage as AccountInfoStorage
Note over User,Storage: 1. 初始化邮箱登录界面
User->>VC: 选择邮箱登录
VC->>VC: updateLoginType(LoginDisplayType_email)
VC->>VC: setupEmailInputArea()
VC->>IV: 创建邮箱输入框
VC->>IV: 创建验证码输入框
Note over User,Storage: 2. 获取邮箱验证码
User->>IV: 输入邮箱地址
User->>IV: 点击"获取验证码"
IV->>VC: handleTapGetMailVerificationCode
VC->>VC: 验证邮箱地址非空
VC->>P: sendMailVerificationCode(email, GetSmsType_Regist)
P->>P: DES加密邮箱地址
P->>API: emailGetCode(encryptedEmail, type=1)
API-->>P: 验证码发送结果
P-->>VC: emailCodeSucess / emailCodeFailure
VC->>IV: startVerificationCountDown / endVerificationCountDown
VC->>User: 显示成功/失败提示
Note over User,Storage: 3. 邮箱验证码登录
User->>IV: 输入验证码
IV->>VC: 输入内容变化回调
VC->>VC: checkActionButtonStatus()
VC->>User: 启用/禁用登录按钮
User->>VC: 点击登录按钮
VC->>VC: didTapActionButton()
VC->>P: loginWithEmail(email, code)
P->>P: DES加密邮箱地址
P->>API: loginWithCode(email, code, ...)
API-->>P: OAuth Token 响应
P->>P: 解析 AccountModel
P->>Storage: saveAccountInfo(accountModel)
P-->>VC: loginSuccess / loginFailWithMsg
VC->>User: 显示登录结果
VC->>User: 跳转到主界面
```
## 安全机制
### 1. 数据加密
- **邮箱地址加密**: 使用 DES 算法加密邮箱地址后传输
```objc
NSString *desEmail = [DESEncrypt encryptUseDES:email key:KeyWithType(KeyType_PasswordEncode)];
```
### 2. 输入验证
- **邮箱格式验证**: 通过 `UIKeyboardTypeEmailAddress` 键盘类型引导正确输入
- **非空验证**: 邮箱和验证码都必须非空才能执行登录
### 3. 验证码安全
- **时效性**: 验证码具有倒计时机制,防止重复获取
- **类型标识**: 使用 `GetSmsType_Regist = 1` 标识登录验证码
### 4. 网络安全
- **错误处理**: 完整的成功/失败回调机制
- **加载状态**: `showLoading:YES` 防止重复请求
- **错误提示**: `errorToast:YES` 显示网络错误
## 错误处理机制
### 1. 邮箱验证码获取错误
```objc
- (void)emailCodeFailure {
[self.secondLineInputView endVerificationCountDown]; // 停止倒计时
// 用户可以重新获取验证码
}
```
### 2. 登录失败处理
```objc
- (void)loginFailWithMsg:(NSString *)msg {
[self showSuccessToast:msg]; // 显示具体错误信息
// 用户可以重新尝试登录
}
```
### 3. 网络请求错误
- **自动重试**: 用户可以手动重新点击获取验证码或登录
- **错误提示**: 通过 Toast 显示具体错误信息
- **状态恢复**: 失败后恢复按钮可点击状态
## 本地化支持
### 关键文本资源
- `@"20.20.51_text_1"`: "邮箱登录"
- `@"20.20.51_text_4"`: "请输入邮箱地址"
- `@"20.20.51_text_7"`: "请输入验证码"
- `@"XPLoginPhoneViewController2"`: "验证码已发送"
- `@"XPLoginPhoneViewController1"`: "登录成功"
### 多语言支持
- 简体中文 (`zh-Hant.lproj`)
- 英文 (`en.lproj`)
- 阿拉伯语 (`ar.lproj`)
- 土耳其语 (`tr.lproj`)
## 依赖组件
### 外部框架
- **MASConstraintMaker**: 自动布局
- **ReactiveObjC**: 响应式编程(部分组件使用)
### 内部组件
- **YMLocalizedString**: 本地化字符串管理
- **DESEncrypt**: DES 加密工具
- **AccountInfoStorage**: 账户信息存储
- **HttpRequestHelper**: 网络请求管理
## 扩展和维护
### 新增功能建议
1. **邮箱格式验证**: 添加正则表达式验证邮箱格式
2. **验证码长度限制**: 限制验证码输入长度
3. **自动填充**: 支持系统邮箱自动填充
4. **记住邮箱**: 保存最近使用的邮箱地址
### 性能优化
1. **请求去重**: 防止短时间内重复请求验证码
2. **缓存机制**: 缓存验证码倒计时状态
3. **网络优化**: 添加请求超时和重试机制
### 代码维护
1. **常量管理**: 将硬编码字符串提取为常量
2. **错误码统一**: 统一管理API错误码
3. **日志记录**: 添加详细的操作日志
## 总结
邮箱验证码登录流程是一个完整的用户认证系统,包含了界面展示、验证码获取、用户登录、数据存储等完整环节。该流程具有良好的安全性、用户体验和错误处理机制,符合现代移动应用的认证标准。
通过本文档,开发人员可以全面了解邮箱登录的实现细节,便于后续的功能扩展和维护工作。

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

262
yana/APIs/oauth flow.md Normal file
View File

@@ -0,0 +1,262 @@
# OAuth/Ticket 认证系统 API 文档
## 概述
本文档描述了 YuMi 应用中 OAuth 认证和 Ticket 会话管理的完整流程。系统采用两阶段认证机制:
1. **OAuth 阶段**:用户登录获取 `access_token`
2. **Ticket 阶段**:使用 `access_token` 获取业务会话 `ticket`
## 认证流程架构
### 核心组件
- **AccountInfoStorage**: 负责账户信息和 ticket 的本地存储
- **HttpRequestHelper**: 网络请求管理,自动添加认证头
- **Api+Login**: 登录相关 API 接口
- **Api+Main**: Ticket 获取相关 API 接口
### 认证数据模型
#### AccountModel
```objc
@interface AccountModel : PIBaseModel
@property (nonatomic, assign) NSString *uid; // 用户唯一标识
@property (nonatomic, copy) NSString *jti; // JWT ID
@property (nonatomic, copy) NSString *token_type; // Token 类型
@property (nonatomic, copy) NSString *refresh_token; // 刷新令牌
@property (nonatomic, copy) NSString *netEaseToken; // 网易云信令牌
@property (nonatomic, copy) NSString *access_token; // OAuth 访问令牌
@property (nonatomic, assign) NSNumber *expires_in; // 过期时间
@end
```
## API 接口详情
### 1. OAuth 登录接口
#### 1.1 手机验证码登录
```objc
+ (void)loginWithCode:(HttpRequestHelperCompletion)completion
phone:(NSString *)phone
code:(NSString *)code
client_secret:(NSString *)client_secret
version:(NSString *)version
client_id:(NSString *)client_id
grant_type:(NSString *)grant_type
phoneAreaCode:(NSString *)phoneAreaCode;
```
**接口路径**: `POST /oauth/token`
**请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| phone | String | 是 | 手机号DES加密 |
| code | String | 是 | 验证码 |
| client_secret | String | 是 | 客户端密钥,固定值:"uyzjdhds" |
| version | String | 是 | 版本号,固定值:"1" |
| client_id | String | 是 | 客户端ID固定值"erban-client" |
| grant_type | String | 是 | 授权类型,验证码登录为:"sms_code" |
| phoneAreaCode | String | 是 | 手机区号 |
**返回数据**: AccountModel 对象
#### 1.2 手机密码登录
```objc
+ (void)loginWithPassword:(HttpRequestHelperCompletion)completion
phone:(NSString *)phone
password:(NSString *)password
client_secret:(NSString *)client_secret
version:(NSString *)version
client_id:(NSString *)client_id
grant_type:(NSString *)grant_type;
```
**接口路径**: `POST /oauth/token`
**请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| phone | String | 是 | 手机号DES加密 |
| password | String | 是 | 密码DES加密 |
| client_secret | String | 是 | 客户端密钥 |
| version | String | 是 | 版本号 |
| client_id | String | 是 | 客户端ID |
| grant_type | String | 是 | 授权类型,密码登录为:"password" |
#### 1.3 第三方登录
```objc
+ (void)loginWithThirdPart:(HttpRequestHelperCompletion)completion
openid:(NSString *)openid
unionid:(NSString *)unionid
access_token:(NSString *)access_token
type:(NSString *)type;
```
**接口路径**: `POST /acc/third/login`
**请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| openid | String | 是 | 第三方平台用户唯一标识 |
| unionid | String | 是 | 第三方平台联合ID |
| access_token | String | 是 | 第三方平台访问令牌 |
| type | String | 是 | 第三方平台类型1:Apple, 2:Facebook, 3:Google等 |
### 2. Ticket 获取接口
#### 2.1 获取 Ticket
```objc
+ (void)requestTicket:(HttpRequestHelperCompletion)completion
access_token:(NSString *)accessToken
issue_type:(NSString *)issueType;
```
**接口路径**: `POST /oauth/ticket`
**请求参数**:
| 参数名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| access_token | String | 是 | OAuth 登录获取的访问令牌 |
| issue_type | String | 是 | 签发类型,固定值:"multi" |
**返回数据**:
```json
{
"code": 200,
"data": {
"tickets": [
{
"ticket": "eyJhbGciOiJIUzI1NiJ9..."
}
]
}
}
```
### 3. HTTP 请求头配置
所有业务 API 请求都会自动添加以下请求头:
```objc
// 在 HttpRequestHelper 中自动配置
- (void)setupHeader {
AFHTTPSessionManager *client = [HttpRequestHelper requestManager];
// 用户ID头
if ([[AccountInfoStorage instance] getUid].length > 0) {
[client.requestSerializer setValue:[[AccountInfoStorage instance] getUid]
forHTTPHeaderField:@"pub_uid"];
}
// Ticket 认证头
if ([[AccountInfoStorage instance] getTicket].length > 0) {
[client.requestSerializer setValue:[[AccountInfoStorage instance] getTicket]
forHTTPHeaderField:@"pub_ticket"];
}
// 其他公共头
[client.requestSerializer setValue:[NSBundle uploadLanguageText]
forHTTPHeaderField:@"Accept-Language"];
[client.requestSerializer setValue:PI_App_Version
forHTTPHeaderField:@"App-Version"];
}
```
## 使用流程
### 完整登录流程示例
```objc
// 1. 用户登录获取 access_token
[Api loginWithCode:^(BaseModel * _Nullable data, NSInteger code, NSString * _Nullable msg) {
if (code == 200) {
// 保存账户信息
AccountModel *accountModel = [AccountModel modelWithDictionary:data.data];
[[AccountInfoStorage instance] saveAccountInfo:accountModel];
// 2. 使用 access_token 获取 ticket
[Api requestTicket:^(BaseModel * _Nullable data, NSInteger code, NSString * _Nullable msg) {
if (code == 200) {
NSArray *tickets = [data.data valueForKey:@"tickets"];
NSString *ticket = [tickets[0] valueForKey:@"ticket"];
// 保存 ticket
[[AccountInfoStorage instance] saveTicket:ticket];
// 3. 登录成功,可以进行业务操作
[self navigateToMainPage];
}
} access_token:accountModel.access_token issue_type:@"multi"];
}
} phone:encryptedPhone
code:verificationCode
client_secret:@"uyzjdhds"
version:@"1"
client_id:@"erban-client"
grant_type:@"sms_code"
phoneAreaCode:areaCode];
```
### 自动登录流程
```objc
- (void)autoLogin {
// 检查本地是否有账户信息
AccountModel *accountModel = [[AccountInfoStorage instance] getCurrentAccountInfo];
if (accountModel == nil || accountModel.access_token == nil) {
[self tokenInvalid]; // 跳转到登录页
return;
}
// 检查是否有有效的 ticket
if ([[AccountInfoStorage instance] getTicket].length > 0) {
[[self getView] autoLoginSuccess];
return;
}
// 使用 access_token 重新获取 ticket
[Api requestTicket:^(BaseModel * _Nonnull data) {
NSArray *tickets = [data.data valueForKey:@"tickets"];
NSString *ticket = [tickets[0] valueForKey:@"ticket"];
[[AccountInfoStorage instance] saveTicket:ticket];
[[self getView] autoLoginSuccess];
} fail:^(NSInteger code, NSString * _Nullable msg) {
[self logout]; // ticket 获取失败,重新登录
} access_token:accountModel.access_token issue_type:@"multi"];
}
```
## 错误处理
### 401 未授权错误
当接收到 401 状态码时,系统会自动处理:
```objc
// 在 HttpRequestHelper 中
if (response && response.statusCode == 401) {
failure(response.statusCode, YMLocalizedString(@"HttpRequestHelper7"));
// 通常需要重新登录
}
```
### Ticket 过期处理
- Ticket 过期时服务器返回 401 错误
- 客户端应该使用保存的 `access_token` 重新获取 ticket
- 如果 `access_token` 也过期,则需要用户重新登录
## 安全注意事项
1. **数据加密**: 敏感信息(手机号、密码)使用 DES 加密传输
2. **本地存储**:
- `access_token` 存储在文件系统中
- `ticket` 存储在内存中,应用重启需重新获取
3. **请求头**: 所有业务请求自动携带 `pub_uid``pub_ticket`
4. **错误处理**: 建立完善的 401 错误重试机制
## 相关文件
- `YuMi/Structure/MVP/Model/AccountInfoStorage.h/m` - 账户信息存储管理
- `YuMi/Modules/YMLogin/Api/Api+Login.h/m` - 登录相关接口
- `YuMi/Modules/YMTabbar/Api/Api+Main.h/m` - Ticket 获取接口
- `YuMi/Network/HttpRequestHelper.h/m` - 网络请求管理
- `YuMi/Structure/MVP/Model/AccountModel.h/m` - 账户数据模型

1
yana/APIs/oauth flow.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -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()
// 🔍 DESOC
// print("🔐 使OCDES")
// 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()

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "bg@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "切图 65@3x (1).png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "切图 65@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "logo@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "勾选@3x (1).png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "top@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "勾选@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -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
}
}
}
}

View File

@@ -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
}
}
}
}

View File

@@ -5,7 +5,7 @@ struct ConfigView: View {
let store: StoreOf<ConfigFeature>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
NavigationView {
VStack(spacing: 20) {
//
@@ -16,7 +16,7 @@ struct ConfigView: View {
//
Group {
if viewStore.isLoading {
if store.isLoading {
VStack {
ProgressView()
.scaleEffect(1.5)
@@ -26,7 +26,7 @@ struct ConfigView: View {
.padding(.top, 8)
}
.frame(height: 100)
} else if let errorMessage = viewStore.errorMessage {
} else if let errorMessage = store.errorMessage {
VStack {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40))
@@ -43,13 +43,13 @@ struct ConfigView: View {
.padding(.horizontal)
Button("清除错误") {
viewStore.send(.clearError)
store.send(.clearError)
}
.buttonStyle(.borderedProminent)
.padding(.top)
}
.frame(maxHeight: .infinity)
} else if let configData = viewStore.configData {
} else if let configData = store.configData {
//
ScrollView {
VStack(alignment: .leading, spacing: 16) {
@@ -102,7 +102,7 @@ struct ConfigView: View {
.cornerRadius(12)
}
if let lastUpdated = viewStore.lastUpdated {
if let lastUpdated = store.lastUpdated {
Text("最后更新: \(lastUpdated, style: .time)")
.font(.caption)
.foregroundColor(.secondary)
@@ -130,21 +130,21 @@ struct ConfigView: View {
//
VStack(spacing: 12) {
Button(action: {
viewStore.send(.loadConfig)
store.send(.loadConfig)
}) {
HStack {
if viewStore.isLoading {
if store.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
} else {
Image(systemName: "arrow.clockwise")
}
Text(viewStore.isLoading ? "加载中..." : "加载配置")
Text(store.isLoading ? "加载中..." : "加载配置")
}
}
.buttonStyle(.borderedProminent)
.disabled(viewStore.isLoading)
.disabled(store.isLoading)
.frame(maxWidth: .infinity)
.frame(height: 50)
@@ -152,7 +152,7 @@ struct ConfigView: View {
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
}
}
.navigationBarHidden(true)

View File

@@ -0,0 +1,188 @@
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 isCodeSent: Bool = false
#if DEBUG
init() {
self.email = "exzero@126.com"
self.verificationCode = ""
}
#endif
}
enum Action {
case emailChanged(String)
case verificationCodeChanged(String)
case getVerificationCodeTapped
case getCodeResponse(Result<EmailGetCodeResponse, Error>)
case loginButtonTapped(email: String, verificationCode: String)
case loginResponse(Result<AccountModel, Error>)
case forgotPasswordTapped
case resetState
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .emailChanged(let email):
state.email = email
state.errorMessage = nil
return .none
case .verificationCodeChanged(let code):
state.verificationCode = code
state.errorMessage = nil
return .none
case .getVerificationCodeTapped:
guard !state.email.isEmpty else {
state.errorMessage = "email_login.email_required".localized
return .none
}
guard ValidationHelper.isValidEmail(state.email) else {
state.errorMessage = "email_login.invalid_email".localized
return .none
}
state.isCodeLoading = true
state.isCodeSent = false //
state.errorMessage = nil
return .run { [email = state.email] send in
do {
guard let request = LoginHelper.createEmailGetCodeRequest(email: email) else {
await send(.getCodeResponse(.failure(APIError.encryptionFailed)))
return
}
let response = try await apiService.request(request)
await send(.getCodeResponse(.success(response)))
} catch {
await send(.getCodeResponse(.failure(error)))
}
}
case .getCodeResponse(.success(let response)):
state.isCodeLoading = false
if response.isSuccess {
state.isCodeSent = true
return .none
} else {
state.errorMessage = response.errorMessage
return .none
}
case .getCodeResponse(.failure(let error)):
state.isCodeLoading = false
if let apiError = error as? APIError {
state.errorMessage = apiError.localizedDescription
} else {
state.errorMessage = "验证码发送失败,请检查网络连接"
}
return .none
case .loginButtonTapped(let email, let verificationCode):
guard !email.isEmpty && !verificationCode.isEmpty else {
state.errorMessage = "email_login.fields_required".localized
return .none
}
guard ValidationHelper.isValidEmail(email) else {
state.errorMessage = "email_login.invalid_email".localized
return .none
}
state.isLoading = true
state.errorMessage = nil
return .run { send in
do {
guard let request = LoginHelper.createEmailLoginRequest(email: email, code: verificationCode) else {
await send(.loginResponse(.failure(APIError.encryptionFailed)))
return
}
let response = try await apiService.request(request)
if response.isSuccess, let loginData = response.data {
guard let accountModel = AccountModel.from(loginData: loginData) else {
await send(.loginResponse(.failure(APIError.invalidResponse)))
return
}
// Ticket
let ticketRequest = TicketHelper.createTicketRequest(
accessToken: accountModel.accessToken ?? "",
uid: accountModel.uid.flatMap { Int($0) }
)
let ticketResponse = try await apiService.request(ticketRequest)
if ticketResponse.isSuccess, let ticket = ticketResponse.ticket {
let completeAccount = accountModel.withTicket(ticket)
await send(.loginResponse(.success(completeAccount)))
} else {
await send(.loginResponse(.failure(APIError.ticketFailed)))
}
} else {
await send(.loginResponse(.failure(APIError.custom(response.errorMessage))))
}
} catch {
await send(.loginResponse(.failure(error)))
}
}
case .loginResponse(.success(let accountModel)):
state.isLoading = false
// AccountModel
UserInfoManager.saveAccountModel(accountModel)
//
NotificationCenter.default.post(name: .ticketSuccess, object: nil)
return .none
case .loginResponse(.failure(let error)):
state.isLoading = false
if let apiError = error as? APIError {
state.errorMessage = apiError.localizedDescription
} else {
state.errorMessage = "登录失败,请重试"
}
return .none
case .forgotPasswordTapped:
return .none
case .resetState:
state.email = ""
state.verificationCode = ""
state.isLoading = false
state.isCodeLoading = false
state.errorMessage = nil
state.isCodeSent = false
return .none
}
}
}
}

View File

@@ -0,0 +1,70 @@
import Foundation
import ComposableArchitecture
@Reducer
struct HomeFeature {
@ObservableState
struct State: Equatable {
var isInitialized = false
var userInfo: UserInfo?
var accountModel: AccountModel?
var error: String?
}
enum Action: Equatable {
case onAppear
case loadUserInfo
case userInfoLoaded(UserInfo?)
case loadAccountModel
case accountModelLoaded(AccountModel?)
case logoutTapped
case logout
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.isInitialized = true
return .concatenate(
.send(.loadUserInfo),
.send(.loadAccountModel)
)
case .loadUserInfo:
//
let userInfo = UserInfoManager.getUserInfo()
return .send(.userInfoLoaded(userInfo))
case let .userInfoLoaded(userInfo):
state.userInfo = userInfo
return .none
case .loadAccountModel:
//
let accountModel = UserInfoManager.getAccountModel()
return .send(.accountModelLoaded(accountModel))
case let .accountModelLoaded(accountModel):
state.accountModel = accountModel
return .none
case .logoutTapped:
return .send(.logout)
case .logout:
//
UserInfoManager.clearAllAuthenticationData()
//
NotificationCenter.default.post(name: .homeLogout, object: nil)
return .none
}
}
}
}
// MARK: - Notification Extension
extension Notification.Name {
static let homeLogout = Notification.Name("homeLogout")
}

View File

@@ -0,0 +1,215 @@
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?
// Account Model Ticket
var accountModel: AccountModel?
var isTicketLoading = false
var ticketError: String?
var loginStep: LoginStep = .initial
enum LoginStep: Equatable {
case initial //
case authenticating // OAuth
case gettingTicket // Ticket
case completed //
case failed //
}
#if DEBUG
init() {
//
self.userID = ""
self.password = ""
}
#endif
}
enum Action: Equatable {
case togglePasswordVisibility
case loginButtonTapped(userID: String, password: String)
case forgotPasswordTapped
case backButtonTapped
case loginResponse(TaskResult<IDLoginResponse>)
// Ticket actions
case requestTicket(accessToken: String)
case ticketResponse(TaskResult<TicketResponse>)
case clearTicketError
case resetLogin
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .togglePasswordVisibility:
state.isPasswordVisible.toggle()
return .none
case let .loginButtonTapped(userID, password):
state.userID = userID
state.password = password
state.isLoading = true
state.errorMessage = nil
state.ticketError = nil
state.loginStep = .authenticating
// IDAPI
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
// AccountModel
if let loginData = response.data,
let accountModel = AccountModel.from(loginData: loginData) {
state.accountModel = accountModel
//
if let userInfo = loginData.userInfo {
UserInfoManager.saveUserInfo(userInfo)
}
print("✅ ID 登录 OAuth 认证成功")
print("🔑 Access Token: \(accountModel.accessToken ?? "nil")")
print("🆔 用户 UID: \(accountModel.uid ?? "nil")")
// ticket
return .send(.requestTicket(accessToken: accountModel.accessToken!))
} else {
state.errorMessage = "登录数据格式错误"
state.loginStep = .failed
}
} else {
state.errorMessage = response.errorMessage
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 { [accountModel = state.accountModel] send in
do {
// AccountModel uid Int
let uid = accountModel?.uid != nil ? Int(accountModel!.uid!) : nil
let ticketRequest = TicketHelper.createTicketRequest(accessToken: accessToken, uid: uid)
let response = try await apiService.request(ticketRequest)
await send(.ticketResponse(.success(response)))
} 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.loginStep = .completed
print("✅ ID 登录完整流程成功")
print("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
// AccountModel ticket
if let ticket = response.ticket {
if var accountModel = state.accountModel {
accountModel.ticket = ticket
state.accountModel = accountModel
// AccountModel
UserInfoManager.saveAccountModel(accountModel)
// Ticket
NotificationCenter.default.post(name: .ticketSuccess, object: nil)
} else {
print("❌ AccountModel 不存在,无法保存 ticket")
state.ticketError = "内部错误:账户信息丢失"
state.loginStep = .failed
}
} else {
state.ticketError = "Ticket 为空"
state.loginStep = .failed
}
} 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.accountModel = nil // AccountModel
state.loginStep = .initial
//
UserInfoManager.clearAllAuthenticationData()
return .none
}
}
}
}

View File

@@ -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,69 +9,214 @@ struct LoginFeature {
var password: String = ""
var isLoading = false
var error: String?
var idLoginState = IDLoginFeature.State()
var emailLoginState = EMailLoginFeature.State() //
// Account Model Ticket
var accountModel: AccountModel?
var isTicketLoading = false
var ticketError: String?
var loginStep: LoginStep = .initial
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
}
enum Action: Equatable {
enum Action {
case updateAccount(String)
case updatePassword(String)
case login
case loginResponse(TaskResult<LoginResponse>)
case loginResponse(TaskResult<IDLoginResponse>)
case idLogin(IDLoginFeature.Action)
case emailLogin(EMailLoginFeature.Action) // action
// Ticket actions
case requestTicket(accessToken: String)
case ticketResponse(TaskResult<TicketResponse>)
case clearTicketError
case resetLogin
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
// Reduce { state, action in
// switch action {
// case let .updateAccount(account):
// state.account = account
// return .none
//
// case let .updatePassword(password):
// state.password = password
// return .none
//
// case .login:
// state.isLoading = true
// state.error = nil
//
// let loginBody = [
// "account": state.account,
// "password": state.password
// ]
//
// return .run { send in
// do {
// let response: LoginResponse = try await APIClientManager.shared.post(
// path: APIConstants.Endpoints.login,
// body: loginBody,
// headers: APIConstants.defaultHeaders
// )
// await send(.loginResponse(.success(response)))
// } catch {
// await send(.loginResponse(.failure(error)))
// }
// }
//
// case let .loginResponse(.success(response)):
// state.isLoading = false
// if response.status == "success" {
// // TODO: token
// } else {
// state.error = response.message ?? ""
// }
// return .none
//
// case let .loginResponse(.failure(error)):
// state.isLoading = false
// state.error = error.localizedDescription
// return .none
// }
// }
Scope(state: \.idLoginState, action: \.idLogin) {
IDLoginFeature()
}
Scope(state: \.emailLoginState, action: \.emailLogin) {
EMailLoginFeature()
}
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
// 使accountpassword
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
// AccountModel
if let loginData = response.data,
let accountModel = AccountModel.from(loginData: loginData) {
state.accountModel = accountModel
print("✅ OAuth 认证成功")
print("🔑 Access Token: \(accountModel.accessToken ?? "nil")")
print("🆔 用户 UID: \(accountModel.uid ?? "nil")")
// ticket
return .send(.requestTicket(accessToken: accountModel.accessToken!))
} else {
state.error = "登录数据格式错误"
state.loginStep = .failed
}
} else {
state.error = response.errorMessage
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 { [accountModel = state.accountModel] send in
do {
// AccountModel uid Int
let uid = accountModel?.uid != nil ? Int(accountModel!.uid!) : nil
let ticketRequest = TicketHelper.createTicketRequest(accessToken: accessToken, uid: uid)
let response = try await apiService.request(ticketRequest)
await send(.ticketResponse(.success(response)))
} 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.loginStep = .completed
print("✅ 完整登录流程成功")
print("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
// AccountModel ticket
if let ticket = response.ticket {
if var accountModel = state.accountModel {
accountModel.ticket = ticket
state.accountModel = accountModel
// AccountModel
UserInfoManager.saveAccountModel(accountModel)
// Ticket
NotificationCenter.default.post(name: .ticketSuccess, object: nil)
} else {
print("❌ AccountModel 不存在,无法保存 ticket")
state.ticketError = "内部错误:账户信息丢失"
state.loginStep = .failed
}
} else {
state.ticketError = "Ticket 为空"
state.loginStep = .failed
}
} 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.accountModel = nil // AccountModel
state.loginStep = .initial
//
UserInfoManager.clearAllAuthenticationData()
return .none
case .idLogin:
// IDLoginfeature
return .none
case .emailLogin:
// EmailLoginfeature
return .none
}
}
}
}

View File

@@ -0,0 +1,281 @@
import Foundation
import ComposableArchitecture
@Reducer
struct RecoverPasswordFeature {
@ObservableState
struct State: Equatable {
var email: String = ""
var verificationCode: String = ""
var newPassword: String = ""
var isCodeLoading: Bool = false
var isResetLoading: Bool = false
var isResetSuccess: Bool = false
var errorMessage: String? = nil
var isCodeSent: Bool = false
#if DEBUG
init() {
self.email = "exzero@126.com"
self.verificationCode = ""
self.newPassword = ""
}
#endif
}
enum Action {
case emailChanged(String)
case verificationCodeChanged(String)
case newPasswordChanged(String)
case getVerificationCodeTapped
case getCodeResponse(Result<EmailGetCodeResponse, Error>)
case resetPasswordTapped
case resetPasswordResponse(Result<ResetPasswordResponse, Error>)
case resetSuccess
case resetState
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .emailChanged(let email):
state.email = email
state.errorMessage = nil
return .none
case .verificationCodeChanged(let code):
state.verificationCode = code
state.errorMessage = nil
return .none
case .newPasswordChanged(let password):
state.newPassword = password
state.errorMessage = nil
return .none
case .getVerificationCodeTapped:
guard !state.email.isEmpty else {
state.errorMessage = "recover_password.email_required".localized
return .none
}
guard ValidationHelper.isValidEmail(state.email) else {
state.errorMessage = "recover_password.invalid_email".localized
return .none
}
state.isCodeLoading = true
state.isCodeSent = false
state.errorMessage = nil
return .run { [email = state.email] send in
do {
guard let request = RecoverPasswordHelper.createEmailGetCodeRequest(email: email) else {
await send(.getCodeResponse(.failure(APIError.encryptionFailed)))
return
}
let response = try await apiService.request(request)
await send(.getCodeResponse(.success(response)))
} catch {
await send(.getCodeResponse(.failure(error)))
}
}
case .getCodeResponse(.success(let response)):
state.isCodeLoading = false
if response.isSuccess {
state.isCodeSent = true
return .none
} else {
state.errorMessage = response.errorMessage
return .none
}
case .getCodeResponse(.failure(let error)):
state.isCodeLoading = false
if let apiError = error as? APIError {
state.errorMessage = apiError.localizedDescription
} else {
state.errorMessage = "recover_password.code_send_failed".localized
}
return .none
case .resetPasswordTapped:
guard !state.email.isEmpty && !state.verificationCode.isEmpty && !state.newPassword.isEmpty else {
state.errorMessage = "recover_password.fields_required".localized
return .none
}
guard ValidationHelper.isValidEmail(state.email) else {
state.errorMessage = "recover_password.invalid_email".localized
return .none
}
guard ValidationHelper.isValidPassword(state.newPassword) else {
state.errorMessage = "recover_password.invalid_password".localized
return .none
}
state.isResetLoading = true
state.errorMessage = nil
return .run { [email = state.email, code = state.verificationCode, password = state.newPassword] send in
do {
guard let request = RecoverPasswordHelper.createResetPasswordRequest(
email: email,
code: code,
newPassword: password
) else {
await send(.resetPasswordResponse(.failure(APIError.encryptionFailed)))
return
}
let response = try await apiService.request(request)
await send(.resetPasswordResponse(.success(response)))
} catch {
await send(.resetPasswordResponse(.failure(error)))
}
}
case .resetPasswordResponse(.success(let response)):
state.isResetLoading = false
if response.isSuccess {
state.isResetSuccess = true
state.errorMessage = nil
return .send(.resetSuccess)
} else {
state.errorMessage = response.errorMessage
return .none
}
case .resetPasswordResponse(.failure(let error)):
state.isResetLoading = false
if let apiError = error as? APIError {
state.errorMessage = apiError.localizedDescription
} else {
state.errorMessage = "recover_password.reset_failed".localized
}
return .none
case .resetSuccess:
//
return .none
case .resetState:
state.email = ""
state.verificationCode = ""
state.newPassword = ""
state.isCodeLoading = false
state.isResetLoading = false
state.isResetSuccess = false
state.errorMessage = nil
state.isCodeSent = false
return .none
}
}
}
}
// MARK: - Password Reset API Models
///
struct ResetPasswordResponse: Codable, Equatable {
let status: String?
let message: String?
let code: Int?
let data: String?
///
var isSuccess: Bool {
return code == 200 || status?.lowercased() == "success"
}
///
var errorMessage: String {
return message ?? "recover_password.reset_failed".localized
}
}
/// - API
struct ResetPasswordRequest: APIRequestProtocol {
typealias Response = ResetPasswordResponse
let endpoint = "/acc/pwd/resetByEmail" // API
let method: HTTPMethod = .POST
let includeBaseParameters = true
let queryParameters: [String: String]?
let bodyParameters: [String: Any]? = nil
let timeout: TimeInterval = 30.0
///
/// - Parameters:
/// - email: DES
/// - code:
/// - newPwd: DES
init(email: String, code: String, newPwd: String) {
self.queryParameters = [
"email": email,
"newPwd": newPwd, // newPwd
"code": code
]
}
}
// MARK: - Recover Password Helper
struct RecoverPasswordHelper {
///
/// - Parameter email:
/// - Returns: APInil
static func createEmailGetCodeRequest(email: String) -> EmailGetCodeRequest? {
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
print("❌ 邮箱DES加密失败")
return nil
}
print("🔐 密码恢复邮箱DES加密成功")
print(" 原始邮箱: \(email)")
print(" 加密邮箱: \(encryptedEmail)")
// 使type=3
return EmailGetCodeRequest(emailAddress: email, type: 3)
}
///
/// - Parameters:
/// - email:
/// - code:
/// - newPassword:
/// - Returns: APInil
static func createResetPasswordRequest(email: String, code: String, newPassword: String) -> ResetPasswordRequest? {
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey),
let encryptedPassword = DESEncrypt.encryptUseDES(newPassword, key: encryptionKey) else {
print("❌ 密码重置DES加密失败")
return nil
}
print("🔐 密码重置DES加密成功")
print(" 原始邮箱: \(email)")
print(" 加密邮箱: \(encryptedEmail)")
print(" 验证码: \(code)")
print(" 原始新密码: \(newPassword)")
print(" 加密新密码: \(encryptedPassword)")
return ResetPasswordRequest(
email: email,
code: code,
newPwd: encryptedPassword // newPwd
)
}
}

View File

@@ -0,0 +1,39 @@
import Foundation
import ComposableArchitecture
@Reducer
struct SplashFeature {
@ObservableState
struct State: Equatable {
var isLoading = true
var shouldShowMainApp = false
}
enum Action: Equatable {
case onAppear
case splashFinished
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.isLoading = true
state.shouldShowMainApp = false
// 1 (iOS 15.5+ )
return .run { send in
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 = 1,000,000,000
await send(.splashFinished)
}
case .splashFinished:
state.isLoading = false
state.shouldShowMainApp = true
//
NotificationCenter.default.post(name: .splashFinished, object: nil)
return .none
}
}
}
}

Binary file not shown.

64
yana/Fonts/README.md Normal file
View File

@@ -0,0 +1,64 @@
# 字体文件使用指南
## 字体文件位置
请将 **Bayon-Regular.ttf** 字体文件放置在此文件夹中。
## 添加步骤
### 1. 获取字体文件
- 从 Google Fonts 下载 Bayon 字体https://fonts.google.com/specimen/Bayon
- 或从设计师提供的字体文件中获取 `Bayon-Regular.ttf`
### 2. 添加到项目
1.`Bayon-Regular.ttf` 文件拖放到此 `Fonts` 文件夹中
2. 在 Xcode 中,确保文件被添加到项目的 Target 中
3. 检查 `Info.plist` 中已经配置了 `UIAppFonts` 数组
### 3. 验证字体是否正确加载
`AppDelegate.swift` 中添加调试代码:
```swift
#if DEBUG
FontManager.printAllAvailableFonts()
// 检查 Bayon 字体是否可用
print("Bayon 字体可用:\(FontManager.isFontAvailable(.bayonRegular))")
#endif
```
## 当前配置状态
### ✅ 已完成:
- [x] Info.plist 配置完成
- [x] FontManager 工具类创建完成
- [x] LoginView 中 E-PARTI 文本已应用 Bayon 字体
- [x] 字体适配与屏幕尺寸兼容
### ⏳ 待完成:
- [ ] 添加 Bayon-Regular.ttf 字体文件到项目中
## 使用方法
### 方法1: 使用 FontManager推荐
```swift
Text("E-PARTI")
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
```
### 方法2: 使用 View Extension
```swift
Text("E-PARTI")
.adaptedCustomFont(.bayonRegular, designSize: 56)
```
### 方法3: 直接指定大小
```swift
Text("E-PARTI")
.customFont(.bayonRegular, size: 56)
```
## 故障排除
如果字体未生效,请检查:
1. 字体文件是否正确添加到项目 Target 中
2. Info.plist 中的字体文件名是否正确
3. 字体文件名与代码中使用的名称是否一致
4. 运行调试代码确认字体是否被系统识别

View File

@@ -9,5 +9,9 @@
</dict>
<key>NSWiFiUsageDescription</key>
<string>应用需要访问 Wi-Fi 信息以提供网络相关功能</string>
<key>UIAppFonts</key>
<array>
<string>Bayon-Regular.ttf</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="bg" translatesAutoresizingMaskIntoConstraints="NO" id="Mom-Je-A43">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="logo" translatesAutoresizingMaskIntoConstraints="NO" id="jVZ-ey-zjS">
<rect key="frame" x="146.66666666666666" y="200" width="100" height="100"/>
<constraints>
<constraint firstAttribute="width" constant="100" id="61A-Xv-MlD"/>
<constraint firstAttribute="height" constant="100" id="NWJ-mJ-K2O"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="E-Parti" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="GrU-nK-tAY">
<rect key="frame" x="138" y="332" width="117" height="48"/>
<fontDescription key="fontDescription" type="system" pointSize="40"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="jVZ-ey-zjS" firstAttribute="centerX" secondItem="Mom-Je-A43" secondAttribute="centerX" id="Atv-LB-aNW"/>
<constraint firstItem="Mom-Je-A43" firstAttribute="leading" secondItem="vDu-zF-Fre" secondAttribute="leading" id="fQ2-yj-nze"/>
<constraint firstItem="Mom-Je-A43" firstAttribute="top" secondItem="5EZ-qb-Rvc" secondAttribute="top" id="gVA-6N-OG4"/>
<constraint firstItem="GrU-nK-tAY" firstAttribute="centerY" secondItem="Mom-Je-A43" secondAttribute="centerY" constant="-70" id="j81-qa-9vS"/>
<constraint firstAttribute="bottom" secondItem="Mom-Je-A43" secondAttribute="bottom" id="lBn-me-aJu"/>
<constraint firstItem="Mom-Je-A43" firstAttribute="trailing" secondItem="vDu-zF-Fre" secondAttribute="trailing" id="lka-KN-fEy"/>
<constraint firstItem="jVZ-ey-zjS" firstAttribute="top" secondItem="Mom-Je-A43" secondAttribute="top" constant="200" id="nCq-oK-mTB"/>
<constraint firstItem="GrU-nK-tAY" firstAttribute="centerX" secondItem="Mom-Je-A43" secondAttribute="centerX" id="sZE-bZ-0Xj"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-208.3969465648855" y="-13.380281690140846"/>
</scene>
</scenes>
<resources>
<image name="bg" width="375" height="812"/>
<image name="logo" width="100" height="100"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@@ -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
}
}

View File

@@ -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
// )
// }
}

View File

@@ -0,0 +1,78 @@
/*
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";
// MARK: - 密码恢复页面
"recover_password.title" = "Recover Password";
"recover_password.placeholder_email" = "Please enter email";
"recover_password.placeholder_verification_code" = "Please enter verification code";
"recover_password.placeholder_new_password" = "6-16 Digits + English Letters";
"recover_password.get_code" = "Get";
"recover_password.confirm_button" = "Confirm";
"recover_password.email_required" = "Please enter email";
"recover_password.invalid_email" = "Please enter a valid email address";
"recover_password.fields_required" = "Please fill in all fields";
"recover_password.invalid_password" = "Password must be 6-16 characters with digits and letters";
"recover_password.code_send_failed" = "Failed to send verification code";
"recover_password.reset_failed" = "Failed to reset password";
"recover_password.reset_success" = "Password reset successfully";
"recover_password.resetting" = "Resetting...";
// MARK: - 主页
"home.title" = "Enjoy your Life Time";

View File

@@ -0,0 +1,78 @@
/*
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" = "登录失败,请检查您的凭据";
// MARK: - 密码恢复页面
"recover_password.title" = "找回密码";
"recover_password.placeholder_email" = "请输入邮箱";
"recover_password.placeholder_verification_code" = "请输入验证码";
"recover_password.placeholder_new_password" = "6-16位数字+英文字母";
"recover_password.get_code" = "获取";
"recover_password.confirm_button" = "确认";
"recover_password.email_required" = "请输入邮箱";
"recover_password.invalid_email" = "请输入有效的邮箱地址";
"recover_password.fields_required" = "请填写所有字段";
"recover_password.invalid_password" = "密码必须是6-16位数字和字母";
"recover_password.code_send_failed" = "验证码发送失败";
"recover_password.reset_failed" = "密码重置失败";
"recover_password.reset_success" = "密码重置成功";
"recover_password.resetting" = "重置中...";
// MARK: - 主页
"home.title" = "享受您的生活时光";

View File

@@ -0,0 +1,26 @@
import SwiftUI
// MARK: - Color Hex Extension
extension Color {
/// 使
/// - Parameter hex: 0xRRGGBB
/// - Example: Color(hex: 0x313131)
init(hex: UInt32) {
let red = Double((hex >> 16) & 0xFF) / 255.0
let green = Double((hex >> 8) & 0xFF) / 255.0
let blue = Double(hex & 0xFF) / 255.0
self.init(red: red, green: green, blue: blue)
}
/// 使
/// - Parameters:
/// - hex: 0xRRGGBB
/// - alpha: 0.0-1.0
/// - Example: Color(hex: 0x313131, alpha: 0.8)
init(hex: UInt32, alpha: Double) {
let red = Double((hex >> 16) & 0xFF) / 255.0
let green = Double((hex >> 8) & 0xFF) / 255.0
let blue = Double(hex & 0xFF) / 255.0
self.init(red: red, green: green, blue: blue, opacity: alpha)
}
}

View File

@@ -0,0 +1,83 @@
import Foundation
///
/// MD5 SHA256
struct StringHashTest {
///
static func runTests() {
print("🧪 开始测试字符串哈希方法...")
let testStrings = [
"hello world",
"test123",
"key=rpbs6us1m8r2j9g6u06ff2bo18orwaya",
"phone=encrypted_phone&password=encrypted_password&client_id=erban-client&key=rpbs6us1m8r2j9g6u06ff2bo18orwaya"
]
for testString in testStrings {
print("\n📝 测试字符串: \"\(testString)\"")
// MD5
let md5Result = testString.md5()
print(" MD5: \(md5Result)")
// SHA256 (iOS 13+)
if #available(iOS 13.0, *) {
let sha256Result = testString.sha256()
print(" SHA256: \(sha256Result)")
} else {
print(" SHA256: 不支持 (需要 iOS 13+)")
}
}
print("\n✅ 哈希方法测试完成")
}
///
static func verifyKnownHashes() {
print("\n🔍 验证已知哈希值...")
// "hello world" MD5 "5d41402abc4b2a76b9719d911017c592"
let testString = "hello world"
let expectedMD5 = "5d41402abc4b2a76b9719d911017c592"
let actualMD5 = testString.md5()
if actualMD5 == expectedMD5 {
print("✅ MD5 验证通过: \(actualMD5)")
} else {
print("❌ MD5 验证失败:")
print(" 期望: \(expectedMD5)")
print(" 实际: \(actualMD5)")
}
// SHA256
if #available(iOS 13.0, *) {
let expectedSHA256 = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
let actualSHA256 = testString.sha256()
if actualSHA256 == expectedSHA256 {
print("✅ SHA256 验证通过: \(actualSHA256)")
} else {
print("❌ SHA256 验证失败:")
print(" 期望: \(expectedSHA256)")
print(" 实际: \(actualSHA256)")
}
}
}
}
// MARK: - 使
/*
//
StringHashTest.runTests()
StringHashTest.verifyKnownHashes()
//
print("Test MD5:", "hello".md5())
if #available(iOS 13.0, *) {
print("Test SHA256:", "hello".sha256())
}
*/

View File

@@ -0,0 +1,39 @@
import Foundation
import CommonCrypto
import CryptoKit
// MARK: - String Hash Extensions
extension String {
/// SHA256使
/// - Returns: SHA256
@available(iOS 13.0, *)
func sha256() -> String {
let data = Data(self.utf8)
let digest = SHA256.hash(data: data)
return digest.compactMap { String(format: "%02x", $0) }.joined()
}
/// MD5
///
/// MD5iOS 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()
}
}
}

View File

@@ -0,0 +1,21 @@
import SwiftUI
// MARK: - View Extension for Placeholder
extension View {
/// TextFieldSecureField
/// - Parameters:
/// - shouldShow:
/// - alignment:
/// - placeholder:
/// - Returns:
func placeholder<Content: View>(
when shouldShow: Bool,
alignment: Alignment = .leading,
@ViewBuilder placeholder: () -> Content) -> some View {
ZStack(alignment: alignment) {
placeholder().opacity(shouldShow ? 1 : 0)
self
}
}
}

View File

@@ -0,0 +1,110 @@
import SwiftUI
///
/// 使
struct FontManager {
// MARK: -
enum CustomFont: String, CaseIterable {
case bayonRegular = "Bayon-Regular"
///
var displayName: String {
switch self {
case .bayonRegular:
return "Bayon Regular"
}
}
///
var fileName: String {
return self.rawValue
}
}
// MARK: -
///
/// - Parameters:
/// - customFont:
/// - size:
/// - Returns: Font
static func font(_ customFont: CustomFont, size: CGFloat) -> Font {
return Font.custom(customFont.rawValue, size: size)
}
///
/// - Parameters:
/// - customFont:
/// - designSize: 稿
/// - screenWidth:
/// - Returns: Font
static func adaptedFont(_ customFont: CustomFont, designSize: CGFloat, for screenWidth: CGFloat) -> Font {
let adaptedSize = ScreenAdapter.fontSize(designSize, for: screenWidth)
return Font.custom(customFont.rawValue, size: adaptedSize)
}
///
/// - Parameter customFont:
/// - Returns:
static func isFontAvailable(_ customFont: CustomFont) -> Bool {
let fontNames = UIFont.familyNames
.flatMap { UIFont.fontNames(forFamilyName: $0) }
return fontNames.contains(customFont.rawValue)
}
///
/// - Returns:
static func getAllAvailableFonts() -> [String] {
return UIFont.familyNames
.flatMap { family in
UIFont.fontNames(forFamilyName: family)
.map { _ in "\(family): \(String(describing: font))" }
}
.sorted()
}
///
static func printAllAvailableFonts() {
print("=== 所有可用字体 ===")
for font in getAllAvailableFonts() {
print(font)
}
print("==================")
}
}
// MARK: - SwiftUI View Extension
extension View {
///
/// - Parameters:
/// - customFont:
/// - size:
/// - Returns:
func customFont(_ customFont: FontManager.CustomFont, size: CGFloat) -> some View {
self.font(FontManager.font(customFont, size: size))
}
///
/// - Parameters:
/// - customFont:
/// - designSize: 稿
/// - Returns:
func adaptedCustomFont(_ customFont: FontManager.CustomFont, designSize: CGFloat) -> some View {
self.modifier(AdaptedCustomFontModifier(customFont: customFont, designSize: designSize))
}
}
// MARK: - ViewModifier
struct AdaptedCustomFontModifier: ViewModifier {
let customFont: FontManager.CustomFont
let designSize: CGFloat
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.font(FontManager.adaptedFont(customFont, designSize: designSize, for: geometry.size.width))
}
}
}

View File

@@ -0,0 +1,135 @@
import Foundation
import SwiftUI
///
/// 便
///
///
/// -
/// -
/// - UserDefaults
class LocalizationManager: ObservableObject {
// MARK: -
static let shared = LocalizationManager()
// MARK: -
enum SupportedLanguage: String, CaseIterable {
case english = "en"
case chineseSimplified = "zh-Hans"
var displayName: String {
switch self {
case .english:
return "English"
case .chineseSimplified:
return "简体中文"
}
}
var localizedDisplayName: String {
switch self {
case .english:
return "English"
case .chineseSimplified:
return "简体中文"
}
}
}
// MARK: -
@Published var currentLanguage: SupportedLanguage {
didSet {
UserDefaults.standard.set(currentLanguage.rawValue, forKey: "AppLanguage")
//
objectWillChange.send()
}
}
private init() {
// UserDefaults
let savedLanguage = UserDefaults.standard.string(forKey: "AppLanguage") ?? ""
self.currentLanguage = SupportedLanguage(rawValue: savedLanguage) ?? .english
// 使
if savedLanguage.isEmpty {
self.currentLanguage = Self.getSystemPreferredLanguage()
}
}
// MARK: -
///
/// - Parameters:
/// - key: key
/// - arguments:
/// - Returns:
func localizedString(_ key: String, arguments: CVarArg...) -> String {
let format = getLocalizedString(for: key)
if arguments.isEmpty {
return format
} else {
return String(format: format, arguments: arguments)
}
}
///
private func getLocalizedString(for key: String) -> String {
guard let path = Bundle.main.path(forResource: currentLanguage.rawValue, ofType: "lproj"),
let bundle = Bundle(path: path) else {
// key
return NSLocalizedString(key, comment: "")
}
return NSLocalizedString(key, bundle: bundle, comment: "")
}
// MARK: -
///
/// - Parameter language:
func switchLanguage(to language: SupportedLanguage) {
currentLanguage = language
}
///
///
private static func getSystemPreferredLanguage() -> SupportedLanguage {
//
//
return .english
}
}
// MARK: - SwiftUI Extensions
extension View {
///
/// - Parameter key: key
/// - Returns:
func localized(_ key: String) -> some View {
self.modifier(LocalizedTextModifier(key: key))
}
}
///
struct LocalizedTextModifier: ViewModifier {
let key: String
@ObservedObject private var localizationManager = LocalizationManager.shared
func body(content: Content) -> some View {
content
}
}
// MARK: - 便
extension String {
///
var localized: String {
return LocalizationManager.shared.localizedString(self)
}
///
func localized(arguments: CVarArg...) -> String {
return LocalizationManager.shared.localizedString(self, arguments: arguments)
}
}

View File

@@ -0,0 +1,114 @@
import SwiftUI
///
/// 稿
struct ScreenAdapter {
// MARK: - 稿
/// 稿 (iPhone 14 Pro)
static let designWidth: CGFloat = 393
/// 稿 (iPhone 14 Pro)
static let designHeight: CGFloat = 852
// MARK: -
/// 稿
/// - Parameters:
/// - designValue: 稿
/// - screenWidth:
/// - Returns:
static func width(_ designValue: CGFloat, for screenWidth: CGFloat) -> CGFloat {
return designValue * (screenWidth / designWidth)
}
/// 稿
/// - Parameters:
/// - designValue: 稿
/// - screenHeight:
/// - Returns:
static func height(_ designValue: CGFloat, for screenHeight: CGFloat) -> CGFloat {
return designValue * (screenHeight / designHeight)
}
/// 稿
/// - Parameters:
/// - designFontSize: 稿
/// - screenWidth:
/// - Returns:
static func fontSize(_ designFontSize: CGFloat, for screenWidth: CGFloat) -> CGFloat {
return designFontSize * (screenWidth / designWidth)
}
/// ()
/// - Parameter screenWidth:
/// - Returns:
static func widthRatio(for screenWidth: CGFloat) -> CGFloat {
return screenWidth / designWidth
}
/// ()
/// - Parameter screenHeight:
/// - Returns:
static func heightRatio(for screenHeight: CGFloat) -> CGFloat {
return screenHeight / designHeight
}
}
// MARK: - SwiftUI View Extension
extension View {
/// 稿
/// - Parameter designValue: 稿
/// - Returns:
func adaptedWidth(_ designValue: CGFloat) -> some View {
self.modifier(AdaptedWidthModifier(designValue: designValue))
}
/// 稿
/// - Parameter designValue: 稿
/// - Returns:
func adaptedHeight(_ designValue: CGFloat) -> some View {
self.modifier(AdaptedHeightModifier(designValue: designValue))
}
/// 稿
/// - Parameter designFontSize: 稿
/// - Returns:
func adaptedFont(_ designFontSize: CGFloat, weight: Font.Weight = .regular) -> some View {
self.modifier(AdaptedFontModifier(designFontSize: designFontSize, weight: weight))
}
}
// MARK: - ViewModifiers
struct AdaptedWidthModifier: ViewModifier {
let designValue: CGFloat
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.frame(width: ScreenAdapter.width(designValue, for: geometry.size.width))
}
}
}
struct AdaptedHeightModifier: ViewModifier {
let designValue: CGFloat
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.padding(.top, ScreenAdapter.height(designValue, for: geometry.size.height))
}
}
}
struct AdaptedFontModifier: ViewModifier {
let designFontSize: CGFloat
let weight: Font.Weight
func body(content: Content) -> some View {
GeometryReader { geometry in
content
.font(.system(size: ScreenAdapter.fontSize(designFontSize, for: geometry.size.width), weight: weight))
}
}
}

View File

@@ -0,0 +1,54 @@
import SwiftUI
/// ScreenAdapter 使
/// SwiftUI 使
struct ScreenAdapterExample: View {
var body: some View {
GeometryReader { geometry in
VStack(spacing: 20) {
// 1: 使 ScreenAdapter
Text("方法1: 直接调用")
.font(.system(size: ScreenAdapter.fontSize(16, for: geometry.size.width)))
.padding(.leading, ScreenAdapter.width(20, for: geometry.size.width))
.padding(.top, ScreenAdapter.height(50, for: geometry.size.height))
// 2: 使 View Extension ()
Text("方法2: View Extension")
.adaptedFont(16)
.adaptedHeight(50)
// 3: 使
Text("方法3: 比例计算")
.font(.system(size: 16 * ScreenAdapter.widthRatio(for: geometry.size.width)))
.padding(.top, 50 * ScreenAdapter.heightRatio(for: geometry.size.height))
Spacer()
}
}
}
}
// MARK: - 使
/*
使
1. View Extension ()
.adaptedFont(16)
.adaptedHeight(20)
.adaptedWidth(100)
2. ()
.font(.system(size: ScreenAdapter.fontSize(16, for: geometry.size.width)))
.padding(.top, ScreenAdapter.height(20, for: geometry.size.height))
3. ()
let ratio = ScreenAdapter.heightRatio(for: geometry.size.height)
.padding(.top, 20 * ratio)
*/
#Preview {
ScreenAdapterExample()
}

View File

@@ -0,0 +1,19 @@
//
// AESUtils.h
// YUMI
//
// Created by YUMI on 2023/2/13.
//
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface AESUtils : NSObject
//MARK: AES加解密
+ (NSString *)aesEncrypt:(NSString *)sourceStr;
+ (NSString *)aesDecrypt:(NSString *)secretStr;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,151 @@
//
// AESUtils.m
// YUMI
//
// Created by YUMI on 2023/2/13.
//
#import "AESUtils.h"
#import <CommonCrypto/CommonCrypto.h>
#define GL_AES_KEY @"aef01238765abcdeaaageggbeggsded"
#define GL_AES_IV @"edgcdgrtc"
@implementation AESUtils
//MARK: AES start
+ (NSString *)aesEncrypt:(NSString *)sourceStr {
if (!sourceStr) {
return nil;
}
//
char keyPtr[kCCKeySizeAES256 + 1];
bzero(keyPtr, sizeof(keyPtr));
[GL_AES_KEY getCString:keyPtr maxLength:sizeof(keyPtr) encoding:NSUTF8StringEncoding];
//
char ivPtr[kCCBlockSizeAES128 + 1];
bzero(ivPtr, sizeof(ivPtr));
[GL_AES_IV getCString:ivPtr maxLength:sizeof(ivPtr) encoding:NSUTF8StringEncoding];
NSData *sourceData = [sourceStr dataUsingEncoding:NSUTF8StringEncoding];
NSUInteger dataLength = [sourceData length];
size_t buffersize = dataLength + kCCBlockSizeAES128;
void *buffer = malloc(buffersize);
size_t numBytesEncrypted = 0;
/*
//CBC
kCCOptionPKCS7Padding
//ECB
kCCOptionPKCS7Padding | kCCOptionECBMode
*/
CCCryptorStatus cryptStatus = CCCrypt(kCCEncrypt,
kCCAlgorithmAES128,
kCCOptionPKCS7Padding,
keyPtr,
kCCBlockSizeAES128,
ivPtr,//ECBNULL
[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,//ECBNULL
[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;
}
}
// 16NSData
+ (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

View File

@@ -0,0 +1,16 @@
//
// Base64.h
// YMhatFramework
//
// Created by chenran on 2017/5/4.
// Copyright © 2017年 chenran. All rights reserved.
//
#import <Foundation/Foundation.h>
@interface Base64 : NSObject
+(NSString *)encode:(NSData *)data;
+(NSData *)decode:(NSString *)dataString;
@end

View File

@@ -0,0 +1,133 @@
//
// Base64.m
// YMhatFramework
//
// Created by chenran on 2017/5/4.
// Copyright © 2017 chenran. All rights reserved.
//
#import "Base64.h"
@interface Base64()
+(int)char2Int:(char)c;
@end
@implementation Base64
static const char encodingTable[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+(NSString *)encode:(NSData *)data
{
if (data.length == 0)
return nil;
char *characters = malloc(data.length * 3 / 2);
if (characters == NULL)
return nil;
int end = data.length - 3;
int index = 0;
int charCount = 0;
int n = 0;
while (index <= end) {
int d = (((int)(((char *)[data bytes])[index]) & 0x0ff) << 16)
| (((int)(((char *)[data bytes])[index + 1]) & 0x0ff) << 8)
| ((int)(((char *)[data bytes])[index + 2]) & 0x0ff);
characters[charCount++] = encodingTable[(d >> 18) & 63];
characters[charCount++] = encodingTable[(d >> 12) & 63];
characters[charCount++] = encodingTable[(d >> 6) & 63];
characters[charCount++] = encodingTable[d & 63];
index += 3;
if(n++ >= 14)
{
n = 0;
characters[charCount++] = ' ';
}
}
if(index == data.length - 2)
{
int d = (((int)(((char *)[data bytes])[index]) & 0x0ff) << 16)
| (((int)(((char *)[data bytes])[index + 1]) & 255) << 8);
characters[charCount++] = encodingTable[(d >> 18) & 63];
characters[charCount++] = encodingTable[(d >> 12) & 63];
characters[charCount++] = encodingTable[(d >> 6) & 63];
characters[charCount++] = '=';
}
else if(index == data.length - 1)
{
int d = ((int)(((char *)[data bytes])[index]) & 0x0ff) << 16;
characters[charCount++] = encodingTable[(d >> 18) & 63];
characters[charCount++] = encodingTable[(d >> 12) & 63];
characters[charCount++] = '=';
characters[charCount++] = '=';
}
NSString * rtnStr = [[NSString alloc] initWithBytesNoCopy:characters length:charCount encoding:NSUTF8StringEncoding freeWhenDone:YES];
return rtnStr;
}
+(NSData *)decode:(NSString *)data
{
if(data == nil || data.length <= 0) {
return nil;
}
NSMutableData *rtnData = [[NSMutableData alloc]init];
int slen = data.length;
int index = 0;
while (true) {
while (index < slen && [data characterAtIndex:index] <= ' ') {
index++;
}
if (index >= slen || index + 3 >= slen) {
break;
}
int byte = ([self char2Int:[data characterAtIndex:index]] << 18) + ([self char2Int:[data characterAtIndex:index + 1]] << 12) + ([self char2Int:[data characterAtIndex:index + 2]] << 6) + [self char2Int:[data characterAtIndex:index + 3]];
Byte temp1 = (byte >> 16) & 255;
[rtnData appendBytes:&temp1 length:1];
if([data characterAtIndex:index + 2] == '=') {
break;
}
Byte temp2 = (byte >> 8) & 255;
[rtnData appendBytes:&temp2 length:1];
if([data characterAtIndex:index + 3] == '=') {
break;
}
Byte temp3 = byte & 255;
[rtnData appendBytes:&temp3 length:1];
index += 4;
}
return rtnData;
}
+(int)char2Int:(char)c
{
if (c >= 'A' && c <= 'Z') {
return c - 65;
} else if (c >= 'a' && c <= 'z') {
return c - 97 + 26;
} else if (c >= '0' && c <= '9') {
return c - 48 + 26 + 26;
} else {
switch(c) {
case '+':
return 62;
case '/':
return 63;
case '=':
return 0;
default:
return -1;
}
}
}
@end

View File

@@ -0,0 +1,16 @@
//
// DESEncrypt.h
// YMhatFramework
//
// Created by chenran on 2017/5/4.
// Copyright © 2017年 chenran. All rights reserved.
//
#import <Foundation/Foundation.h>
@interface DESEncrypt : NSObject
//加密方法
+(NSString *) encryptUseDES:(NSString *)plainText key:(NSString *)key;
//解密方法
+(NSString *) decryptUseDES:(NSString *)cipherText key:(NSString *)key;
@end

View File

@@ -0,0 +1,63 @@
//
// DESEncrypt.m
// YMhatFramework
//
// Created by chenran on 2017/5/4.
// Copyright © 2017 chenran. All rights reserved.
//
#import "DESEncrypt.h"
#import <CommonCrypto/CommonCrypto.h>
#import "Base64.h"
@implementation DESEncrypt : NSObject
const Byte iv[] = {1,2,3,4,5,6,7,8};
#pragma mark-
+(NSString *) encryptUseDES:(NSString *)plainText key:(NSString *)key
{
NSString *ciphertext = nil;
NSData *textData = [plainText dataUsingEncoding:NSUTF8StringEncoding];
NSUInteger dataLength = [textData length];
unsigned char buffer[200000];
memset(buffer, 0, sizeof(char));
size_t numBytesEncrypted = 0;
CCCryptorStatus cryptStatus = CCCrypt(kCCEncrypt, kCCAlgorithmDES,
kCCOptionPKCS7Padding|kCCOptionECBMode,
[key UTF8String], kCCKeySizeDES,
iv,
[textData bytes], dataLength,
buffer, 200000,
&numBytesEncrypted);
if (cryptStatus == kCCSuccess) {
NSData *data = [NSData dataWithBytes:buffer length:(NSUInteger)numBytesEncrypted];
ciphertext = [Base64 encode:data];
}
return ciphertext;
}
#pragma mark-
+(NSString *)decryptUseDES:(NSString *)cipherText key:(NSString *)key
{
NSString *plaintext = nil;
NSData *cipherdata = [Base64 decode:cipherText];
unsigned char buffer[200000];
memset(buffer, 0, sizeof(char));
size_t numBytesDecrypted = 0;
// kCCOptionPKCS7Padding|kCCOptionECBMode
CCCryptorStatus cryptStatus = CCCrypt(kCCDecrypt, kCCAlgorithmDES,
kCCOptionPKCS7Padding|kCCOptionECBMode,
[key UTF8String], kCCKeySizeDES,
iv,
[cipherdata bytes], [cipherdata length],
buffer, 200000,
&numBytesDecrypted);
if(cryptStatus == kCCSuccess) {
NSData *plaindata = [NSData dataWithBytes:buffer length:(NSUInteger)numBytesDecrypted];
plaintext = [[NSString alloc]initWithData:plaindata encoding:NSUTF8StringEncoding];
}
return plaintext;
}
@end

View File

@@ -0,0 +1,51 @@
import Foundation
/// OCDES
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

View File

@@ -0,0 +1,41 @@
import Foundation
struct ValidationHelper {
///
/// - Parameter email:
/// - Returns:
static func isValidEmail(_ email: String) -> Bool {
let emailRegex = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"
let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
return emailPredicate.evaluate(with: email)
}
///
/// - Parameter phoneNumber:
/// - Returns:
static func isValidPhoneNumber(_ phoneNumber: String) -> Bool {
let phoneRegex = "^1[3-9]\\d{9}$"
let phonePredicate = NSPredicate(format: "SELF MATCHES %@", phoneRegex)
return phonePredicate.evaluate(with: phoneNumber)
}
///
/// - Parameter password:
/// - Returns: 6-16
static func isValidPassword(_ password: String) -> Bool {
guard password.count >= 6 && password.count <= 16 else { return false }
let hasLetter = password.rangeOfCharacter(from: .letters) != nil
let hasNumber = password.rangeOfCharacter(from: .decimalDigits) != nil
return hasLetter && hasNumber
}
///
/// - Parameter code:
/// - Returns: 4-6
static func isValidVerificationCode(_ code: String) -> Bool {
let codeRegex = "^\\d{4,6}$"
let codePredicate = NSPredicate(format: "SELF MATCHES %@", codeRegex)
return codePredicate.evaluate(with: code)
}
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
import ComposableArchitecture
struct AppRootView: View {
@State private var shouldShowMainApp = false
@State private var shouldShowHomePage = false
let splashStore = Store(
initialState: SplashFeature.State()
) {
SplashFeature()
}
let loginStore = Store(
initialState: LoginFeature.State()
) {
LoginFeature()
}
let homeStore = Store(
initialState: HomeFeature.State()
) {
HomeFeature()
}
var body: some View {
Group {
if shouldShowHomePage {
//
HomeView(store: homeStore)
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
} else if shouldShowMainApp {
//
LoginView(store: loginStore)
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
} else {
//
SplashView(store: splashStore)
.transition(.opacity.animation(.easeInOut(duration: 0.5)))
.onReceive(NotificationCenter.default.publisher(for: .splashFinished)) { _ in
shouldShowMainApp = true
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .ticketSuccess)) { _ in
// Ticket
withAnimation(.easeInOut(duration: 0.5)) {
shouldShowHomePage = true
}
}
.onReceive(NotificationCenter.default.publisher(for: .homeLogout)) { _ in
//
withAnimation(.easeInOut(duration: 0.5)) {
shouldShowHomePage = false
shouldShowMainApp = true
}
}
}
}
extension Notification.Name {
static let splashFinished = Notification.Name("splashFinished")
static let ticketSuccess = Notification.Name("ticketSuccess")
}
#Preview {
AppRootView()
}

View File

@@ -0,0 +1,59 @@
import SwiftUI
// MARK: - Login Button Component
struct LoginButton: View {
let iconName: String
let iconColor: Color
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
ZStack {
//
Color.white
.cornerRadius(28)
//
Text(title)
.font(.system(size: 18, weight: .semibold))
.frame(alignment: .center)
.foregroundColor(Color(hex: 0x313131))
//
HStack {
Image(systemName: iconName)
.foregroundColor(iconColor)
.font(.system(size: 30))
.padding(.leading, 33)
Spacer()
}
}
.frame(height: 56)
.padding(.horizontal, 29)
}
}
}
#Preview {
VStack(spacing: 20) {
LoginButton(
iconName: "person.circle.fill",
iconColor: .green,
title: "ID Login"
) {
// Preview action
}
LoginButton(
iconName: "envelope.fill",
iconColor: .blue,
title: "Email Login"
) {
// Preview action
}
}
.padding()
.background(Color.gray.opacity(0.2))
}

View File

@@ -0,0 +1,88 @@
import SwiftUI
// MARK: - User Agreement View Component
struct UserAgreementView: View {
@Binding var isAgreed: Bool
let onUserServiceTapped: () -> Void
let onPrivacyPolicyTapped: () -> Void
var body: some View {
HStack(alignment: .center, spacing: 12) {
//
Button(action: {
isAgreed.toggle()
}) {
Image(systemName: isAgreed ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22))
.foregroundColor(isAgreed ? Color(hex: 0x8A4FFF) : Color(hex: 0x666666))
}
//
Text(createAttributedText())
.font(.system(size: 14))
.multilineTextAlignment(.leading)
.environment(\.openURL, OpenURLAction { url in
if url.absoluteString == "user-service-agreement" {
onUserServiceTapped()
return .handled
} else if url.absoluteString == "privacy-policy" {
onPrivacyPolicyTapped()
return .handled
}
return .systemAction
})
}
.frame(maxWidth: .infinity) //
.padding(.horizontal, 29) //
}
// MARK: - Private Methods
private func createAttributedText() -> AttributedString {
var attributedString = AttributedString("login.agreement_policy".localized)
//
attributedString.foregroundColor = Color(hex: 0x666666)
// ""
if let userServiceRange = attributedString.range(of: "login.agreement".localized) {
attributedString[userServiceRange].foregroundColor = Color(hex: 0x8A4FFF)
attributedString[userServiceRange].underlineStyle = .single
attributedString[userServiceRange].link = URL(string: "user-service-agreement")
}
// ""
if let privacyPolicyRange = attributedString.range(of: "login.policy".localized) {
attributedString[privacyPolicyRange].foregroundColor = Color(hex: 0x8A4FFF)
attributedString[privacyPolicyRange].underlineStyle = .single
attributedString[privacyPolicyRange].link = URL(string: "privacy-policy")
}
return attributedString
}
}
#Preview {
VStack(spacing: 20) {
UserAgreementView(
isAgreed: .constant(true),
onUserServiceTapped: {
print("User Service Agreement tapped")
},
onPrivacyPolicyTapped: {
print("Privacy Policy tapped")
}
)
UserAgreementView(
isAgreed: .constant(true),
onUserServiceTapped: {
print("User Service Agreement tapped")
},
onPrivacyPolicyTapped: {
print("Privacy Policy tapped")
}
)
}
.padding()
.background(Color.gray.opacity(0.1))
}

View File

@@ -0,0 +1,55 @@
import SwiftUI
import SafariServices
// MARK: - Web View Component
struct WebView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
let config = SFSafariViewController.Configuration()
config.entersReaderIfAvailable = false
config.barCollapsingEnabled = true
let safariViewController = SFSafariViewController(url: url, configuration: config)
safariViewController.preferredBarTintColor = UIColor.systemBackground
safariViewController.preferredControlTintColor = UIColor.systemBlue
return safariViewController
}
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
// Safari View Controller
}
}
// MARK: - Web View Modifier
extension View {
/// Web
/// - Parameters:
/// - isPresented:
/// - url: URL
/// - Returns:
func webView(isPresented: Binding<Bool>, url: URL?) -> some View {
self.sheet(isPresented: isPresented) {
if let url = url {
WebView(url: url)
} else {
Text("无法加载页面")
.foregroundColor(.red)
.padding()
}
}
}
}
#Preview {
VStack {
Button("打开网页") {
//
}
}
.webView(
isPresented: .constant(true),
url: URL(string: "https://www.apple.com")
)
}

View File

@@ -0,0 +1,271 @@
import SwiftUI
import ComposableArchitecture
struct EMailLoginView: View {
let store: StoreOf<EMailLoginFeature>
let onBack: () -> Void
// 使@StateUI
@State private var email: String = ""
@State private var verificationCode: String = ""
@State private var codeCountdown: Int = 0
@State private var timer: Timer?
//
@FocusState private var focusedField: Field?
enum Field {
case email
case verificationCode
}
//
private var isLoginButtonEnabled: Bool {
return !store.isLoading && !email.isEmpty && !verificationCode.isEmpty
}
//
private var getCodeButtonText: String {
if store.isCodeLoading {
return ""
} else if codeCountdown > 0 {
return "\(codeCountdown)S"
} else {
return "email_login.get_code".localized
}
}
//
private var isCodeButtonEnabled: Bool {
return !store.isCodeLoading && codeCountdown == 0
}
var body: some View {
GeometryReader { geometry in
ZStack {
//
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)
.focused($focusedField, equals: .email)
}
//
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)
.focused($focusedField, equals: .verificationCode)
//
Button(action: {
//
startCountdown()
// API
store.send(.getVerificationCodeTapped)
}) {
ZStack {
if store.isCodeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.7)
} else {
Text(getCodeButtonText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
}
}
.frame(width: 60, height: 36)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(Color.white.opacity(isCodeButtonEnabled && !email.isEmpty ? 0.2 : 0.1))
)
}
.disabled(!isCodeButtonEnabled || email.isEmpty || store.isCodeLoading)
}
.padding(.horizontal, 24)
}
}
.padding(.horizontal, 32)
Spacer()
.frame(height: 60)
//
Button(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 {
//
store.send(.resetState)
email = ""
verificationCode = ""
codeCountdown = 0
stopCountdown()
#if DEBUG
email = "exzero@126.com"
store.send(.emailChanged(email))
#endif
}
.onDisappear {
stopCountdown()
}
.onChange(of: email) { newEmail in
store.send(.emailChanged(newEmail))
}
.onChange(of: verificationCode) { newCode in
store.send(.verificationCodeChanged(newCode))
}
.onChange(of: store.isCodeLoading) { isCodeLoading in
// API
if !isCodeLoading && store.errorMessage == nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
focusedField = .verificationCode
}
}
}
}
// MARK: -
private func startCountdown() {
stopCountdown()
//
codeCountdown = 60
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
DispatchQueue.main.async {
if codeCountdown > 0 {
codeCountdown -= 1
} else {
stopCountdown()
}
}
}
}
private func stopCountdown() {
timer?.invalidate()
timer = nil
}
}
#Preview {
EMailLoginView(
store: Store(
initialState: EMailLoginFeature.State()
) {
EMailLoginFeature()
},
onBack: {}
)
}

124
yana/Views/HomeView.swift Normal file
View File

@@ -0,0 +1,124 @@
import SwiftUI
import ComposableArchitecture
struct HomeView: View {
let store: StoreOf<HomeFeature>
@ObservedObject private var localizationManager = LocalizationManager.shared
var body: some View {
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
// - 使"bg"
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
// Navigation Bar
Text("home.title".localized)
.font(.custom("PingFang SC-Semibold", size: 16))
.foregroundColor(.white)
.frame(
width: 158,
height: 22,
alignment: .center
) //
.padding(.top, 8)
.padding(.horizontal)
//
VStack(spacing: 32) {
Spacer()
//
VStack(spacing: 16) {
// UserInfo
if let userInfo = store.userInfo, let userName = userInfo.username {
Text("欢迎, \(userName)")
.font(.title2)
.foregroundColor(.white)
} else {
Text("欢迎")
.font(.title2)
.foregroundColor(.white)
}
// ID UserInfo AccountModel
if let userInfo = store.userInfo, let userId = userInfo.userId {
Text("ID: \(userId)")
.font(.caption)
.foregroundColor(.white.opacity(0.8))
} else if let accountModel = store.accountModel, let uid = accountModel.uid {
Text("UID: \(uid)")
.font(.caption)
.foregroundColor(.white.opacity(0.8))
}
// AccountModel
if let accountModel = store.accountModel {
VStack(spacing: 4) {
if accountModel.hasValidSession {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("已登录")
.foregroundColor(.white.opacity(0.9))
}
.font(.caption)
} else if accountModel.hasValidAuthentication {
HStack {
Image(systemName: "clock.circle.fill")
.foregroundColor(.orange)
Text("认证中")
.foregroundColor(.white.opacity(0.9))
}
.font(.caption)
}
}
}
}
.padding()
.background(Color.black.opacity(0.3))
.cornerRadius(12)
.padding(.horizontal, 32)
Spacer()
//
Button(action: {
store.send(.logoutTapped)
}) {
HStack {
Image(systemName: "arrow.right.square")
Text("退出登录")
}
.font(.body)
.foregroundColor(.white)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color.red.opacity(0.7))
.cornerRadius(8)
}
.padding(.bottom, 50)
}
}
}
}
.onAppear {
store.send(.onAppear)
}
}
}
}
#Preview {
HomeView(
store: Store(
initialState: HomeFeature.State()
) {
HomeFeature()
}
)
}

View File

@@ -0,0 +1,225 @@
import SwiftUI
import ComposableArchitecture
struct IDLoginView: View {
let store: StoreOf<IDLoginFeature>
let onBack: () -> Void
// 使@StateUI
@State private var userID: String = ""
@State private var password: String = ""
@State private var isPasswordVisible: Bool = false
@State private var showRecoverPassword: Bool = false
//
private var isLoginButtonEnabled: Bool {
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: {
showRecoverPassword = true
}) {
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()
}
// NavigationLink -
NavigationLink(
destination: RecoverPasswordView(
store: Store(
initialState: RecoverPasswordFeature.State()
) {
RecoverPasswordFeature()
},
onBack: {
showRecoverPassword = false
}
)
.navigationBarHidden(true),
isActive: $showRecoverPassword
) {
EmptyView()
}
.hidden()
}
}
.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: {}
)
}

View File

@@ -0,0 +1,96 @@
import SwiftUI
import ComposableArchitecture
struct LanguageSettingsView: View {
@ObservedObject private var localizationManager = LocalizationManager.shared
@Binding var isPresented: Bool
init(isPresented: Binding<Bool> = .constant(true)) {
self._isPresented = isPresented
}
var body: some View {
NavigationView {
List {
Section {
ForEach(LocalizationManager.SupportedLanguage.allCases, id: \.rawValue) { language in
LanguageRow(
language: language,
isSelected: localizationManager.currentLanguage == language
) {
localizationManager.switchLanguage(to: language)
}
}
} header: {
Text("选择语言 / Select Language")
.font(.caption)
.foregroundColor(.secondary)
}
Section {
HStack {
Text("当前语言 / Current Language")
.font(.body)
Spacer()
Text(localizationManager.currentLanguage.localizedDisplayName)
.font(.body)
.foregroundColor(.blue)
}
} header: {
Text("语言信息 / Language Info")
.font(.caption)
.foregroundColor(.secondary)
}
}
.navigationTitle("语言设置 / Language")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("返回 / Back") {
isPresented = false
}
}
}
}
}
}
struct LanguageRow: View {
let language: LocalizationManager.SupportedLanguage
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(language.localizedDisplayName)
.font(.body)
.foregroundColor(.primary)
Text(language.displayName)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
.font(.system(size: 20))
}
}
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
}
}
// MARK: - Preview
#Preview {
LanguageSettingsView(isPresented: .constant(true))
}

179
yana/Views/LoginView.swift Normal file
View File

@@ -0,0 +1,179 @@
import SwiftUI
import ComposableArchitecture
// PreferenceKey
struct ImageHeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct LoginView: View {
let store: StoreOf<LoginFeature>
@State private var topImageHeight: CGFloat = 120 //
@ObservedObject private var localizationManager = LocalizationManager.shared
@State private var showLanguageSettings = false
@State private var isAgreedToTerms = true
@State private var showUserAgreement = false
@State private var showPrivacyPolicy = false
@State private var showIDLogin = false // 使SwiftUI@State
@State private var showEmailLogin = false //
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
) {
showEmailLogin = true //
}
}.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()
// NavigationLink
NavigationLink(
destination: EMailLoginView(
store: store.scope(
state: \.emailLoginState,
action: \.emailLogin
),
onBack: {
showEmailLogin = false // SwiftUI
}
)
.navigationBarHidden(true),
isActive: $showEmailLogin // 使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()
}
)
}

View File

@@ -0,0 +1,309 @@
import SwiftUI
import ComposableArchitecture
struct RecoverPasswordView: View {
let store: StoreOf<RecoverPasswordFeature>
let onBack: () -> Void
// 使@StateUI
@State private var email: String = ""
@State private var verificationCode: String = ""
@State private var newPassword: String = ""
@State private var isNewPasswordVisible: Bool = false
//
@State private var countdown: Int = 0
@State private var countdownTimer: Timer?
//
private var isConfirmButtonEnabled: Bool {
return !store.isResetLoading && !email.isEmpty && !verificationCode.isEmpty && !newPassword.isEmpty
}
//
private var isGetCodeButtonEnabled: Bool {
return !store.isCodeLoading && !email.isEmpty && countdown == 0
}
//
private var getCodeButtonText: String {
if store.isCodeLoading {
return ""
} else if countdown > 0 {
return "\(countdown)s"
} else {
return "recover_password.get_code".localized
}
}
var body: some View {
GeometryReader { geometry in
ZStack {
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
//
HStack {
Button(action: {
onBack()
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 8)
Spacer()
.frame(height: 60)
//
Text("recover_password.title".localized)
.font(.system(size: 28, weight: .medium))
.foregroundColor(.white)
.padding(.bottom, 80)
//
VStack(spacing: 24) {
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
TextField("", text: $email)
.placeholder(when: email.isEmpty) {
Text("recover_password.placeholder_email".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
.padding(.horizontal, 24)
.keyboardType(.emailAddress)
.autocapitalization(.none)
}
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
HStack {
TextField("", text: $verificationCode)
.placeholder(when: verificationCode.isEmpty) {
Text("recover_password.placeholder_verification_code".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
.keyboardType(.numberPad)
//
Button(action: {
//
startCountdown()
// API
store.send(.getVerificationCodeTapped)
}) {
ZStack {
if store.isCodeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.7)
} else {
Text(getCodeButtonText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
}
}
.frame(width: 60, height: 36)
.background(
RoundedRectangle(cornerRadius: 15)
.fill(Color.white.opacity(isGetCodeButtonEnabled ? 0.2 : 0.1))
)
}
.disabled(!isGetCodeButtonEnabled || store.isCodeLoading)
}
.padding(.horizontal, 24)
}
//
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.white.opacity(0.1))
.overlay(
RoundedRectangle(cornerRadius: 25)
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
.frame(height: 56)
HStack {
if isNewPasswordVisible {
TextField("", text: $newPassword)
.placeholder(when: newPassword.isEmpty) {
Text("recover_password.placeholder_new_password".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
} else {
SecureField("", text: $newPassword)
.placeholder(when: newPassword.isEmpty) {
Text("recover_password.placeholder_new_password".localized)
.foregroundColor(.white.opacity(0.6))
}
.foregroundColor(.white)
.font(.system(size: 16))
}
Button(action: {
isNewPasswordVisible.toggle()
}) {
Image(systemName: isNewPasswordVisible ? "eye.slash" : "eye")
.foregroundColor(.white.opacity(0.7))
.font(.system(size: 18))
}
}
.padding(.horizontal, 24)
}
}
.padding(.horizontal, 32)
Spacer()
.frame(height: 80)
//
Button(action: {
store.send(.resetPasswordTapped)
}) {
ZStack {
//
LinearGradient(
colors: [
Color(red: 0.85, green: 0.37, blue: 1.0), // #D85EFF
Color(red: 0.54, green: 0.31, blue: 1.0) // #8A4FFF
],
startPoint: .leading,
endPoint: .trailing
)
.clipShape(RoundedRectangle(cornerRadius: 28))
HStack {
if store.isResetLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
}
Text(store.isResetLoading ? "recover_password.resetting".localized : "recover_password.confirm_button".localized)
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
}
}
.frame(height: 56)
}
.disabled(!isConfirmButtonEnabled)
.opacity(isConfirmButtonEnabled ? 1.0 : 0.5)
.padding(.horizontal, 32)
//
if let errorMessage = store.errorMessage {
Text(errorMessage)
.font(.system(size: 14))
.foregroundColor(.red)
.padding(.top, 16)
.padding(.horizontal, 32)
}
Spacer()
}
}
}
.onAppear {
//
store.send(.resetState)
email = ""
verificationCode = ""
newPassword = ""
isNewPasswordVisible = false
countdown = 0
stopCountdown()
#if DEBUG
email = "exzero@126.com"
store.send(.emailChanged(email))
#endif
}
.onDisappear {
stopCountdown()
}
.onChange(of: email) { newEmail in
store.send(.emailChanged(newEmail))
}
.onChange(of: verificationCode) { newCode in
store.send(.verificationCodeChanged(newCode))
}
.onChange(of: newPassword) { newPassword in
store.send(.newPasswordChanged(newPassword))
}
.onChange(of: store.isCodeLoading) { isCodeLoading in
// API
if !isCodeLoading && store.errorMessage == nil {
//
}
}
.onChange(of: store.isResetSuccess) { isResetSuccess in
//
if isResetSuccess {
onBack()
}
}
}
// MARK: - Private Methods
private func startCountdown() {
countdown = 60
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if countdown > 0 {
countdown -= 1
} else {
stopCountdown()
}
}
}
private func stopCountdown() {
countdownTimer?.invalidate()
countdownTimer = nil
countdown = 0
}
}
#Preview {
RecoverPasswordView(
store: Store(
initialState: RecoverPasswordFeature.State()
) {
RecoverPasswordFeature()
},
onBack: {}
)
}

View File

@@ -0,0 +1,49 @@
import SwiftUI
import ComposableArchitecture
struct SplashView: View {
let store: StoreOf<SplashFeature>
var body: some View {
WithPerceptionTracking {
ZStack {
// -
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 32) {
Spacer()
.frame(height: 200) // storyboard
// Logo - 100x100
Image("logo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100)
// - 40pt
Text("E-Parti")
.font(.system(size: 40, weight: .regular))
.foregroundColor(.white)
Spacer()
}
}
.onAppear {
store.send(.onAppear)
}
}
}
}
#Preview {
SplashView(
store: Store(
initialState: SplashFeature.State()
) {
SplashFeature()
}
)
}

View File

@@ -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"

View File

@@ -1,8 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.external-accessory.wireless-configuration</key>
<true/>
</dict>
<dict/>
</plist>

View File

@@ -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()
}
}
}

View File

@@ -33,36 +33,143 @@ 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 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", "访问令牌应该正确")
}
func testClientInit_Failure() {
// mock
//
let expectation = self.expectation(description: "clientInit failure")
// APIbaseURLmock
API.clientInit { result in
switch result {
case .success(_):
// fail
XCTFail("Expected failure, got success")
case .failure(let error):
XCTAssertNotNil(error)
}
expectation.fulfill()
func testAccountModelFlow() {
// AccountModel
// 1. oauth/token
let loginData = IDLoginData(
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test",
refreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh",
tokenType: "bearer",
expiresIn: 2591999,
scope: "read write",
userInfo: nil, // APIuser_info
uid: 3184,
netEaseToken: "6fba51065b5e32ad18a935438517a1a9",
jti: "d3a82ddb-ea6f-4d2f-8dc7-7bdb3d6b9e87"
)
// 2. IDLoginData AccountModel
let accountModel = AccountModel.from(loginData: loginData)
XCTAssertNotNil(accountModel, "应该能从IDLoginData创建AccountModel")
XCTAssertEqual(accountModel?.uid, "3184", "UID应该正确转换为字符串")
XCTAssertEqual(accountModel?.accessToken, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test", "Access Token应该正确")
XCTAssertEqual(accountModel?.tokenType, "bearer", "Token类型应该正确")
XCTAssertEqual(accountModel?.netEaseToken, "6fba51065b5e32ad18a935438517a1a9", "网易云Token应该正确")
XCTAssertNil(accountModel?.ticket, "初始ticket应该为空")
// 3.
XCTAssertTrue(accountModel?.hasValidAuthentication ?? false, "应该有有效的认证")
XCTAssertFalse(accountModel?.hasValidSession ?? true, "没有ticket时不应该有有效会话")
// 4. ticketAccountModel
let ticketString = "eyJhbGciOiJIUzI1NiJ9.ticket"
let updatedAccountModel = accountModel?.withTicket(ticketString)
XCTAssertNotNil(updatedAccountModel, "应该能更新ticket")
XCTAssertEqual(updatedAccountModel?.ticket, ticketString, "Ticket应该正确设置")
XCTAssertTrue(updatedAccountModel?.hasValidSession ?? false, "有ticket时应该有有效会话")
// 5.
if let finalAccountModel = updatedAccountModel {
UserInfoManager.saveAccountModel(finalAccountModel)
let loadedAccountModel = UserInfoManager.getAccountModel()
XCTAssertNotNil(loadedAccountModel, "应该能加载保存的AccountModel")
XCTAssertEqual(loadedAccountModel?.uid, "3184", "加载的UID应该正确")
XCTAssertEqual(loadedAccountModel?.accessToken, finalAccountModel.accessToken, "加载的Access Token应该正确")
XCTAssertEqual(loadedAccountModel?.ticket, ticketString, "加载的Ticket应该正确")
// 6.
let userId = UserInfoManager.getCurrentUserId()
let accessToken = UserInfoManager.getAccessToken()
let ticket = UserInfoManager.getCurrentUserTicket()
XCTAssertEqual(userId, "3184", "向后兼容的用户ID应该正确")
XCTAssertEqual(accessToken, finalAccountModel.accessToken, "向后兼容的Access Token应该正确")
XCTAssertEqual(ticket, ticketString, "向后兼容的Ticket应该正确")
}
// 7.
UserInfoManager.clearAllAuthenticationData()
XCTAssertNil(UserInfoManager.getAccountModel(), "清理后AccountModel应该为空")
XCTAssertNil(UserInfoManager.getCurrentUserId(), "清理后用户ID应该为空")
}
func testAccountModelWithRealAPIData() {
// 使API
let realAPIResponseData: [String: Any] = [
"uid": 3184,
"jti": "d3a82ddb-ea6f-4d2f-8dc7-7bdb3d6b9e87",
"token_type": "bearer",
"scope": "read write",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiIyMzU2ODE0Iiwic2NvcGUiOlsicmVhZCIsIndyaXRlIl0sImF0aSI6ImQzYTgyZGRiLWVhNmYtNGQyZi04ZGM3LTdiZGIzZDZiOWU4NyIsImV4cCI6MTc1NTI0MjY5MiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9hY2NvdW50IiwiUk9MRV9NT0JJTEUiLCJST0xFX1VOSVRZIl0sImp0aSI6ImFiZjhjN2ZjLTllOWEtNDE2Yy04NTk2LTBkMWYxZWQyODU2MiIsImNsaWVudF9pZCI6ImVyYmFuLWNsaWVudCJ9.6i_9FnZvviuWYIoXDv9of7EDRyjRVxNbkiHayNUFxNw",
"netEaseToken": "6fba51065b5e32ad18a935438517a1a9",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTQ2Mzc4OTIsInVzZXJfbmFtZSI6IjIzNTY4MTQiLCJhdXRob3JpdGllcyI6WyJST0xFX2FjY291bnQiLCJST0xFX01PQklMRSIsIlJPTEVfVU5JVFkiXSwianRpIjoiZDNhODJkZGItZWE2Zi00ZDJmLThkYzctN2JkYjNkNmI5ZTg3IiwiY2xpZW50X2lkIjoiZXJiYW4tY2xpZW50Iiwic2NvcGUiOlsicmVhZCIsIndyaXRlIl19.ynUptBtAoPVXz4J1AO8LbaAhmFRF4UnF4C-Ggj6Izpc",
"expires_in": 2591999
]
// JSON
do {
let jsonData = try JSONSerialization.data(withJSONObject: realAPIResponseData)
let loginData = try JSONDecoder().decode(IDLoginData.self, from: jsonData)
// AccountModel
let accountModel = AccountModel.from(loginData: loginData)
XCTAssertNotNil(accountModel, "应该能从真实API数据创建AccountModel")
XCTAssertEqual(accountModel?.uid, "3184", "真实API数据的UID应该正确")
XCTAssertTrue(accountModel?.hasValidAuthentication ?? false, "真实API数据应该有有效认证")
print("✅ 真实API数据测试通过")
print(" UID: \(accountModel?.uid ?? "nil")")
print(" Access Token存在: \(accountModel?.accessToken != nil)")
print(" Token类型: \(accountModel?.tokenType ?? "nil")")
} catch {
XCTFail("解析真实API数据失败: \(error)")
}
waitForExpectations(timeout: 5, handler: nil)
}
}