Compare commits
3 Commits
e-party/1.
...
6084ade9ea
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6084ade9ea | ||
![]() |
e45ad3bad5 | ||
![]() |
c470dba79c |
@@ -6,7 +6,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
# CONTEXT
|
# 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,
|
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.
|
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
|
# 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -7,6 +7,7 @@ alwaysApply: true
|
|||||||
|
|
||||||
# Architechture
|
# Architechture
|
||||||
- Use TCA(The Composable Architecture) architecture with SwiftUI & Swift
|
- Use TCA(The Composable Architecture) architecture with SwiftUI & Swift
|
||||||
|
- Don't use TCA for UI Navigation
|
||||||
|
|
||||||
# Code Structure
|
# Code Structure
|
||||||
- Use Swift's latest features and protocol-oriented programming
|
- Use Swift's latest features and protocol-oriented programming
|
||||||
|
@@ -1,7 +1,5 @@
|
|||||||
---
|
---
|
||||||
description:
|
alwaysApply: false
|
||||||
globs:
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
---
|
||||||
# TCA Architecture Guidelines
|
# TCA Architecture Guidelines
|
||||||
- Use The Composable Architecture (TCA) for state management and side effect handling.
|
- Use The Composable Architecture (TCA) for state management and side effect handling.
|
||||||
|
@@ -5,8 +5,8 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "yana",
|
name: "yana",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v17),
|
.iOS(.v15),
|
||||||
.macOS(.v14)
|
.macOS(.v12)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.library(
|
.library(
|
||||||
|
@@ -10,7 +10,7 @@
|
|||||||
4C4C91092DE85A3E00384527 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */; };
|
4C4C91092DE85A3E00384527 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */; };
|
||||||
4C4C910B2DE85A4F00384527 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */; };
|
4C4C910B2DE85A4F00384527 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */; };
|
||||||
4C4C91302DE864F000384527 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 4C4C912F2DE864F000384527 /* ComposableArchitecture */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -24,13 +24,13 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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; };
|
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; };
|
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; };
|
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>"; };
|
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>"; };
|
||||||
D8529F57AF9337F626C670ED /* Pods_yana.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_yana.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -47,8 +47,6 @@
|
|||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = {
|
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = yanaAPITests;
|
path = yanaAPITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -68,9 +66,9 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
4C4C91092DE85A3E00384527 /* SystemConfiguration.framework in Frameworks */,
|
4C4C91092DE85A3E00384527 /* SystemConfiguration.framework in Frameworks */,
|
||||||
856EF8A28776CEF6CE595B76 /* Pods_yana.framework in Frameworks */,
|
|
||||||
4C4C91302DE864F000384527 /* ComposableArchitecture in Frameworks */,
|
4C4C91302DE864F000384527 /* ComposableArchitecture in Frameworks */,
|
||||||
4C4C910B2DE85A4F00384527 /* CoreFoundation.framework in Frameworks */,
|
4C4C910B2DE85A4F00384527 /* CoreFoundation.framework in Frameworks */,
|
||||||
|
DF26704F4C1F2ABA0405A0C3 /* Pods_yana.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -87,7 +85,6 @@
|
|||||||
4C3E65162DB61F7A00E5A455 = {
|
4C3E65162DB61F7A00E5A455 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
4C4C8FE72DE6F05300384527 /* tools */,
|
|
||||||
4C55BD992DB64C3C0021505D /* yana */,
|
4C55BD992DB64C3C0021505D /* yana */,
|
||||||
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */,
|
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */,
|
||||||
4C3E65202DB61F7A00E5A455 /* Products */,
|
4C3E65202DB61F7A00E5A455 /* Products */,
|
||||||
@@ -105,19 +102,12 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
4C4C8FE72DE6F05300384527 /* tools */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
);
|
|
||||||
path = tools;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
556C2003CCDA5AC2C56882D0 /* Frameworks */ = {
|
556C2003CCDA5AC2C56882D0 /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */,
|
4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */,
|
||||||
4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */,
|
4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */,
|
||||||
D8529F57AF9337F626C670ED /* Pods_yana.framework */,
|
E9DBA38268C435BE06F39AE2 /* Pods_yana.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -125,8 +115,8 @@
|
|||||||
87A8B7A8B4E2D53BA55B66D1 /* Pods */ = {
|
87A8B7A8B4E2D53BA55B66D1 /* Pods */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
0977E1E6E883533DD125CAD4 /* Pods-yana.debug.xcconfig */,
|
A5E34AF461FA7ADC3DFB5276 /* Pods-yana.debug.xcconfig */,
|
||||||
977CD030E95CB064179F3A1B /* Pods-yana.release.xcconfig */,
|
EB8184AF9789E3C79FC90DBB /* Pods-yana.release.xcconfig */,
|
||||||
);
|
);
|
||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -148,12 +138,12 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 4C3E652A2DB61F7B00E5A455 /* Build configuration list for PBXNativeTarget "yana" */;
|
buildConfigurationList = 4C3E652A2DB61F7B00E5A455 /* Build configuration list for PBXNativeTarget "yana" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
E0BDB6E67FEFE696E7D48CE4 /* [CP] Check Pods Manifest.lock */,
|
5EE242260A8B893DC4D6B6DD /* [CP] Check Pods Manifest.lock */,
|
||||||
4C4C90522DE6FCF700384527 /* Headers */,
|
4C4C90522DE6FCF700384527 /* Headers */,
|
||||||
4C3E651B2DB61F7A00E5A455 /* Sources */,
|
4C3E651B2DB61F7A00E5A455 /* Sources */,
|
||||||
4C3E651C2DB61F7A00E5A455 /* Frameworks */,
|
4C3E651C2DB61F7A00E5A455 /* Frameworks */,
|
||||||
4C3E651D2DB61F7A00E5A455 /* Resources */,
|
4C3E651D2DB61F7A00E5A455 /* Resources */,
|
||||||
80E012C82442392F08611AA3 /* [CP] Embed Pods Frameworks */,
|
A9AAC370C902C50E37521C40 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -213,6 +203,7 @@
|
|||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
"zh-Hans",
|
||||||
);
|
);
|
||||||
mainGroup = 4C3E65162DB61F7A00E5A455;
|
mainGroup = 4C3E65162DB61F7A00E5A455;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
@@ -248,24 +239,7 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
80E012C82442392F08611AA3 /* [CP] Embed Pods Frameworks */ = {
|
5EE242260A8B893DC4D6B6DD /* [CP] Check Pods Manifest.lock */ = {
|
||||||
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 */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
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";
|
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;
|
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 */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -438,10 +433,11 @@
|
|||||||
};
|
};
|
||||||
4C3E652B2DB61F7B00E5A455 /* Debug */ = {
|
4C3E652B2DB61F7B00E5A455 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = 0977E1E6E883533DD125CAD4 /* Pods-yana.debug.xcconfig */;
|
baseConfigurationReference = A5E34AF461FA7ADC3DFB5276 /* Pods-yana.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = yana/yana.entitlements;
|
CODE_SIGN_ENTITLEMENTS = yana/yana.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -462,11 +458,12 @@
|
|||||||
"\"${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers\"",
|
"\"${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers\"",
|
||||||
);
|
);
|
||||||
INFOPLIST_FILE = yana/Info.plist;
|
INFOPLIST_FILE = yana/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = EParti;
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "此App将可发现和连接到您所用网络上的设备。";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "此App将可发现和连接到您所用网络上的设备。";
|
||||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“MoliStar”需要您的同意,才可以进行定位服务,访问网络状态";
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“eparty”需要您的同意,才可以进行定位服务,访问网络状态";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||||
@@ -474,7 +471,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 20.20.61;
|
MARKETING_VERSION = 1.0.0;
|
||||||
OTHER_LIBTOOLFLAGS = "-framework \"SystemConfiguration\"";
|
OTHER_LIBTOOLFLAGS = "-framework \"SystemConfiguration\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yana.yana;
|
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yana.yana;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -491,10 +488,11 @@
|
|||||||
};
|
};
|
||||||
4C3E652C2DB61F7B00E5A455 /* Release */ = {
|
4C3E652C2DB61F7B00E5A455 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = 977CD030E95CB064179F3A1B /* Pods-yana.release.xcconfig */;
|
baseConfigurationReference = EB8184AF9789E3C79FC90DBB /* Pods-yana.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = yana/yana.entitlements;
|
CODE_SIGN_ENTITLEMENTS = yana/yana.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -515,11 +513,12 @@
|
|||||||
"\"${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers\"",
|
"\"${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers\"",
|
||||||
);
|
);
|
||||||
INFOPLIST_FILE = yana/Info.plist;
|
INFOPLIST_FILE = yana/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = EParti;
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "此App将可发现和连接到您所用网络上的设备。";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "此App将可发现和连接到您所用网络上的设备。";
|
||||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“MoliStar”需要您的同意,才可以进行定位服务,访问网络状态";
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“eparty”需要您的同意,才可以进行定位服务,访问网络状态";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||||
@@ -527,7 +526,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 20.20.61;
|
MARKETING_VERSION = 1.0.0;
|
||||||
OTHER_LIBTOOLFLAGS = "-framework \"SystemConfiguration\"";
|
OTHER_LIBTOOLFLAGS = "-framework \"SystemConfiguration\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yana.yana;
|
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yana.yana;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
@@ -164,5 +164,69 @@
|
|||||||
landmarkType = "7">
|
landmarkType = "7">
|
||||||
</BreakpointContent>
|
</BreakpointContent>
|
||||||
</BreakpointProxy>
|
</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>
|
</Breakpoints>
|
||||||
</Bucket>
|
</Bucket>
|
||||||
|
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
| 环境 | 地址 | 说明 |
|
| 环境 | 地址 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 生产环境 | `https://api.hfighting.com` | 正式服务器 |
|
| 生产环境 | `https://api.epartylive.com` | 正式服务器 |
|
||||||
| 测试环境 | `http://beta.api.molistar.xyz` | 开发测试服务器 |
|
| 测试环境 | `http://beta.api.molistar.xyz` | 开发测试服务器 |
|
||||||
| 图片服务 | `https://image.hfighting.com` | 静态资源服务器 |
|
| 图片服务 | `https://image.hfighting.com` | 静态资源服务器 |
|
||||||
|
|
||||||
@@ -177,4 +177,4 @@ YuMi iOS 项目的 API 架构设计了完整的网络请求体系,包含:
|
|||||||
- 🛠️ **开发支持**: 环境切换、错误追踪、调试日志
|
- 🛠️ **开发支持**: 环境切换、错误追踪、调试日志
|
||||||
- 🏗️ **架构清晰**: 模块化设计、统一管理、易于维护
|
- 🏗️ **架构清晰**: 模块化设计、统一管理、易于维护
|
||||||
|
|
||||||
这种设计确保了 API 请求的安全性、稳定性和高性能,为应用提供了可靠的网络服务基础。
|
这种设计确保了 API 请求的安全性、稳定性和高性能,为应用提供了可靠的网络服务基础。
|
||||||
|
@@ -3,17 +3,13 @@ import Foundation
|
|||||||
/// API 常量定义
|
/// API 常量定义
|
||||||
///
|
///
|
||||||
/// 集中管理 API 相关的常量值,包括:
|
/// 集中管理 API 相关的常量值,包括:
|
||||||
/// - 服务器地址
|
|
||||||
/// - 通用请求头
|
/// - 通用请求头
|
||||||
/// - API 端点路径
|
/// - API 端点路径
|
||||||
/// - 通用参数
|
/// - 通用参数
|
||||||
///
|
///
|
||||||
/// 注意:此文件与 APIConfiguration 有部分重复,
|
/// 注意:baseURL已统一到AppConfig中管理
|
||||||
/// 建议后续重构时统一到 APIConfiguration 中
|
/// 建议后续重构时统一到 APIConfiguration 中
|
||||||
enum APIConstants {
|
enum APIConstants {
|
||||||
// MARK: - Base URLs
|
|
||||||
/// 测试环境服务器地址
|
|
||||||
static let baseURL = "http://beta.api.molistar.xyz"
|
|
||||||
|
|
||||||
// MARK: - Common Headers
|
// MARK: - Common Headers
|
||||||
/// 通用请求头配置
|
/// 通用请求头配置
|
||||||
@@ -34,7 +30,7 @@ enum APIConstants {
|
|||||||
/// 客户端初始化接口
|
/// 客户端初始化接口
|
||||||
static let clientInit = "/client/init"
|
static let clientInit = "/client/init"
|
||||||
/// 用户登录接口
|
/// 用户登录接口
|
||||||
static let login = "/user/login"
|
static let login = "/oauth/token"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Common Parameters
|
// MARK: - Common Parameters
|
||||||
|
@@ -16,8 +16,12 @@ import Foundation
|
|||||||
enum APIEndpoint: String, CaseIterable {
|
enum APIEndpoint: String, CaseIterable {
|
||||||
case config = "/client/config"
|
case config = "/client/config"
|
||||||
case configInit = "/client/init"
|
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 {
|
var path: String {
|
||||||
return self.rawValue
|
return self.rawValue
|
||||||
@@ -39,10 +43,38 @@ enum APIEndpoint: String, CaseIterable {
|
|||||||
/// - 防止资源超限的保护机制
|
/// - 防止资源超限的保护机制
|
||||||
/// - 自动添加认证和设备信息头部
|
/// - 自动添加认证和设备信息头部
|
||||||
struct APIConfiguration {
|
struct APIConfiguration {
|
||||||
static let baseURL = "http://beta.api.molistar.xyz"
|
static var baseURL: String { AppConfig.baseURL }
|
||||||
static let timeout: TimeInterval = 30.0
|
static let timeout: TimeInterval = 30.0
|
||||||
static let maxDataSize: Int = 50 * 1024 * 1024 // 50MB 限制,防止资源超限
|
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 请求都需要的基础请求头,包括:
|
/// 返回所有 API 请求都需要的基础请求头,包括:
|
||||||
@@ -58,7 +90,8 @@ struct APIConfiguration {
|
|||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"Accept-Encoding": "gzip, br",
|
"Accept-Encoding": "gzip, br",
|
||||||
"Accept-Language": Locale.current.languageCode ?? "en",
|
"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(如果存在)
|
// 添加用户认证相关 headers(如果存在)
|
||||||
|
@@ -35,6 +35,10 @@ enum APIError: Error, Equatable {
|
|||||||
case httpError(statusCode: Int, message: String?)
|
case httpError(statusCode: Int, message: String?)
|
||||||
case timeout
|
case timeout
|
||||||
case resourceTooLarge
|
case resourceTooLarge
|
||||||
|
case encryptionFailed // 新增:加密失败
|
||||||
|
case invalidResponse // 新增:无效响应
|
||||||
|
case ticketFailed // 新增:票据获取失败
|
||||||
|
case custom(String) // 新增:自定义错误信息
|
||||||
case unknown(String)
|
case unknown(String)
|
||||||
|
|
||||||
var localizedDescription: String {
|
var localizedDescription: String {
|
||||||
@@ -53,6 +57,14 @@ enum APIError: Error, Equatable {
|
|||||||
return "请求超时"
|
return "请求超时"
|
||||||
case .resourceTooLarge:
|
case .resourceTooLarge:
|
||||||
return "响应数据过大"
|
return "响应数据过大"
|
||||||
|
case .encryptionFailed:
|
||||||
|
return "数据加密失败"
|
||||||
|
case .invalidResponse:
|
||||||
|
return "服务器响应无效"
|
||||||
|
case .ticketFailed:
|
||||||
|
return "获取会话票据失败"
|
||||||
|
case .custom(let message):
|
||||||
|
return message
|
||||||
case .unknown(let message):
|
case .unknown(let message):
|
||||||
return "未知错误: \(message)"
|
return "未知错误: \(message)"
|
||||||
}
|
}
|
||||||
@@ -118,7 +130,7 @@ struct BaseRequest: Codable {
|
|||||||
self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
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)
|
// 网络类型检测(WiFi=2, 蜂窝网络=1)
|
||||||
self.netType = NetworkTypeDetector.getCurrentNetworkType()
|
self.netType = NetworkTypeDetector.getCurrentNetworkType()
|
||||||
@@ -131,7 +143,7 @@ struct BaseRequest: Codable {
|
|||||||
|
|
||||||
// 渠道信息
|
// 渠道信息
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
self.channel = "TestFlight"
|
self.channel = "molistar_enterprise"
|
||||||
#else
|
#else
|
||||||
self.channel = "appstore"
|
self.channel = "appstore"
|
||||||
#endif
|
#endif
|
||||||
@@ -186,9 +198,10 @@ struct BaseRequest: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 按 key 升序排序并拼接
|
// 3. 按 key 升序排序并拼接
|
||||||
|
// 拼接格式 "key0=value0&key1=value1&key2=value2"
|
||||||
let sortedKeys = filteredParams.keys.sorted()
|
let sortedKeys = filteredParams.keys.sorted()
|
||||||
let paramString = sortedKeys.map { key in
|
let paramString = sortedKeys.map { key in
|
||||||
"\(key)=\(filteredParams[key] ?? "")"
|
"\(key)=\(String(describing: filteredParams[key] ?? ""))"
|
||||||
}.joined(separator: "&")
|
}.joined(separator: "&")
|
||||||
|
|
||||||
// 4. 添加密钥
|
// 4. 添加密钥
|
||||||
@@ -205,7 +218,7 @@ struct NetworkTypeDetector {
|
|||||||
static func getCurrentNetworkType() -> Int {
|
static func getCurrentNetworkType() -> Int {
|
||||||
// WiFi = 2, 蜂窝网络 = 1
|
// WiFi = 2, 蜂窝网络 = 1
|
||||||
// 这里是简化实现,实际应该检测网络状态
|
// 这里是简化实现,实际应该检测网络状态
|
||||||
return 1 // 默认蜂窝网络
|
return 2 // 默认蜂窝网络
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,16 +237,208 @@ struct CarrierInfoManager {
|
|||||||
|
|
||||||
// MARK: - User Info Manager (for Headers)
|
// MARK: - User Info Manager (for Headers)
|
||||||
struct UserInfoManager {
|
struct UserInfoManager {
|
||||||
static func getCurrentUserId() -> String? {
|
private static let userDefaults = UserDefaults.standard
|
||||||
// 从存储中获取当前用户 ID
|
|
||||||
// 实际实现应该从 AccountInfoStorage 或类似的地方获取
|
// MARK: - Storage Keys
|
||||||
return nil
|
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? {
|
static func getCurrentUserTicket() -> String? {
|
||||||
// 从存储中获取当前用户认证票据
|
return currentTicket
|
||||||
// 实际实现应该从 AccountInfoStorage 或类似的地方获取
|
}
|
||||||
return nil
|
|
||||||
|
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("❌ 无法更新 ticket:AccountModel 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accountModel.ticket = ticket
|
||||||
|
saveAccountModel(accountModel)
|
||||||
|
saveTicket(ticket) // 同时更新内存中的 ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否有有效的 AccountModel
|
||||||
|
/// - Returns: 是否存在有效的账户模型
|
||||||
|
static func hasValidAccountModel() -> Bool {
|
||||||
|
guard let accountModel = getAccountModel() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return accountModel.hasValidAuthentication
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除 AccountModel
|
||||||
|
static func clearAccountModel() {
|
||||||
|
userDefaults.removeObject(forKey: StorageKeys.accountModel)
|
||||||
|
userDefaults.synchronize()
|
||||||
|
print("🗑️ AccountModel 已清除")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +472,7 @@ protocol APIRequestProtocol {
|
|||||||
var queryParameters: [String: String]? { get }
|
var queryParameters: [String: String]? { get }
|
||||||
var bodyParameters: [String: Any]? { get }
|
var bodyParameters: [String: Any]? { get }
|
||||||
var headers: [String: String]? { get }
|
var headers: [String: String]? { get }
|
||||||
|
var customHeaders: [String: String]? { get } // 新增:自定义请求头
|
||||||
var timeout: TimeInterval { get }
|
var timeout: TimeInterval { get }
|
||||||
var includeBaseParameters: Bool { get }
|
var includeBaseParameters: Bool { get }
|
||||||
}
|
}
|
||||||
@@ -275,6 +481,7 @@ extension APIRequestProtocol {
|
|||||||
var timeout: TimeInterval { 30.0 }
|
var timeout: TimeInterval { 30.0 }
|
||||||
var includeBaseParameters: Bool { true }
|
var includeBaseParameters: Bool { true }
|
||||||
var headers: [String: String]? { nil }
|
var headers: [String: String]? { nil }
|
||||||
|
var customHeaders: [String: String]? { nil } // 新增:默认实现
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Generic API Response
|
// MARK: - Generic API Response
|
||||||
@@ -285,19 +492,5 @@ struct APIResponse<T: Codable>: Codable {
|
|||||||
let code: Int?
|
let code: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - String MD5 Extension
|
// 注意:String+MD5 扩展已移至 Utils/Extensions/String+MD5.swift
|
||||||
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
|
|
||||||
|
|
||||||
|
@@ -93,6 +93,11 @@ struct LiveAPIService: APIServiceProtocol {
|
|||||||
headers.merge(customHeaders) { _, new in new }
|
headers.merge(customHeaders) { _, new in new }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加自定义请求头支持
|
||||||
|
if let additionalHeaders = request.customHeaders {
|
||||||
|
headers.merge(additionalHeaders) { _, new in new }
|
||||||
|
}
|
||||||
|
|
||||||
for (key, value) in headers {
|
for (key, value) in headers {
|
||||||
urlRequest.setValue(value, forHTTPHeaderField: key)
|
urlRequest.setValue(value, forHTTPHeaderField: key)
|
||||||
}
|
}
|
||||||
|
423
yana/APIs/LoginModels.swift
Normal 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: DES加密后的用户ID/手机号
|
||||||
|
/// - password: DES加密后的密码
|
||||||
|
/// - clientSecret: 客户端密钥,固定为"uyzjdhds"
|
||||||
|
/// - version: 版本号,固定为"1"
|
||||||
|
/// - clientId: 客户端ID,固定为"erban-client"
|
||||||
|
/// - grantType: 授权类型,固定为"password"
|
||||||
|
init(phone: String, password: String, clientSecret: String = "uyzjdhds", version: String = "1", clientId: String = "erban-client", grantType: String = "password") {
|
||||||
|
self.queryParameters = [
|
||||||
|
"phone": phone,
|
||||||
|
"password": password,
|
||||||
|
"client_secret": clientSecret,
|
||||||
|
"version": version,
|
||||||
|
"client_id": clientId,
|
||||||
|
"grant_type": grantType
|
||||||
|
];
|
||||||
|
// self.bodyParameters = [
|
||||||
|
// "phone": phone,
|
||||||
|
// "password": password,
|
||||||
|
// "client_secret": clientSecret,
|
||||||
|
// "version": version,
|
||||||
|
// "client_id": clientId,
|
||||||
|
// "grant_type": grantType
|
||||||
|
// ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ID Login Response Model
|
||||||
|
struct IDLoginResponse: Codable, Equatable {
|
||||||
|
let status: String?
|
||||||
|
let message: String?
|
||||||
|
let code: Int?
|
||||||
|
let data: IDLoginData?
|
||||||
|
|
||||||
|
/// 是否登录成功
|
||||||
|
var isSuccess: Bool {
|
||||||
|
return code == 200 || status?.lowercased() == "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误消息(如果有)
|
||||||
|
var errorMessage: String {
|
||||||
|
return message ?? "登录失败,请重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ID Login Data Model
|
||||||
|
struct IDLoginData: Codable, Equatable {
|
||||||
|
let accessToken: String?
|
||||||
|
let refreshToken: String?
|
||||||
|
let tokenType: String?
|
||||||
|
let expiresIn: Int?
|
||||||
|
let scope: String?
|
||||||
|
let userInfo: UserInfo?
|
||||||
|
let uid: Int? // 修改:从String?改为Int?以匹配API返回
|
||||||
|
let netEaseToken: String? // 新增:网易云token
|
||||||
|
let jti: String? // 新增:JWT token identifier
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken = "access_token"
|
||||||
|
case refreshToken = "refresh_token"
|
||||||
|
case tokenType = "token_type"
|
||||||
|
case expiresIn = "expires_in"
|
||||||
|
case scope
|
||||||
|
case userInfo = "user_info"
|
||||||
|
case uid
|
||||||
|
case netEaseToken
|
||||||
|
case jti
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User Info Model
|
||||||
|
struct UserInfo: Codable, Equatable {
|
||||||
|
let userId: String?
|
||||||
|
let username: String?
|
||||||
|
let nickname: String?
|
||||||
|
let avatar: String?
|
||||||
|
let email: String?
|
||||||
|
let phone: String?
|
||||||
|
let status: String?
|
||||||
|
let createTime: String?
|
||||||
|
let updateTime: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case userId = "user_id"
|
||||||
|
case username
|
||||||
|
case nickname
|
||||||
|
case avatar
|
||||||
|
case email
|
||||||
|
case phone
|
||||||
|
case status
|
||||||
|
case createTime = "create_time"
|
||||||
|
case updateTime = "update_time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Login Helper
|
||||||
|
struct LoginHelper {
|
||||||
|
|
||||||
|
/// 创建ID登录请求
|
||||||
|
/// 这个方法会自动处理DES加密
|
||||||
|
/// - Parameters:
|
||||||
|
/// - userID: 原始用户ID
|
||||||
|
/// - password: 原始密码
|
||||||
|
/// - Returns: 配置好的API请求,如果加密失败返回nil
|
||||||
|
static func createIDLoginRequest(userID: String, password: String) -> IDLoginAPIRequest? {
|
||||||
|
// 使用DES加密ID和密码
|
||||||
|
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||||
|
|
||||||
|
guard let encryptedID = DESEncrypt.encryptUseDES(userID, key: encryptionKey),
|
||||||
|
let encryptedPassword = DESEncrypt.encryptUseDES(password, key: encryptionKey) else {
|
||||||
|
print("❌ DES加密失败")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
print("🔐 DES加密成功")
|
||||||
|
print(" 原始ID: \(userID)")
|
||||||
|
print(" 加密后ID: \(encryptedID)")
|
||||||
|
print(" 原始密码: \(password)")
|
||||||
|
print(" 加密后密码: \(encryptedPassword)")
|
||||||
|
|
||||||
|
return IDLoginAPIRequest(
|
||||||
|
phone: userID,
|
||||||
|
password: encryptedPassword
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ticket API Models
|
||||||
|
|
||||||
|
/// Ticket 请求结构体
|
||||||
|
struct TicketAPIRequest: APIRequestProtocol {
|
||||||
|
typealias Response = TicketResponse
|
||||||
|
|
||||||
|
let endpoint = "/oauth/ticket"
|
||||||
|
let method: HTTPMethod = .POST
|
||||||
|
let includeBaseParameters = true
|
||||||
|
let queryParameters: [String: String]?
|
||||||
|
let bodyParameters: [String: Any]? = nil
|
||||||
|
let timeout: TimeInterval = 30.0
|
||||||
|
let customHeaders: [String: String]?
|
||||||
|
|
||||||
|
/// 初始化 Ticket 请求
|
||||||
|
/// - Parameters:
|
||||||
|
/// - accessToken: OAuth 访问令牌
|
||||||
|
/// - issueType: 签发类型,固定为"multi"
|
||||||
|
/// - uid: 用户唯一标识,用于添加到请求头
|
||||||
|
init(accessToken: String, issueType: String = "multi", uid: Int? = nil) {
|
||||||
|
self.queryParameters = [
|
||||||
|
"access_token": accessToken,
|
||||||
|
"issue_type": issueType
|
||||||
|
]
|
||||||
|
|
||||||
|
// 设置自定义请求头
|
||||||
|
var headers: [String: String] = [:]
|
||||||
|
if let uid = uid {
|
||||||
|
headers["pub_uid"] = "\(uid)" // 转换为字符串
|
||||||
|
}
|
||||||
|
self.customHeaders = headers.isEmpty ? nil : headers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 响应结构体
|
||||||
|
struct TicketResponse: Codable, Equatable {
|
||||||
|
let code: Int?
|
||||||
|
let message: String?
|
||||||
|
let data: TicketData?
|
||||||
|
|
||||||
|
/// 是否获取成功
|
||||||
|
var isSuccess: Bool {
|
||||||
|
return code == 200
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误消息(如果有)
|
||||||
|
var errorMessage: String {
|
||||||
|
return message ?? "Ticket 获取失败,请重试"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Ticket 字符串
|
||||||
|
var ticket: String? {
|
||||||
|
return data?.tickets?.first?.ticket
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 数据结构体
|
||||||
|
struct TicketData: Codable, Equatable {
|
||||||
|
let tickets: [TicketInfo]?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ticket 信息结构体
|
||||||
|
struct TicketInfo: Codable, Equatable {
|
||||||
|
let ticket: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ticket Helper
|
||||||
|
struct TicketHelper {
|
||||||
|
|
||||||
|
/// 创建 Ticket 请求
|
||||||
|
/// - Parameters:
|
||||||
|
/// - accessToken: OAuth 访问令牌
|
||||||
|
/// - uid: 用户唯一标识
|
||||||
|
/// - Returns: 配置好的 Ticket API 请求
|
||||||
|
static func createTicketRequest(accessToken: String, uid: Int?) -> TicketAPIRequest {
|
||||||
|
return TicketAPIRequest(accessToken: accessToken, uid: uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调试打印 Ticket 请求信息
|
||||||
|
/// - Parameters:
|
||||||
|
/// - accessToken: OAuth 访问令牌
|
||||||
|
/// - uid: 用户唯一标识
|
||||||
|
static func debugTicketRequest(accessToken: String, uid: Int?) {
|
||||||
|
print("🎫 Ticket 请求调试信息")
|
||||||
|
print(" AccessToken: \(accessToken)")
|
||||||
|
print(" UID: \(uid?.description ?? "nil")")
|
||||||
|
print(" Endpoint: /oauth/ticket")
|
||||||
|
print(" Method: POST")
|
||||||
|
print(" Headers: pub_uid = \(uid?.description ?? "nil")")
|
||||||
|
print(" Parameters: access_token=\(accessToken), issue_type=multi")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 兼容旧的LoginResponse(如果需要)
|
||||||
|
typealias LoginResponse = IDLoginResponse
|
||||||
|
|
||||||
|
// MARK: - Email Verification Code Models
|
||||||
|
|
||||||
|
/// 邮箱验证码获取请求
|
||||||
|
struct EmailGetCodeRequest: APIRequestProtocol {
|
||||||
|
typealias Response = EmailGetCodeResponse
|
||||||
|
|
||||||
|
let endpoint = APIEndpoint.emailGetCode.path
|
||||||
|
let method: HTTPMethod = .POST
|
||||||
|
let includeBaseParameters = true
|
||||||
|
let queryParameters: [String: String]?
|
||||||
|
let bodyParameters: [String: Any]? = nil
|
||||||
|
let timeout: TimeInterval = 30.0
|
||||||
|
|
||||||
|
/// 初始化邮箱验证码获取请求
|
||||||
|
/// - Parameters:
|
||||||
|
/// - emailAddress: DES加密后的邮箱地址
|
||||||
|
/// - type: 验证码类型(1=注册/登录)
|
||||||
|
init(emailAddress: String, type: Int = 1) {
|
||||||
|
self.queryParameters = [
|
||||||
|
"emailAddress": emailAddress,
|
||||||
|
"type": String(type)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 邮箱验证码获取响应
|
||||||
|
struct EmailGetCodeResponse: Codable, Equatable {
|
||||||
|
let status: String?
|
||||||
|
let message: String?
|
||||||
|
let code: Int?
|
||||||
|
let data: String? // 通常为空,成功时只需要检查状态码
|
||||||
|
|
||||||
|
/// 是否发送成功
|
||||||
|
var isSuccess: Bool {
|
||||||
|
return code == 200 || status?.lowercased() == "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 错误消息(如果有)
|
||||||
|
var errorMessage: String {
|
||||||
|
return message ?? "验证码发送失败,请重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 邮箱验证码登录请求
|
||||||
|
struct EmailLoginRequest: APIRequestProtocol {
|
||||||
|
typealias Response = IDLoginResponse // 复用ID登录的响应模型
|
||||||
|
|
||||||
|
let endpoint = APIEndpoint.login.path
|
||||||
|
let method: HTTPMethod = .POST
|
||||||
|
let includeBaseParameters = true
|
||||||
|
let queryParameters: [String: String]?
|
||||||
|
let bodyParameters: [String: Any]? = nil
|
||||||
|
let timeout: TimeInterval = 30.0
|
||||||
|
|
||||||
|
/// 初始化邮箱验证码登录请求
|
||||||
|
/// - Parameters:
|
||||||
|
/// - email: DES加密后的邮箱地址
|
||||||
|
/// - code: 验证码
|
||||||
|
/// - clientSecret: 客户端密钥,固定为"uyzjdhds"
|
||||||
|
/// - version: 版本号,固定为"1"
|
||||||
|
/// - clientId: 客户端ID,固定为"erban-client"
|
||||||
|
/// - grantType: 授权类型,固定为"email"
|
||||||
|
init(email: String, code: String, clientSecret: String = "uyzjdhds", version: String = "1", clientId: String = "erban-client", grantType: String = "email") {
|
||||||
|
self.queryParameters = [
|
||||||
|
"email": email,
|
||||||
|
"code": code,
|
||||||
|
"client_secret": clientSecret,
|
||||||
|
"version": version,
|
||||||
|
"client_id": clientId,
|
||||||
|
"grant_type": grantType
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Email Login Helper
|
||||||
|
extension LoginHelper {
|
||||||
|
|
||||||
|
/// 创建邮箱验证码获取请求
|
||||||
|
/// - Parameter email: 原始邮箱地址
|
||||||
|
/// - Returns: 配置好的API请求,如果加密失败返回nil
|
||||||
|
static func createEmailGetCodeRequest(email: String) -> EmailGetCodeRequest? {
|
||||||
|
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||||
|
|
||||||
|
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
||||||
|
print("❌ 邮箱DES加密失败")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
print("🔐 邮箱DES加密成功")
|
||||||
|
print(" 原始邮箱: \(email)")
|
||||||
|
print(" 加密邮箱: \(encryptedEmail)")
|
||||||
|
|
||||||
|
return EmailGetCodeRequest(emailAddress: email, type: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建邮箱验证码登录请求
|
||||||
|
/// - Parameters:
|
||||||
|
/// - email: 原始邮箱地址
|
||||||
|
/// - code: 验证码
|
||||||
|
/// - Returns: 配置好的API请求,如果加密失败返回nil
|
||||||
|
static func createEmailLoginRequest(email: String, code: String) -> EmailLoginRequest? {
|
||||||
|
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||||
|
|
||||||
|
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
||||||
|
print("❌ 邮箱DES加密失败")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
print("🔐 邮箱验证码登录DES加密成功")
|
||||||
|
print(" 原始邮箱: \(email)")
|
||||||
|
print(" 加密邮箱: \(encryptedEmail)")
|
||||||
|
print(" 验证码: \(code)")
|
||||||
|
|
||||||
|
return EmailLoginRequest(email: encryptedEmail, code: code)
|
||||||
|
}
|
||||||
|
}
|
434
yana/APIs/email login flow.md
Normal 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. **日志记录**: 添加详细的操作日志
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
邮箱验证码登录流程是一个完整的用户认证系统,包含了界面展示、验证码获取、用户登录、数据存储等完整环节。该流程具有良好的安全性、用户体验和错误处理机制,符合现代移动应用的认证标准。
|
||||||
|
|
||||||
|
通过本文档,开发人员可以全面了解邮箱登录的实现细节,便于后续的功能扩展和维护工作。
|
1
yana/APIs/email login flow.svg
Normal file
After Width: | Height: | Size: 42 KiB |
262
yana/APIs/oauth flow.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# OAuth/Ticket 认证系统 API 文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本文档描述了 YuMi 应用中 OAuth 认证和 Ticket 会话管理的完整流程。系统采用两阶段认证机制:
|
||||||
|
1. **OAuth 阶段**:用户登录获取 `access_token`
|
||||||
|
2. **Ticket 阶段**:使用 `access_token` 获取业务会话 `ticket`
|
||||||
|
|
||||||
|
## 认证流程架构
|
||||||
|
|
||||||
|
### 核心组件
|
||||||
|
- **AccountInfoStorage**: 负责账户信息和 ticket 的本地存储
|
||||||
|
- **HttpRequestHelper**: 网络请求管理,自动添加认证头
|
||||||
|
- **Api+Login**: 登录相关 API 接口
|
||||||
|
- **Api+Main**: Ticket 获取相关 API 接口
|
||||||
|
|
||||||
|
### 认证数据模型
|
||||||
|
|
||||||
|
#### AccountModel
|
||||||
|
```objc
|
||||||
|
@interface AccountModel : PIBaseModel
|
||||||
|
@property (nonatomic, assign) NSString *uid; // 用户唯一标识
|
||||||
|
@property (nonatomic, copy) NSString *jti; // JWT ID
|
||||||
|
@property (nonatomic, copy) NSString *token_type; // Token 类型
|
||||||
|
@property (nonatomic, copy) NSString *refresh_token; // 刷新令牌
|
||||||
|
@property (nonatomic, copy) NSString *netEaseToken; // 网易云信令牌
|
||||||
|
@property (nonatomic, copy) NSString *access_token; // OAuth 访问令牌
|
||||||
|
@property (nonatomic, assign) NSNumber *expires_in; // 过期时间
|
||||||
|
@end
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口详情
|
||||||
|
|
||||||
|
### 1. OAuth 登录接口
|
||||||
|
|
||||||
|
#### 1.1 手机验证码登录
|
||||||
|
```objc
|
||||||
|
+ (void)loginWithCode:(HttpRequestHelperCompletion)completion
|
||||||
|
phone:(NSString *)phone
|
||||||
|
code:(NSString *)code
|
||||||
|
client_secret:(NSString *)client_secret
|
||||||
|
version:(NSString *)version
|
||||||
|
client_id:(NSString *)client_id
|
||||||
|
grant_type:(NSString *)grant_type
|
||||||
|
phoneAreaCode:(NSString *)phoneAreaCode;
|
||||||
|
```
|
||||||
|
|
||||||
|
**接口路径**: `POST /oauth/token`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
| 参数名 | 类型 | 必填 | 描述 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| phone | String | 是 | 手机号(DES加密) |
|
||||||
|
| code | String | 是 | 验证码 |
|
||||||
|
| client_secret | String | 是 | 客户端密钥,固定值:"uyzjdhds" |
|
||||||
|
| version | String | 是 | 版本号,固定值:"1" |
|
||||||
|
| client_id | String | 是 | 客户端ID,固定值:"erban-client" |
|
||||||
|
| grant_type | String | 是 | 授权类型,验证码登录为:"sms_code" |
|
||||||
|
| phoneAreaCode | String | 是 | 手机区号 |
|
||||||
|
|
||||||
|
**返回数据**: AccountModel 对象
|
||||||
|
|
||||||
|
#### 1.2 手机密码登录
|
||||||
|
```objc
|
||||||
|
+ (void)loginWithPassword:(HttpRequestHelperCompletion)completion
|
||||||
|
phone:(NSString *)phone
|
||||||
|
password:(NSString *)password
|
||||||
|
client_secret:(NSString *)client_secret
|
||||||
|
version:(NSString *)version
|
||||||
|
client_id:(NSString *)client_id
|
||||||
|
grant_type:(NSString *)grant_type;
|
||||||
|
```
|
||||||
|
|
||||||
|
**接口路径**: `POST /oauth/token`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
| 参数名 | 类型 | 必填 | 描述 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| phone | String | 是 | 手机号(DES加密) |
|
||||||
|
| password | String | 是 | 密码(DES加密) |
|
||||||
|
| client_secret | String | 是 | 客户端密钥 |
|
||||||
|
| version | String | 是 | 版本号 |
|
||||||
|
| client_id | String | 是 | 客户端ID |
|
||||||
|
| grant_type | String | 是 | 授权类型,密码登录为:"password" |
|
||||||
|
|
||||||
|
#### 1.3 第三方登录
|
||||||
|
```objc
|
||||||
|
+ (void)loginWithThirdPart:(HttpRequestHelperCompletion)completion
|
||||||
|
openid:(NSString *)openid
|
||||||
|
unionid:(NSString *)unionid
|
||||||
|
access_token:(NSString *)access_token
|
||||||
|
type:(NSString *)type;
|
||||||
|
```
|
||||||
|
|
||||||
|
**接口路径**: `POST /acc/third/login`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
| 参数名 | 类型 | 必填 | 描述 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| openid | String | 是 | 第三方平台用户唯一标识 |
|
||||||
|
| unionid | String | 是 | 第三方平台联合ID |
|
||||||
|
| access_token | String | 是 | 第三方平台访问令牌 |
|
||||||
|
| type | String | 是 | 第三方平台类型(1:Apple, 2:Facebook, 3:Google等) |
|
||||||
|
|
||||||
|
### 2. Ticket 获取接口
|
||||||
|
|
||||||
|
#### 2.1 获取 Ticket
|
||||||
|
```objc
|
||||||
|
+ (void)requestTicket:(HttpRequestHelperCompletion)completion
|
||||||
|
access_token:(NSString *)accessToken
|
||||||
|
issue_type:(NSString *)issueType;
|
||||||
|
```
|
||||||
|
|
||||||
|
**接口路径**: `POST /oauth/ticket`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
| 参数名 | 类型 | 必填 | 描述 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| access_token | String | 是 | OAuth 登录获取的访问令牌 |
|
||||||
|
| issue_type | String | 是 | 签发类型,固定值:"multi" |
|
||||||
|
|
||||||
|
**返回数据**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"tickets": [
|
||||||
|
{
|
||||||
|
"ticket": "eyJhbGciOiJIUzI1NiJ9..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. HTTP 请求头配置
|
||||||
|
|
||||||
|
所有业务 API 请求都会自动添加以下请求头:
|
||||||
|
|
||||||
|
```objc
|
||||||
|
// 在 HttpRequestHelper 中自动配置
|
||||||
|
- (void)setupHeader {
|
||||||
|
AFHTTPSessionManager *client = [HttpRequestHelper requestManager];
|
||||||
|
|
||||||
|
// 用户ID头
|
||||||
|
if ([[AccountInfoStorage instance] getUid].length > 0) {
|
||||||
|
[client.requestSerializer setValue:[[AccountInfoStorage instance] getUid]
|
||||||
|
forHTTPHeaderField:@"pub_uid"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket 认证头
|
||||||
|
if ([[AccountInfoStorage instance] getTicket].length > 0) {
|
||||||
|
[client.requestSerializer setValue:[[AccountInfoStorage instance] getTicket]
|
||||||
|
forHTTPHeaderField:@"pub_ticket"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他公共头
|
||||||
|
[client.requestSerializer setValue:[NSBundle uploadLanguageText]
|
||||||
|
forHTTPHeaderField:@"Accept-Language"];
|
||||||
|
[client.requestSerializer setValue:PI_App_Version
|
||||||
|
forHTTPHeaderField:@"App-Version"];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
### 完整登录流程示例
|
||||||
|
|
||||||
|
```objc
|
||||||
|
// 1. 用户登录获取 access_token
|
||||||
|
[Api loginWithCode:^(BaseModel * _Nullable data, NSInteger code, NSString * _Nullable msg) {
|
||||||
|
if (code == 200) {
|
||||||
|
// 保存账户信息
|
||||||
|
AccountModel *accountModel = [AccountModel modelWithDictionary:data.data];
|
||||||
|
[[AccountInfoStorage instance] saveAccountInfo:accountModel];
|
||||||
|
|
||||||
|
// 2. 使用 access_token 获取 ticket
|
||||||
|
[Api requestTicket:^(BaseModel * _Nullable data, NSInteger code, NSString * _Nullable msg) {
|
||||||
|
if (code == 200) {
|
||||||
|
NSArray *tickets = [data.data valueForKey:@"tickets"];
|
||||||
|
NSString *ticket = [tickets[0] valueForKey:@"ticket"];
|
||||||
|
|
||||||
|
// 保存 ticket
|
||||||
|
[[AccountInfoStorage instance] saveTicket:ticket];
|
||||||
|
|
||||||
|
// 3. 登录成功,可以进行业务操作
|
||||||
|
[self navigateToMainPage];
|
||||||
|
}
|
||||||
|
} access_token:accountModel.access_token issue_type:@"multi"];
|
||||||
|
}
|
||||||
|
} phone:encryptedPhone
|
||||||
|
code:verificationCode
|
||||||
|
client_secret:@"uyzjdhds"
|
||||||
|
version:@"1"
|
||||||
|
client_id:@"erban-client"
|
||||||
|
grant_type:@"sms_code"
|
||||||
|
phoneAreaCode:areaCode];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自动登录流程
|
||||||
|
|
||||||
|
```objc
|
||||||
|
- (void)autoLogin {
|
||||||
|
// 检查本地是否有账户信息
|
||||||
|
AccountModel *accountModel = [[AccountInfoStorage instance] getCurrentAccountInfo];
|
||||||
|
if (accountModel == nil || accountModel.access_token == nil) {
|
||||||
|
[self tokenInvalid]; // 跳转到登录页
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有有效的 ticket
|
||||||
|
if ([[AccountInfoStorage instance] getTicket].length > 0) {
|
||||||
|
[[self getView] autoLoginSuccess];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 access_token 重新获取 ticket
|
||||||
|
[Api requestTicket:^(BaseModel * _Nonnull data) {
|
||||||
|
NSArray *tickets = [data.data valueForKey:@"tickets"];
|
||||||
|
NSString *ticket = [tickets[0] valueForKey:@"ticket"];
|
||||||
|
[[AccountInfoStorage instance] saveTicket:ticket];
|
||||||
|
[[self getView] autoLoginSuccess];
|
||||||
|
} fail:^(NSInteger code, NSString * _Nullable msg) {
|
||||||
|
[self logout]; // ticket 获取失败,重新登录
|
||||||
|
} access_token:accountModel.access_token issue_type:@"multi"];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 401 未授权错误
|
||||||
|
当接收到 401 状态码时,系统会自动处理:
|
||||||
|
|
||||||
|
```objc
|
||||||
|
// 在 HttpRequestHelper 中
|
||||||
|
if (response && response.statusCode == 401) {
|
||||||
|
failure(response.statusCode, YMLocalizedString(@"HttpRequestHelper7"));
|
||||||
|
// 通常需要重新登录
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ticket 过期处理
|
||||||
|
- Ticket 过期时服务器返回 401 错误
|
||||||
|
- 客户端应该使用保存的 `access_token` 重新获取 ticket
|
||||||
|
- 如果 `access_token` 也过期,则需要用户重新登录
|
||||||
|
|
||||||
|
## 安全注意事项
|
||||||
|
|
||||||
|
1. **数据加密**: 敏感信息(手机号、密码)使用 DES 加密传输
|
||||||
|
2. **本地存储**:
|
||||||
|
- `access_token` 存储在文件系统中
|
||||||
|
- `ticket` 存储在内存中,应用重启需重新获取
|
||||||
|
3. **请求头**: 所有业务请求自动携带 `pub_uid` 和 `pub_ticket` 头
|
||||||
|
4. **错误处理**: 建立完善的 401 错误重试机制
|
||||||
|
|
||||||
|
## 相关文件
|
||||||
|
|
||||||
|
- `YuMi/Structure/MVP/Model/AccountInfoStorage.h/m` - 账户信息存储管理
|
||||||
|
- `YuMi/Modules/YMLogin/Api/Api+Login.h/m` - 登录相关接口
|
||||||
|
- `YuMi/Modules/YMTabbar/Api/Api+Main.h/m` - Ticket 获取接口
|
||||||
|
- `YuMi/Network/HttpRequestHelper.h/m` - 网络请求管理
|
||||||
|
- `YuMi/Structure/MVP/Model/AccountModel.h/m` - 账户数据模型
|
1
yana/APIs/oauth flow.svg
Normal file
After Width: | Height: | Size: 31 KiB |
@@ -1,5 +1,5 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import NIMSDK
|
//import NIMSDK
|
||||||
|
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
@@ -11,20 +11,67 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// 网络诊断
|
// 🔍 DES加密已切换到OC版本
|
||||||
let testURL = URL(string: "http://beta.api.molistar.xyz/client/init")!
|
// print("🔐 使用OC版本的DES加密")
|
||||||
let request = URLRequest(url: testURL)
|
// DESEncryptOCTest.runInAppDelegate()
|
||||||
|
|
||||||
print("🛠 原生URLSession测试开始")
|
// 网络诊断 - 使用完整的登录参数测试
|
||||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
// let testURL = URL(string: "http://192.168.10.211:8080/oauth/token")!
|
||||||
print("""
|
// var request = URLRequest(url: testURL)
|
||||||
=== 网络诊断结果 ===
|
// request.httpMethod = "POST"
|
||||||
响应状态码: \((response as? HTTPURLResponse)?.statusCode ?? -1)
|
// request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
错误信息: \(error?.localizedDescription ?? "无")
|
// request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
原始数据: \(data?.count ?? 0) bytes
|
// request.setValue("zh-Hant", forHTTPHeaderField: "Accept-Language")
|
||||||
==================
|
//
|
||||||
""")
|
// // 添加完整的测试参数
|
||||||
}.resume()
|
// 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
|
#endif
|
||||||
|
|
||||||
// NIMConfigurationManager.setupNimSDK()
|
// NIMConfigurationManager.setupNimSDK()
|
||||||
|
6
yana/Assets.xcassets/Login/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
21
yana/Assets.xcassets/Login/bg.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "bg@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
BIN
yana/Assets.xcassets/Login/bg.imageset/bg@3x.png
vendored
Normal file
After Width: | Height: | Size: 1.2 MiB |
21
yana/Assets.xcassets/Login/email icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "切图 65@3x (1).png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
BIN
yana/Assets.xcassets/Login/email icon.imageset/切图 65@3x (1).png
vendored
Normal file
After Width: | Height: | Size: 2.2 KiB |
21
yana/Assets.xcassets/Login/id icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "切图 65@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
BIN
yana/Assets.xcassets/Login/id icon.imageset/切图 65@3x.png
vendored
Normal file
After Width: | Height: | Size: 3.1 KiB |
21
yana/Assets.xcassets/Login/logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "logo@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
BIN
yana/Assets.xcassets/Login/logo.imageset/logo@3x.png
vendored
Normal file
After Width: | Height: | Size: 113 KiB |
21
yana/Assets.xcassets/Login/selected icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "勾选@3x (1).png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
BIN
yana/Assets.xcassets/Login/selected icon.imageset/勾选@3x (1).png
vendored
Normal file
After Width: | Height: | Size: 2.2 KiB |
21
yana/Assets.xcassets/Login/top.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "top@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
BIN
yana/Assets.xcassets/Login/top.imageset/top@3x.png
vendored
Normal file
After Width: | Height: | Size: 379 KiB |
21
yana/Assets.xcassets/Login/unselected icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "勾选@3x.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
BIN
yana/Assets.xcassets/Login/unselected icon.imageset/勾选@3x.png
vendored
Normal file
After Width: | Height: | Size: 1.6 KiB |
@@ -15,9 +15,22 @@ struct AppConfig {
|
|||||||
static var baseURL: String {
|
static var baseURL: String {
|
||||||
switch current {
|
switch current {
|
||||||
case .development:
|
case .development:
|
||||||
|
// return "http://192.168.10.211:8080"
|
||||||
return "http://beta.api.molistar.xyz"
|
return "http://beta.api.molistar.xyz"
|
||||||
case .production:
|
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
|
current = env
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加调试配置
|
// 网络调试配置
|
||||||
static var enableNetworkDebug: Bool {
|
static var enableNetworkDebug: Bool {
|
||||||
#if DEBUG
|
switch current {
|
||||||
return true
|
case .development:
|
||||||
#else
|
return true
|
||||||
return false
|
case .production:
|
||||||
#endif
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加服务器信任配置
|
// 服务器信任配置
|
||||||
static var serverTrustPolicies: [String: ServerTrustEvaluating] {
|
static var serverTrustPolicies: [String: ServerTrustEvaluating] {
|
||||||
#if DEBUG
|
switch current {
|
||||||
return ["beta.api.molistar.xyz": DisabledTrustEvaluator()]
|
case .development:
|
||||||
#else
|
return ["beta.api.molistar.xyz": DisabledTrustEvaluator()]
|
||||||
return ["api.hfighting.com": PublicKeysTrustEvaluator()]
|
case .production:
|
||||||
#endif
|
return ["api.epartylive.com": PublicKeysTrustEvaluator()]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static var networkDebugEnabled: Bool {
|
static var networkDebugEnabled: Bool {
|
||||||
#if DEBUG
|
switch current {
|
||||||
return true
|
case .development:
|
||||||
#else
|
return true
|
||||||
return false
|
case .production:
|
||||||
#endif
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -36,53 +36,51 @@ struct ContentView: View {
|
|||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
WithPerceptionTracking {
|
||||||
// 原有登录界面
|
TabView(selection: $selectedTab) {
|
||||||
VStack {
|
// 原有登录界面
|
||||||
// 日志级别选择器
|
VStack {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
// 日志级别选择器
|
||||||
Text("日志级别:")
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.font(.headline)
|
Text("日志级别:")
|
||||||
.foregroundColor(.primary)
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
Picker("日志级别", selection: $selectedLogLevel) {
|
|
||||||
Text("无日志").tag(APILogger.LogLevel.none)
|
Picker("日志级别", selection: $selectedLogLevel) {
|
||||||
Text("基础日志").tag(APILogger.LogLevel.basic)
|
Text("无日志").tag(APILogger.LogLevel.none)
|
||||||
Text("详细日志").tag(APILogger.LogLevel.detailed)
|
Text("基础日志").tag(APILogger.LogLevel.basic)
|
||||||
|
Text("详细日志").tag(APILogger.LogLevel.detailed)
|
||||||
|
}
|
||||||
|
.pickerStyle(SegmentedPickerStyle())
|
||||||
}
|
}
|
||||||
.pickerStyle(SegmentedPickerStyle())
|
.padding()
|
||||||
}
|
.background(Color.gray.opacity(0.1))
|
||||||
.padding()
|
.cornerRadius(10)
|
||||||
.background(Color.gray.opacity(0.1))
|
|
||||||
.cornerRadius(10)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
VStack(spacing: 20) {
|
|
||||||
Text("yana")
|
|
||||||
.font(.largeTitle)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
|
|
||||||
VStack(spacing: 15) {
|
Spacer()
|
||||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
|
||||||
TextField("账号", text: viewStore.binding(
|
VStack(spacing: 20) {
|
||||||
get: \.account,
|
Text("eparty")
|
||||||
send: { LoginFeature.Action.updateAccount($0) }
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
|
||||||
|
VStack(spacing: 15) {
|
||||||
|
TextField("账号", text: Binding(
|
||||||
|
get: { store.account },
|
||||||
|
set: { store.send(.updateAccount($0)) }
|
||||||
))
|
))
|
||||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
|
|
||||||
SecureField("密码", text: viewStore.binding(
|
SecureField("密码", text: Binding(
|
||||||
get: \.password,
|
get: { store.password },
|
||||||
send: { LoginFeature.Action.updatePassword($0) }
|
set: { store.send(.updatePassword($0)) }
|
||||||
))
|
))
|
||||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||||
}
|
}
|
||||||
}
|
.padding(.horizontal)
|
||||||
.padding(.horizontal)
|
|
||||||
|
if let error = store.error {
|
||||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
|
||||||
if let error = viewStore.error {
|
|
||||||
Text(error)
|
Text(error)
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -92,114 +90,112 @@ struct ContentView: View {
|
|||||||
|
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewStore.send(.login)
|
store.send(.login)
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
if viewStore.isLoading {
|
if store.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.scaleEffect(0.8)
|
.scaleEffect(0.8)
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
}
|
}
|
||||||
Text(viewStore.isLoading ? "登录中..." : "登录")
|
Text(store.isLoading ? "登录中..." : "登录")
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
.background(viewStore.isLoading ? Color.gray : Color.blue)
|
.background(store.isLoading ? Color.gray : Color.blue)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.cornerRadius(10)
|
.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: {
|
||||||
Button(action: {
|
initStore.send(.initialize)
|
||||||
initViewStore.send(.initialize)
|
}) {
|
||||||
}) {
|
HStack {
|
||||||
HStack {
|
if initStore.isLoading {
|
||||||
if initViewStore.isLoading {
|
ProgressView()
|
||||||
ProgressView()
|
.scaleEffect(0.8)
|
||||||
.scaleEffect(0.8)
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
|
||||||
}
|
|
||||||
Text(initViewStore.isLoading ? "测试中..." : "测试初始化")
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
Text(initStore.isLoading ? "测试中..." : "测试初始化")
|
||||||
.padding()
|
|
||||||
.background(initViewStore.isLoading ? Color.gray : Color.green)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
}
|
||||||
.disabled(initViewStore.isLoading)
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
// API 测试结果显示区域
|
.background(initStore.isLoading ? Color.gray : Color.green)
|
||||||
if let response = initViewStore.response {
|
.foregroundColor(.white)
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
.cornerRadius(10)
|
||||||
HStack {
|
}
|
||||||
Text("API 测试结果:")
|
.disabled(initStore.isLoading)
|
||||||
.font(.headline)
|
|
||||||
.foregroundColor(.primary)
|
// API 测试结果显示区域
|
||||||
}
|
if let response = initStore.response {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ScrollView {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Text("API 测试结果:")
|
||||||
Text("状态: \(response.status)")
|
.font(.headline)
|
||||||
if let message = response.message {
|
.foregroundColor(.primary)
|
||||||
Text("消息: \(message)")
|
}
|
||||||
}
|
|
||||||
if let data = response.data {
|
ScrollView {
|
||||||
Text("版本: \(data.version ?? "未知")")
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("时间戳: \(data.timestamp ?? 0)")
|
Text("状态: \(response.status)")
|
||||||
if let config = data.config {
|
if let message = response.message {
|
||||||
Text("配置:")
|
Text("消息: \(message)")
|
||||||
ForEach(Array(config.keys), id: \.self) { key in
|
}
|
||||||
Text(" \(key): \(config[key] ?? "")")
|
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()
|
.frame(maxHeight: 200)
|
||||||
.background(Color.gray.opacity(0.05))
|
|
||||||
.cornerRadius(10)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let error = initViewStore.error {
|
|
||||||
Text(error)
|
|
||||||
.foregroundColor(.red)
|
|
||||||
.font(.caption)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
|
.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()
|
||||||
}
|
}
|
||||||
|
.padding()
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.tabItem {
|
|
||||||
Label("登录", systemImage: "person.circle")
|
|
||||||
}
|
|
||||||
.tag(0)
|
|
||||||
|
|
||||||
// 新的 API 配置测试界面
|
|
||||||
ConfigView(store: configStore)
|
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("API 测试", systemImage: "network")
|
Label("登录", systemImage: "person.circle")
|
||||||
}
|
}
|
||||||
.tag(1)
|
.tag(0)
|
||||||
}
|
|
||||||
.onChange(of: selectedLogLevel) { newValue in
|
// 新的 API 配置测试界面
|
||||||
APILogger.logLevel = newValue
|
ConfigView(store: configStore)
|
||||||
|
.tabItem {
|
||||||
|
Label("API 测试", systemImage: "network")
|
||||||
|
}
|
||||||
|
.tag(1)
|
||||||
|
}
|
||||||
|
.onChange(of: selectedLogLevel) { newValue in
|
||||||
|
APILogger.logLevel = newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -5,7 +5,7 @@ struct ConfigView: View {
|
|||||||
let store: StoreOf<ConfigFeature>
|
let store: StoreOf<ConfigFeature>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
WithPerceptionTracking {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// 标题
|
// 标题
|
||||||
@@ -16,7 +16,7 @@ struct ConfigView: View {
|
|||||||
|
|
||||||
// 状态显示
|
// 状态显示
|
||||||
Group {
|
Group {
|
||||||
if viewStore.isLoading {
|
if store.isLoading {
|
||||||
VStack {
|
VStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.scaleEffect(1.5)
|
.scaleEffect(1.5)
|
||||||
@@ -26,7 +26,7 @@ struct ConfigView: View {
|
|||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.frame(height: 100)
|
.frame(height: 100)
|
||||||
} else if let errorMessage = viewStore.errorMessage {
|
} else if let errorMessage = store.errorMessage {
|
||||||
VStack {
|
VStack {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
@@ -43,13 +43,13 @@ struct ConfigView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
Button("清除错误") {
|
Button("清除错误") {
|
||||||
viewStore.send(.clearError)
|
store.send(.clearError)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
}
|
}
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
} else if let configData = viewStore.configData {
|
} else if let configData = store.configData {
|
||||||
// 配置数据显示
|
// 配置数据显示
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
@@ -102,7 +102,7 @@ struct ConfigView: View {
|
|||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let lastUpdated = viewStore.lastUpdated {
|
if let lastUpdated = store.lastUpdated {
|
||||||
Text("最后更新: \(lastUpdated, style: .time)")
|
Text("最后更新: \(lastUpdated, style: .time)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@@ -130,21 +130,21 @@ struct ConfigView: View {
|
|||||||
// 操作按钮
|
// 操作按钮
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewStore.send(.loadConfig)
|
store.send(.loadConfig)
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
if viewStore.isLoading {
|
if store.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
.scaleEffect(0.8)
|
.scaleEffect(0.8)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
Text(viewStore.isLoading ? "加载中..." : "加载配置")
|
Text(store.isLoading ? "加载中..." : "加载配置")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(viewStore.isLoading)
|
.disabled(store.isLoading)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: 50)
|
.frame(height: 50)
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ struct ConfigView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
|
188
yana/Features/EMailLoginFeature.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
70
yana/Features/HomeFeature.swift
Normal 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")
|
||||||
|
}
|
215
yana/Features/IDLoginFeature.swift
Normal 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
|
||||||
|
|
||||||
|
// 实现真实的ID登录API调用
|
||||||
|
return .run { send in
|
||||||
|
do {
|
||||||
|
// 使用LoginHelper创建加密的登录请求
|
||||||
|
guard let loginRequest = LoginHelper.createIDLoginRequest(userID: userID, password: password) else {
|
||||||
|
await send(.loginResponse(.failure(APIError.decodingError("加密失败"))))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发起登录请求
|
||||||
|
let response = try await apiService.request(loginRequest)
|
||||||
|
await send(.loginResponse(.success(response)))
|
||||||
|
} catch {
|
||||||
|
if let apiError = error as? APIError {
|
||||||
|
await send(.loginResponse(.failure(apiError)))
|
||||||
|
} else {
|
||||||
|
await send(.loginResponse(.failure(APIError.unknown(error.localizedDescription))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case .forgotPasswordTapped:
|
||||||
|
// TODO: 处理忘记密码
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .backButtonTapped:
|
||||||
|
// 由父级处理返回逻辑
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case let .loginResponse(.success(response)):
|
||||||
|
state.isLoading = false
|
||||||
|
if response.isSuccess {
|
||||||
|
// OAuth 认证成功,清除错误信息
|
||||||
|
state.errorMessage = nil
|
||||||
|
|
||||||
|
// 从响应数据创建 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,12 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ComposableArchitecture
|
import ComposableArchitecture
|
||||||
|
|
||||||
struct LoginResponse: Codable, Equatable {
|
|
||||||
let status: String
|
|
||||||
let message: String?
|
|
||||||
let token: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
@Reducer
|
@Reducer
|
||||||
struct LoginFeature {
|
struct LoginFeature {
|
||||||
@ObservableState
|
@ObservableState
|
||||||
@@ -15,69 +9,214 @@ struct LoginFeature {
|
|||||||
var password: String = ""
|
var password: String = ""
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var error: String?
|
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
|
#if DEBUG
|
||||||
init() {
|
init() {
|
||||||
self.account = "3184"
|
// 移除测试用的硬编码凭据
|
||||||
self.password = "a0d5da073d14731cc7a01ecaa17b9174"
|
self.account = ""
|
||||||
|
self.password = ""
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Action: Equatable {
|
enum Action {
|
||||||
case updateAccount(String)
|
case updateAccount(String)
|
||||||
case updatePassword(String)
|
case updatePassword(String)
|
||||||
case login
|
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> {
|
var body: some ReducerOf<Self> {
|
||||||
// Reduce { state, action in
|
Scope(state: \.idLoginState, action: \.idLogin) {
|
||||||
// switch action {
|
IDLoginFeature()
|
||||||
// case let .updateAccount(account):
|
}
|
||||||
// state.account = account
|
|
||||||
// return .none
|
Scope(state: \.emailLoginState, action: \.emailLogin) {
|
||||||
//
|
EMailLoginFeature()
|
||||||
// case let .updatePassword(password):
|
}
|
||||||
// state.password = password
|
|
||||||
// return .none
|
Reduce { state, action in
|
||||||
//
|
switch action {
|
||||||
// case .login:
|
case let .updateAccount(account):
|
||||||
// state.isLoading = true
|
state.account = account
|
||||||
// state.error = nil
|
return .none
|
||||||
//
|
|
||||||
// let loginBody = [
|
case let .updatePassword(password):
|
||||||
// "account": state.account,
|
state.password = password
|
||||||
// "password": state.password
|
return .none
|
||||||
// ]
|
|
||||||
//
|
case .login:
|
||||||
// return .run { send in
|
state.isLoading = true
|
||||||
// do {
|
state.error = nil
|
||||||
// let response: LoginResponse = try await APIClientManager.shared.post(
|
state.ticketError = nil
|
||||||
// path: APIConstants.Endpoints.login,
|
state.loginStep = .authenticating
|
||||||
// body: loginBody,
|
|
||||||
// headers: APIConstants.defaultHeaders
|
// 实现登录逻辑(使用account和password)
|
||||||
// )
|
return .run { [account = state.account, password = state.password] send in
|
||||||
// await send(.loginResponse(.success(response)))
|
do {
|
||||||
// } catch {
|
// 使用LoginHelper创建加密的登录请求
|
||||||
// await send(.loginResponse(.failure(error)))
|
guard let loginRequest = LoginHelper.createIDLoginRequest(userID: account, password: password) else {
|
||||||
// }
|
await send(.loginResponse(.failure(APIError.decodingError("加密失败"))))
|
||||||
// }
|
return
|
||||||
//
|
}
|
||||||
// case let .loginResponse(.success(response)):
|
|
||||||
// state.isLoading = false
|
// 发起登录请求
|
||||||
// if response.status == "success" {
|
let response = try await apiService.request(loginRequest)
|
||||||
// // TODO: 处理登录成功,保存 token 等
|
await send(.loginResponse(.success(response)))
|
||||||
// } else {
|
} catch {
|
||||||
// state.error = response.message ?? "登录失败"
|
if let apiError = error as? APIError {
|
||||||
// }
|
await send(.loginResponse(.failure(apiError)))
|
||||||
// return .none
|
} else {
|
||||||
//
|
await send(.loginResponse(.failure(APIError.unknown(error.localizedDescription))))
|
||||||
// case let .loginResponse(.failure(error)):
|
}
|
||||||
// state.isLoading = false
|
}
|
||||||
// state.error = error.localizedDescription
|
}
|
||||||
// return .none
|
|
||||||
// }
|
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:
|
||||||
|
// IDLogin动作由子feature处理
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .emailLogin:
|
||||||
|
// EmailLogin动作由子feature处理
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
281
yana/Features/RecoverPasswordFeature.swift
Normal 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: 配置好的API请求,如果加密失败返回nil
|
||||||
|
static func createEmailGetCodeRequest(email: String) -> EmailGetCodeRequest? {
|
||||||
|
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||||
|
|
||||||
|
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
||||||
|
print("❌ 邮箱DES加密失败")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
print("🔐 密码恢复邮箱DES加密成功")
|
||||||
|
print(" 原始邮箱: \(email)")
|
||||||
|
print(" 加密邮箱: \(encryptedEmail)")
|
||||||
|
|
||||||
|
// 使用type=3表示密码重置验证码
|
||||||
|
return EmailGetCodeRequest(emailAddress: email, type: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建密码重置请求
|
||||||
|
/// - Parameters:
|
||||||
|
/// - email: 原始邮箱地址
|
||||||
|
/// - code: 验证码
|
||||||
|
/// - newPassword: 新密码
|
||||||
|
/// - Returns: 配置好的API请求,如果加密失败返回nil
|
||||||
|
static func createResetPasswordRequest(email: String, code: String, newPassword: String) -> ResetPasswordRequest? {
|
||||||
|
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||||
|
|
||||||
|
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey),
|
||||||
|
let encryptedPassword = DESEncrypt.encryptUseDES(newPassword, key: encryptionKey) else {
|
||||||
|
print("❌ 密码重置DES加密失败")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
print("🔐 密码重置DES加密成功")
|
||||||
|
print(" 原始邮箱: \(email)")
|
||||||
|
print(" 加密邮箱: \(encryptedEmail)")
|
||||||
|
print(" 验证码: \(code)")
|
||||||
|
print(" 原始新密码: \(newPassword)")
|
||||||
|
print(" 加密新密码: \(encryptedPassword)")
|
||||||
|
|
||||||
|
return ResetPasswordRequest(
|
||||||
|
email: email,
|
||||||
|
code: code,
|
||||||
|
newPwd: encryptedPassword // 参数名改为newPwd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
39
yana/Features/SplashFeature.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
import ComposableArchitecture
|
||||||
|
|
||||||
|
@Reducer
|
||||||
|
struct SplashFeature {
|
||||||
|
@ObservableState
|
||||||
|
struct State: Equatable {
|
||||||
|
var isLoading = true
|
||||||
|
var shouldShowMainApp = false
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Action: Equatable {
|
||||||
|
case onAppear
|
||||||
|
case splashFinished
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some ReducerOf<Self> {
|
||||||
|
Reduce { state, action in
|
||||||
|
switch action {
|
||||||
|
case .onAppear:
|
||||||
|
state.isLoading = true
|
||||||
|
state.shouldShowMainApp = false
|
||||||
|
|
||||||
|
// 1秒延迟后显示主应用 (iOS 15.5+ 兼容)
|
||||||
|
return .run { send in
|
||||||
|
try await Task.sleep(nanoseconds: 1_000_000_000) // 1秒 = 1,000,000,000 纳秒
|
||||||
|
await send(.splashFinished)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .splashFinished:
|
||||||
|
state.isLoading = false
|
||||||
|
state.shouldShowMainApp = true
|
||||||
|
// 发送通知
|
||||||
|
NotificationCenter.default.post(name: .splashFinished, object: nil)
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
BIN
yana/Fonts/Bayon-Regular.ttf
Normal file
64
yana/Fonts/README.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 字体文件使用指南
|
||||||
|
|
||||||
|
## 字体文件位置
|
||||||
|
请将 **Bayon-Regular.ttf** 字体文件放置在此文件夹中。
|
||||||
|
|
||||||
|
## 添加步骤
|
||||||
|
|
||||||
|
### 1. 获取字体文件
|
||||||
|
- 从 Google Fonts 下载 Bayon 字体:https://fonts.google.com/specimen/Bayon
|
||||||
|
- 或从设计师提供的字体文件中获取 `Bayon-Regular.ttf`
|
||||||
|
|
||||||
|
### 2. 添加到项目
|
||||||
|
1. 将 `Bayon-Regular.ttf` 文件拖放到此 `Fonts` 文件夹中
|
||||||
|
2. 在 Xcode 中,确保文件被添加到项目的 Target 中
|
||||||
|
3. 检查 `Info.plist` 中已经配置了 `UIAppFonts` 数组
|
||||||
|
|
||||||
|
### 3. 验证字体是否正确加载
|
||||||
|
在 `AppDelegate.swift` 中添加调试代码:
|
||||||
|
```swift
|
||||||
|
#if DEBUG
|
||||||
|
FontManager.printAllAvailableFonts()
|
||||||
|
// 检查 Bayon 字体是否可用
|
||||||
|
print("Bayon 字体可用:\(FontManager.isFontAvailable(.bayonRegular))")
|
||||||
|
#endif
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前配置状态
|
||||||
|
|
||||||
|
### ✅ 已完成:
|
||||||
|
- [x] Info.plist 配置完成
|
||||||
|
- [x] FontManager 工具类创建完成
|
||||||
|
- [x] LoginView 中 E-PARTI 文本已应用 Bayon 字体
|
||||||
|
- [x] 字体适配与屏幕尺寸兼容
|
||||||
|
|
||||||
|
### ⏳ 待完成:
|
||||||
|
- [ ] 添加 Bayon-Regular.ttf 字体文件到项目中
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 方法1: 使用 FontManager(推荐)
|
||||||
|
```swift
|
||||||
|
Text("E-PARTI")
|
||||||
|
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法2: 使用 View Extension
|
||||||
|
```swift
|
||||||
|
Text("E-PARTI")
|
||||||
|
.adaptedCustomFont(.bayonRegular, designSize: 56)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方法3: 直接指定大小
|
||||||
|
```swift
|
||||||
|
Text("E-PARTI")
|
||||||
|
.customFont(.bayonRegular, size: 56)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
如果字体未生效,请检查:
|
||||||
|
1. 字体文件是否正确添加到项目 Target 中
|
||||||
|
2. Info.plist 中的字体文件名是否正确
|
||||||
|
3. 字体文件名与代码中使用的名称是否一致
|
||||||
|
4. 运行调试代码确认字体是否被系统识别
|
@@ -9,5 +9,9 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>NSWiFiUsageDescription</key>
|
<key>NSWiFiUsageDescription</key>
|
||||||
<string>应用需要访问 Wi-Fi 信息以提供网络相关功能</string>
|
<string>应用需要访问 Wi-Fi 信息以提供网络相关功能</string>
|
||||||
|
<key>UIAppFonts</key>
|
||||||
|
<array>
|
||||||
|
<string>Bayon-Regular.ttf</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
63
yana/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||||
|
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
|
||||||
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="s0d-6b-0kx">
|
||||||
|
<objects>
|
||||||
|
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
|
||||||
|
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="bg" translatesAutoresizingMaskIntoConstraints="NO" id="Mom-Je-A43">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||||
|
</imageView>
|
||||||
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="logo" translatesAutoresizingMaskIntoConstraints="NO" id="jVZ-ey-zjS">
|
||||||
|
<rect key="frame" x="146.66666666666666" y="200" width="100" height="100"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstAttribute="width" constant="100" id="61A-Xv-MlD"/>
|
||||||
|
<constraint firstAttribute="height" constant="100" id="NWJ-mJ-K2O"/>
|
||||||
|
</constraints>
|
||||||
|
</imageView>
|
||||||
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="E-Parti" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="GrU-nK-tAY">
|
||||||
|
<rect key="frame" x="138" y="332" width="117" height="48"/>
|
||||||
|
<fontDescription key="fontDescription" type="system" pointSize="40"/>
|
||||||
|
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
<nil key="highlightedColor"/>
|
||||||
|
</label>
|
||||||
|
</subviews>
|
||||||
|
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
|
||||||
|
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="jVZ-ey-zjS" firstAttribute="centerX" secondItem="Mom-Je-A43" secondAttribute="centerX" id="Atv-LB-aNW"/>
|
||||||
|
<constraint firstItem="Mom-Je-A43" firstAttribute="leading" secondItem="vDu-zF-Fre" secondAttribute="leading" id="fQ2-yj-nze"/>
|
||||||
|
<constraint firstItem="Mom-Je-A43" firstAttribute="top" secondItem="5EZ-qb-Rvc" secondAttribute="top" id="gVA-6N-OG4"/>
|
||||||
|
<constraint firstItem="GrU-nK-tAY" firstAttribute="centerY" secondItem="Mom-Je-A43" secondAttribute="centerY" constant="-70" id="j81-qa-9vS"/>
|
||||||
|
<constraint firstAttribute="bottom" secondItem="Mom-Je-A43" secondAttribute="bottom" id="lBn-me-aJu"/>
|
||||||
|
<constraint firstItem="Mom-Je-A43" firstAttribute="trailing" secondItem="vDu-zF-Fre" secondAttribute="trailing" id="lka-KN-fEy"/>
|
||||||
|
<constraint firstItem="jVZ-ey-zjS" firstAttribute="top" secondItem="Mom-Je-A43" secondAttribute="top" constant="200" id="nCq-oK-mTB"/>
|
||||||
|
<constraint firstItem="GrU-nK-tAY" firstAttribute="centerX" secondItem="Mom-Je-A43" secondAttribute="centerX" id="sZE-bZ-0Xj"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="-208.3969465648855" y="-13.380281690140846"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="bg" width="375" height="812"/>
|
||||||
|
<image name="logo" width="100" height="100"/>
|
||||||
|
<systemColor name="systemBackgroundColor">
|
||||||
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
|
</systemColor>
|
||||||
|
</resources>
|
||||||
|
</document>
|
@@ -1,35 +0,0 @@
|
|||||||
import NIMSDK
|
|
||||||
import NECoreKit
|
|
||||||
import NECoreIM2Kit
|
|
||||||
import NEChatKit
|
|
||||||
import NEChatUIKit
|
|
||||||
|
|
||||||
struct NIMConfigurationManager {
|
|
||||||
|
|
||||||
static func setupNimSDK() {
|
|
||||||
let option = configureNIMSDKOption()
|
|
||||||
setupSDK(with: option)
|
|
||||||
setupChatSDK(with: option)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setupSDK(with option: NIMSDKOption) {
|
|
||||||
NIMSDK.shared().register(with: option)
|
|
||||||
NIMSDKConfig.shared().shouldConsiderRevokedMessageUnreadCount = true
|
|
||||||
NIMSDKConfig.shared().shouldSyncStickTopSessionInfos = true
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setupChatSDK(with option: NIMSDKOption) {
|
|
||||||
let v2Option = V2NIMSDKOption()
|
|
||||||
v2Option.enableV2CloudConversation = false
|
|
||||||
// TODO: 修复 IMKitClient API 调用
|
|
||||||
// IMKitClient.shared.setupIM2(option, v2Option)
|
|
||||||
print("⚠️ NIM SDK 配置暂时被注释,需要修复 IMKitClient API")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func configureNIMSDKOption() -> NIMSDKOption {
|
|
||||||
let option = NIMSDKOption()
|
|
||||||
option.appKey = "79bc37000f4018a2a24ea9dc6ca08d32"
|
|
||||||
option.apnsCername = "pikoDevelopPush"
|
|
||||||
return option
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,127 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import NIMSDK
|
|
||||||
|
|
||||||
// MARK: - 网络状态通知
|
|
||||||
extension Notification.Name {
|
|
||||||
static let NIMNetworkStateChanged = Notification.Name("NIMNetworkStateChangedNotification")
|
|
||||||
static let NIMTokenExpired = Notification.Name("NIMTokenExpiredNotification")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc
|
|
||||||
@objcMembers
|
|
||||||
final class NIMSessionManager: NSObject {
|
|
||||||
|
|
||||||
static let shared = NIMSessionManager()
|
|
||||||
|
|
||||||
// MARK: - 登录管理
|
|
||||||
func autoLogin(account: String, token: String, completion: @escaping (Error?) -> Void) {
|
|
||||||
NIMSDK.shared().v2LoginService.add(self)
|
|
||||||
let data = NIMAutoLoginData()
|
|
||||||
data.account = account
|
|
||||||
data.token = token
|
|
||||||
data.forcedMode = false
|
|
||||||
NIMSDK.shared().loginManager.autoLogin(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func login(account: String, token: String, completion: @escaping (Error?) -> Void) {
|
|
||||||
NIMSDK.shared().loginManager.login(account, token: token) { error in
|
|
||||||
if error == nil {
|
|
||||||
self.registerObservers()
|
|
||||||
}
|
|
||||||
completion(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func logout() {
|
|
||||||
NIMSDK.shared().loginManager.logout { _ in
|
|
||||||
self.removeObservers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 消息监听
|
|
||||||
private func registerObservers() {
|
|
||||||
// 在 autoLogin 方法中
|
|
||||||
// NIMSDK.shared().v2LoginService.add(self as! V2NIMLoginServiceDelegate)
|
|
||||||
|
|
||||||
// 在 registerObservers 方法中
|
|
||||||
// NIMSDK.shared().v2LoginService.add(self as! V2NIMLoginServiceDelegate)
|
|
||||||
|
|
||||||
// 在 removeObservers 方法中
|
|
||||||
// NIMSDK.shared().v2LoginService.remove(self as! V2NIMLoginServiceDelegate)
|
|
||||||
NIMSDK.shared().chatManager.add(self)
|
|
||||||
NIMSDK.shared().loginManager.add(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeObservers() {
|
|
||||||
NIMSDK.shared().v2LoginService.remove(self)
|
|
||||||
NIMSDK.shared().chatManager.remove(self)
|
|
||||||
NIMSDK.shared().loginManager.remove(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - NIMChatManagerDelegate
|
|
||||||
extension NIMSessionManager: NIMChatManagerDelegate {
|
|
||||||
func onRecvMessages(_ messages: [NIMMessage]) {
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .NIMDidReceiveMessage,
|
|
||||||
object: messages
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - NIMLoginManagerDelegate
|
|
||||||
extension NIMSessionManager: NIMLoginManagerDelegate {
|
|
||||||
func onLogin(_ step: NIMLoginStep) {
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .NIMLoginStateChanged,
|
|
||||||
object: step
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func onAutoLoginFailed(_ error: Error) {
|
|
||||||
if (error as NSError).code == 302 {
|
|
||||||
NotificationCenter.default.post(name: .NIMTokenExpired, object: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 通知定义
|
|
||||||
extension Notification.Name {
|
|
||||||
static let NIMDidReceiveMessage = Notification.Name("NIMDidReceiveMessageNotification")
|
|
||||||
static let NIMLoginStateChanged = Notification.Name("NIMLoginStateChangedNotification")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - NIMV2LoginServiceDelegate
|
|
||||||
extension NIMSessionManager: V2NIMLoginListener {
|
|
||||||
func onLoginStatus(_ status: V2NIMLoginStatus) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func onLoginFailed(_ error: V2NIMError) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func onKickedOffline(_ detail: V2NIMKickedOfflineDetail) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func onLoginClientChanged(
|
|
||||||
_ change: V2NIMLoginClientChange,
|
|
||||||
clients: [V2NIMLoginClient]?
|
|
||||||
) {
|
|
||||||
|
|
||||||
}
|
|
||||||
// @objc func onLoginProcess(step: NIMV2LoginStep) {
|
|
||||||
// NotificationCenter.default.post(
|
|
||||||
// name: .NIMV2LoginStateChanged,
|
|
||||||
// object: step
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @objc func onKickOut(result: NIMKickOutResult) {
|
|
||||||
// NotificationCenter.default.post(
|
|
||||||
// name: .NIMKickOutNotification,
|
|
||||||
// object: result
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
}
|
|
78
yana/Resources/Localizable.strings
Normal 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";
|
78
yana/Resources/zh-Hans.lproj/Localizable.strings
Normal 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" = "享受您的生活时光";
|
26
yana/Utils/Extensions/Color+Hex.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Color Hex Extension
|
||||||
|
extension Color {
|
||||||
|
/// 使用十六进制值创建颜色
|
||||||
|
/// - Parameter hex: 十六进制颜色值,格式为 0xRRGGBB
|
||||||
|
/// - Example: Color(hex: 0x313131)
|
||||||
|
init(hex: UInt32) {
|
||||||
|
let red = Double((hex >> 16) & 0xFF) / 255.0
|
||||||
|
let green = Double((hex >> 8) & 0xFF) / 255.0
|
||||||
|
let blue = Double(hex & 0xFF) / 255.0
|
||||||
|
self.init(red: red, green: green, blue: blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用十六进制值和透明度创建颜色
|
||||||
|
/// - Parameters:
|
||||||
|
/// - hex: 十六进制颜色值,格式为 0xRRGGBB
|
||||||
|
/// - alpha: 透明度,范围 0.0-1.0
|
||||||
|
/// - Example: Color(hex: 0x313131, alpha: 0.8)
|
||||||
|
init(hex: UInt32, alpha: Double) {
|
||||||
|
let red = Double((hex >> 16) & 0xFF) / 255.0
|
||||||
|
let green = Double((hex >> 8) & 0xFF) / 255.0
|
||||||
|
let blue = Double(hex & 0xFF) / 255.0
|
||||||
|
self.init(red: red, green: green, blue: blue, opacity: alpha)
|
||||||
|
}
|
||||||
|
}
|
83
yana/Utils/Extensions/String+HashTest.swift
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 字符串哈希方法测试工具
|
||||||
|
/// 用于验证 MD5 和 SHA256 方法的正确性
|
||||||
|
struct StringHashTest {
|
||||||
|
|
||||||
|
/// 测试哈希方法
|
||||||
|
static func runTests() {
|
||||||
|
print("🧪 开始测试字符串哈希方法...")
|
||||||
|
|
||||||
|
let testStrings = [
|
||||||
|
"hello world",
|
||||||
|
"test123",
|
||||||
|
"key=rpbs6us1m8r2j9g6u06ff2bo18orwaya",
|
||||||
|
"phone=encrypted_phone&password=encrypted_password&client_id=erban-client&key=rpbs6us1m8r2j9g6u06ff2bo18orwaya"
|
||||||
|
]
|
||||||
|
|
||||||
|
for testString in testStrings {
|
||||||
|
print("\n📝 测试字符串: \"\(testString)\"")
|
||||||
|
|
||||||
|
// 测试 MD5
|
||||||
|
let md5Result = testString.md5()
|
||||||
|
print(" MD5: \(md5Result)")
|
||||||
|
|
||||||
|
// 测试 SHA256 (iOS 13+)
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
let sha256Result = testString.sha256()
|
||||||
|
print(" SHA256: \(sha256Result)")
|
||||||
|
} else {
|
||||||
|
print(" SHA256: 不支持 (需要 iOS 13+)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\n✅ 哈希方法测试完成")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 验证已知的哈希值
|
||||||
|
static func verifyKnownHashes() {
|
||||||
|
print("\n🔍 验证已知哈希值...")
|
||||||
|
|
||||||
|
// 验证 "hello world" 的 MD5 应该是 "5d41402abc4b2a76b9719d911017c592"
|
||||||
|
let testString = "hello world"
|
||||||
|
let expectedMD5 = "5d41402abc4b2a76b9719d911017c592"
|
||||||
|
let actualMD5 = testString.md5()
|
||||||
|
|
||||||
|
if actualMD5 == expectedMD5 {
|
||||||
|
print("✅ MD5 验证通过: \(actualMD5)")
|
||||||
|
} else {
|
||||||
|
print("❌ MD5 验证失败:")
|
||||||
|
print(" 期望: \(expectedMD5)")
|
||||||
|
print(" 实际: \(actualMD5)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 SHA256
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
let expectedSHA256 = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||||
|
let actualSHA256 = testString.sha256()
|
||||||
|
|
||||||
|
if actualSHA256 == expectedSHA256 {
|
||||||
|
print("✅ SHA256 验证通过: \(actualSHA256)")
|
||||||
|
} else {
|
||||||
|
print("❌ SHA256 验证失败:")
|
||||||
|
print(" 期望: \(expectedSHA256)")
|
||||||
|
print(" 实际: \(actualSHA256)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 使用示例
|
||||||
|
/*
|
||||||
|
|
||||||
|
// 在适当的地方调用测试
|
||||||
|
StringHashTest.runTests()
|
||||||
|
StringHashTest.verifyKnownHashes()
|
||||||
|
|
||||||
|
// 或者在开发时快速测试
|
||||||
|
print("Test MD5:", "hello".md5())
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
print("Test SHA256:", "hello".sha256())
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
39
yana/Utils/Extensions/String+MD5.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import Foundation
|
||||||
|
import CommonCrypto
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
// MARK: - String Hash Extensions
|
||||||
|
extension String {
|
||||||
|
/// 计算字符串的SHA256哈希值(推荐使用)
|
||||||
|
/// - Returns: SHA256哈希值的小写十六进制字符串
|
||||||
|
@available(iOS 13.0, *)
|
||||||
|
func sha256() -> String {
|
||||||
|
let data = Data(self.utf8)
|
||||||
|
let digest = SHA256.hash(data: data)
|
||||||
|
return digest.compactMap { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算字符串的MD5哈希值(已弃用,仅用于兼容性)
|
||||||
|
///
|
||||||
|
/// ⚠️ 警告:MD5在iOS 13.0后已被弃用,因为它在加密学上是不安全的
|
||||||
|
/// 建议使用 sha256() 方法替代
|
||||||
|
///
|
||||||
|
/// - Returns: MD5哈希值的小写十六进制字符串
|
||||||
|
func md5() -> String {
|
||||||
|
if #available(iOS 13.0, *) {
|
||||||
|
// iOS 13+ 使用 CryptoKit 的 Insecure.MD5
|
||||||
|
let data = Data(self.utf8)
|
||||||
|
let digest = Insecure.MD5.hash(data: data)
|
||||||
|
return digest.compactMap { String(format: "%02x", $0) }.joined()
|
||||||
|
} else {
|
||||||
|
// iOS 13 以下使用 CommonCrypto
|
||||||
|
let data = Data(self.utf8)
|
||||||
|
let hash = data.withUnsafeBytes { bytes -> [UInt8] in
|
||||||
|
var hash = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
|
||||||
|
CC_MD5(bytes.baseAddress, CC_LONG(data.count), &hash)
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
return hash.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
yana/Utils/Extensions/View+Placeholder.swift
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - View Extension for Placeholder
|
||||||
|
extension View {
|
||||||
|
/// 为TextField和SecureField添加占位符功能
|
||||||
|
/// - Parameters:
|
||||||
|
/// - shouldShow: 是否显示占位符
|
||||||
|
/// - alignment: 占位符对齐方式
|
||||||
|
/// - placeholder: 占位符视图构建器
|
||||||
|
/// - Returns: 带有占位符的视图
|
||||||
|
func placeholder<Content: View>(
|
||||||
|
when shouldShow: Bool,
|
||||||
|
alignment: Alignment = .leading,
|
||||||
|
@ViewBuilder placeholder: () -> Content) -> some View {
|
||||||
|
|
||||||
|
ZStack(alignment: alignment) {
|
||||||
|
placeholder().opacity(shouldShow ? 1 : 0)
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
110
yana/Utils/FontManager.swift
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 字体管理工具类
|
||||||
|
/// 统一管理项目中使用的自定义字体
|
||||||
|
struct FontManager {
|
||||||
|
|
||||||
|
// MARK: - 自定义字体名称
|
||||||
|
enum CustomFont: String, CaseIterable {
|
||||||
|
case bayonRegular = "Bayon-Regular"
|
||||||
|
|
||||||
|
/// 字体的显示名称
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .bayonRegular:
|
||||||
|
return "Bayon Regular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 字体文件名(不包含扩展名)
|
||||||
|
var fileName: String {
|
||||||
|
return self.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 字体创建方法
|
||||||
|
|
||||||
|
/// 创建自定义字体
|
||||||
|
/// - Parameters:
|
||||||
|
/// - customFont: 自定义字体类型
|
||||||
|
/// - size: 字体大小
|
||||||
|
/// - Returns: Font 对象
|
||||||
|
static func font(_ customFont: CustomFont, size: CGFloat) -> Font {
|
||||||
|
return Font.custom(customFont.rawValue, size: size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建适配屏幕的自定义字体
|
||||||
|
/// - Parameters:
|
||||||
|
/// - customFont: 自定义字体类型
|
||||||
|
/// - designSize: 设计稿中的字体大小
|
||||||
|
/// - screenWidth: 当前屏幕宽度
|
||||||
|
/// - Returns: Font 对象
|
||||||
|
static func adaptedFont(_ customFont: CustomFont, designSize: CGFloat, for screenWidth: CGFloat) -> Font {
|
||||||
|
let adaptedSize = ScreenAdapter.fontSize(designSize, for: screenWidth)
|
||||||
|
return Font.custom(customFont.rawValue, size: adaptedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查字体是否可用
|
||||||
|
/// - Parameter customFont: 自定义字体类型
|
||||||
|
/// - Returns: 字体是否可用
|
||||||
|
static func isFontAvailable(_ customFont: CustomFont) -> Bool {
|
||||||
|
let fontNames = UIFont.familyNames
|
||||||
|
.flatMap { UIFont.fontNames(forFamilyName: $0) }
|
||||||
|
|
||||||
|
return fontNames.contains(customFont.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有可用的字体列表(调试用)
|
||||||
|
/// - Returns: 所有可用字体名称的数组
|
||||||
|
static func getAllAvailableFonts() -> [String] {
|
||||||
|
return UIFont.familyNames
|
||||||
|
.flatMap { family in
|
||||||
|
UIFont.fontNames(forFamilyName: family)
|
||||||
|
.map { _ in "\(family): \(String(describing: font))" }
|
||||||
|
}
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打印所有可用字体(调试用)
|
||||||
|
static func printAllAvailableFonts() {
|
||||||
|
print("=== 所有可用字体 ===")
|
||||||
|
for font in getAllAvailableFonts() {
|
||||||
|
print(font)
|
||||||
|
}
|
||||||
|
print("==================")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SwiftUI View Extension
|
||||||
|
extension View {
|
||||||
|
/// 应用自定义字体
|
||||||
|
/// - Parameters:
|
||||||
|
/// - customFont: 自定义字体类型
|
||||||
|
/// - size: 字体大小
|
||||||
|
/// - Returns: 应用了自定义字体的视图
|
||||||
|
func customFont(_ customFont: FontManager.CustomFont, size: CGFloat) -> some View {
|
||||||
|
self.font(FontManager.font(customFont, size: size))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用适配屏幕的自定义字体
|
||||||
|
/// - Parameters:
|
||||||
|
/// - customFont: 自定义字体类型
|
||||||
|
/// - designSize: 设计稿中的字体大小
|
||||||
|
/// - Returns: 应用了适配字体的视图修饰器
|
||||||
|
func adaptedCustomFont(_ customFont: FontManager.CustomFont, designSize: CGFloat) -> some View {
|
||||||
|
self.modifier(AdaptedCustomFontModifier(customFont: customFont, designSize: designSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ViewModifier
|
||||||
|
struct AdaptedCustomFontModifier: ViewModifier {
|
||||||
|
let customFont: FontManager.CustomFont
|
||||||
|
let designSize: CGFloat
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
content
|
||||||
|
.font(FontManager.adaptedFont(customFont, designSize: designSize, for: geometry.size.width))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
135
yana/Utils/LocalizationManager.swift
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 多语言管理工具类
|
||||||
|
/// 提供便捷的本地化字符串获取和语言切换功能
|
||||||
|
///
|
||||||
|
/// 默认语言策略:
|
||||||
|
/// - 应用全局默认语言为英文,不依赖系统语言设置
|
||||||
|
/// - 用户可通过语言设置界面手动切换到其他支持的语言
|
||||||
|
/// - 用户的语言选择会保存在UserDefaults中,下次启动时保持
|
||||||
|
class LocalizationManager: ObservableObject {
|
||||||
|
|
||||||
|
// MARK: - 单例
|
||||||
|
static let shared = LocalizationManager()
|
||||||
|
|
||||||
|
// MARK: - 支持的语言
|
||||||
|
enum SupportedLanguage: String, CaseIterable {
|
||||||
|
case english = "en"
|
||||||
|
case chineseSimplified = "zh-Hans"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .english:
|
||||||
|
return "English"
|
||||||
|
case .chineseSimplified:
|
||||||
|
return "简体中文"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var localizedDisplayName: String {
|
||||||
|
switch self {
|
||||||
|
case .english:
|
||||||
|
return "English"
|
||||||
|
case .chineseSimplified:
|
||||||
|
return "简体中文"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 当前语言
|
||||||
|
@Published var currentLanguage: SupportedLanguage {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(currentLanguage.rawValue, forKey: "AppLanguage")
|
||||||
|
// 通知视图更新
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// 从 UserDefaults 读取保存的语言设置
|
||||||
|
let savedLanguage = UserDefaults.standard.string(forKey: "AppLanguage") ?? ""
|
||||||
|
self.currentLanguage = SupportedLanguage(rawValue: savedLanguage) ?? .english
|
||||||
|
|
||||||
|
// 如果没有保存过语言设置,使用系统首选语言
|
||||||
|
if savedLanguage.isEmpty {
|
||||||
|
self.currentLanguage = Self.getSystemPreferredLanguage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 本地化方法
|
||||||
|
|
||||||
|
/// 获取本地化字符串
|
||||||
|
/// - Parameters:
|
||||||
|
/// - key: 本地化 key
|
||||||
|
/// - arguments: 格式化参数
|
||||||
|
/// - Returns: 本地化后的字符串
|
||||||
|
func localizedString(_ key: String, arguments: CVarArg...) -> String {
|
||||||
|
let format = getLocalizedString(for: key)
|
||||||
|
if arguments.isEmpty {
|
||||||
|
return format
|
||||||
|
} else {
|
||||||
|
return String(format: format, arguments: arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取本地化字符串(私有方法)
|
||||||
|
private func getLocalizedString(for key: String) -> String {
|
||||||
|
guard let path = Bundle.main.path(forResource: currentLanguage.rawValue, ofType: "lproj"),
|
||||||
|
let bundle = Bundle(path: path) else {
|
||||||
|
// 如果找不到对应语言包,返回 key 本身
|
||||||
|
return NSLocalizedString(key, comment: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return NSLocalizedString(key, bundle: bundle, comment: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 语言切换
|
||||||
|
|
||||||
|
/// 切换到指定语言
|
||||||
|
/// - Parameter language: 目标语言
|
||||||
|
func switchLanguage(to language: SupportedLanguage) {
|
||||||
|
currentLanguage = language
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取系统首选语言
|
||||||
|
/// 注意:应用全局默认语言已设置为英文,用户可通过设置手动切换语言
|
||||||
|
private static func getSystemPreferredLanguage() -> SupportedLanguage {
|
||||||
|
// 全局默认语言设置为英文
|
||||||
|
// 用户仍可通过语言设置界面切换到其他支持的语言
|
||||||
|
return .english
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SwiftUI Extensions
|
||||||
|
extension View {
|
||||||
|
/// 应用本地化字符串
|
||||||
|
/// - Parameter key: 本地化 key
|
||||||
|
/// - Returns: 带有本地化文本的视图
|
||||||
|
func localized(_ key: String) -> some View {
|
||||||
|
self.modifier(LocalizedTextModifier(key: key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地化文本修饰器
|
||||||
|
struct LocalizedTextModifier: ViewModifier {
|
||||||
|
let key: String
|
||||||
|
@ObservedObject private var localizationManager = LocalizationManager.shared
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 便捷方法
|
||||||
|
extension String {
|
||||||
|
/// 获取本地化字符串
|
||||||
|
var localized: String {
|
||||||
|
return LocalizationManager.shared.localizedString(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取本地化字符串(带参数)
|
||||||
|
func localized(arguments: CVarArg...) -> String {
|
||||||
|
return LocalizationManager.shared.localizedString(self, arguments: arguments)
|
||||||
|
}
|
||||||
|
}
|
114
yana/Utils/ScreenAdapter.swift
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 屏幕适配工具类
|
||||||
|
/// 基于设计稿尺寸进行等比例缩放,确保在不同设备上保持一致的视觉效果
|
||||||
|
struct ScreenAdapter {
|
||||||
|
|
||||||
|
// MARK: - 设计稿基准尺寸
|
||||||
|
/// 设计稿宽度基准 (iPhone 14 Pro)
|
||||||
|
static let designWidth: CGFloat = 393
|
||||||
|
/// 设计稿高度基准 (iPhone 14 Pro)
|
||||||
|
static let designHeight: CGFloat = 852
|
||||||
|
|
||||||
|
// MARK: - 适配方法
|
||||||
|
|
||||||
|
/// 根据设计稿宽度计算适配后的宽度
|
||||||
|
/// - Parameters:
|
||||||
|
/// - designValue: 设计稿中的宽度值
|
||||||
|
/// - screenWidth: 当前屏幕宽度
|
||||||
|
/// - Returns: 适配后的宽度值
|
||||||
|
static func width(_ designValue: CGFloat, for screenWidth: CGFloat) -> CGFloat {
|
||||||
|
return designValue * (screenWidth / designWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据设计稿高度计算适配后的高度
|
||||||
|
/// - Parameters:
|
||||||
|
/// - designValue: 设计稿中的高度值
|
||||||
|
/// - screenHeight: 当前屏幕高度
|
||||||
|
/// - Returns: 适配后的高度值
|
||||||
|
static func height(_ designValue: CGFloat, for screenHeight: CGFloat) -> CGFloat {
|
||||||
|
return designValue * (screenHeight / designHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据设计稿字体大小计算适配后的字体大小
|
||||||
|
/// - Parameters:
|
||||||
|
/// - designFontSize: 设计稿中的字体大小
|
||||||
|
/// - screenWidth: 当前屏幕宽度
|
||||||
|
/// - Returns: 适配后的字体大小
|
||||||
|
static func fontSize(_ designFontSize: CGFloat, for screenWidth: CGFloat) -> CGFloat {
|
||||||
|
return designFontSize * (screenWidth / designWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算适配比例 (基于宽度)
|
||||||
|
/// - Parameter screenWidth: 当前屏幕宽度
|
||||||
|
/// - Returns: 宽度适配比例
|
||||||
|
static func widthRatio(for screenWidth: CGFloat) -> CGFloat {
|
||||||
|
return screenWidth / designWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算适配比例 (基于高度)
|
||||||
|
/// - Parameter screenHeight: 当前屏幕高度
|
||||||
|
/// - Returns: 高度适配比例
|
||||||
|
static func heightRatio(for screenHeight: CGFloat) -> CGFloat {
|
||||||
|
return screenHeight / designHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SwiftUI View Extension
|
||||||
|
extension View {
|
||||||
|
/// 根据设计稿尺寸适配宽度
|
||||||
|
/// - Parameter designValue: 设计稿中的宽度值
|
||||||
|
/// - Returns: 带有适配宽度的视图修饰器
|
||||||
|
func adaptedWidth(_ designValue: CGFloat) -> some View {
|
||||||
|
self.modifier(AdaptedWidthModifier(designValue: designValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据设计稿尺寸适配高度
|
||||||
|
/// - Parameter designValue: 设计稿中的高度值
|
||||||
|
/// - Returns: 带有适配高度的视图修饰器
|
||||||
|
func adaptedHeight(_ designValue: CGFloat) -> some View {
|
||||||
|
self.modifier(AdaptedHeightModifier(designValue: designValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据设计稿尺寸适配字体大小
|
||||||
|
/// - Parameter designFontSize: 设计稿中的字体大小
|
||||||
|
/// - Returns: 带有适配字体的视图修饰器
|
||||||
|
func adaptedFont(_ designFontSize: CGFloat, weight: Font.Weight = .regular) -> some View {
|
||||||
|
self.modifier(AdaptedFontModifier(designFontSize: designFontSize, weight: weight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ViewModifiers
|
||||||
|
struct AdaptedWidthModifier: ViewModifier {
|
||||||
|
let designValue: CGFloat
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
content
|
||||||
|
.frame(width: ScreenAdapter.width(designValue, for: geometry.size.width))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AdaptedHeightModifier: ViewModifier {
|
||||||
|
let designValue: CGFloat
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
content
|
||||||
|
.padding(.top, ScreenAdapter.height(designValue, for: geometry.size.height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AdaptedFontModifier: ViewModifier {
|
||||||
|
let designFontSize: CGFloat
|
||||||
|
let weight: Font.Weight
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
content
|
||||||
|
.font(.system(size: ScreenAdapter.fontSize(designFontSize, for: geometry.size.width), weight: weight))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
54
yana/Utils/ScreenAdapterExample.swift
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// ScreenAdapter 使用示例
|
||||||
|
/// 展示如何在 SwiftUI 视图中使用屏幕适配工具类
|
||||||
|
struct ScreenAdapterExample: View {
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
|
||||||
|
// 方法1: 直接使用 ScreenAdapter 静态方法
|
||||||
|
Text("方法1: 直接调用")
|
||||||
|
.font(.system(size: ScreenAdapter.fontSize(16, for: geometry.size.width)))
|
||||||
|
.padding(.leading, ScreenAdapter.width(20, for: geometry.size.width))
|
||||||
|
.padding(.top, ScreenAdapter.height(50, for: geometry.size.height))
|
||||||
|
|
||||||
|
// 方法2: 使用 View Extension (推荐)
|
||||||
|
Text("方法2: View Extension")
|
||||||
|
.adaptedFont(16)
|
||||||
|
.adaptedHeight(50)
|
||||||
|
|
||||||
|
// 方法3: 使用比例计算
|
||||||
|
Text("方法3: 比例计算")
|
||||||
|
.font(.system(size: 16 * ScreenAdapter.widthRatio(for: geometry.size.width)))
|
||||||
|
.padding(.top, 50 * ScreenAdapter.heightRatio(for: geometry.size.height))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 使用建议
|
||||||
|
/*
|
||||||
|
|
||||||
|
推荐使用顺序:
|
||||||
|
|
||||||
|
1. View Extension (最简洁)
|
||||||
|
.adaptedFont(16)
|
||||||
|
.adaptedHeight(20)
|
||||||
|
.adaptedWidth(100)
|
||||||
|
|
||||||
|
2. 直接调用静态方法 (灵活性高)
|
||||||
|
.font(.system(size: ScreenAdapter.fontSize(16, for: geometry.size.width)))
|
||||||
|
.padding(.top, ScreenAdapter.height(20, for: geometry.size.height))
|
||||||
|
|
||||||
|
3. 比例计算 (自定义场景)
|
||||||
|
let ratio = ScreenAdapter.heightRatio(for: geometry.size.height)
|
||||||
|
.padding(.top, 20 * ratio)
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ScreenAdapterExample()
|
||||||
|
}
|
19
yana/Utils/Security/AESUtils.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// AESUtils.h
|
||||||
|
// YUMI
|
||||||
|
//
|
||||||
|
// Created by YUMI on 2023/2/13.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
|
||||||
|
@interface AESUtils : NSObject
|
||||||
|
//MARK: AES加解密
|
||||||
|
+ (NSString *)aesEncrypt:(NSString *)sourceStr;
|
||||||
|
|
||||||
|
+ (NSString *)aesDecrypt:(NSString *)secretStr;
|
||||||
|
@end
|
||||||
|
|
||||||
|
NS_ASSUME_NONNULL_END
|
151
yana/Utils/Security/AESUtils.m
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
//
|
||||||
|
// AESUtils.m
|
||||||
|
// YUMI
|
||||||
|
//
|
||||||
|
// Created by YUMI on 2023/2/13.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "AESUtils.h"
|
||||||
|
#import <CommonCrypto/CommonCrypto.h>
|
||||||
|
|
||||||
|
#define GL_AES_KEY @"aef01238765abcdeaaageggbeggsded"
|
||||||
|
#define GL_AES_IV @"edgcdgrtc"
|
||||||
|
@implementation AESUtils
|
||||||
|
//MARK: AES加解密相关 start
|
||||||
|
+ (NSString *)aesEncrypt:(NSString *)sourceStr {
|
||||||
|
if (!sourceStr) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
//秘钥
|
||||||
|
char keyPtr[kCCKeySizeAES256 + 1];
|
||||||
|
bzero(keyPtr, sizeof(keyPtr));
|
||||||
|
[GL_AES_KEY getCString:keyPtr maxLength:sizeof(keyPtr) encoding:NSUTF8StringEncoding];
|
||||||
|
|
||||||
|
//向量
|
||||||
|
char ivPtr[kCCBlockSizeAES128 + 1];
|
||||||
|
bzero(ivPtr, sizeof(ivPtr));
|
||||||
|
[GL_AES_IV getCString:ivPtr maxLength:sizeof(ivPtr) encoding:NSUTF8StringEncoding];
|
||||||
|
|
||||||
|
NSData *sourceData = [sourceStr dataUsingEncoding:NSUTF8StringEncoding];
|
||||||
|
NSUInteger dataLength = [sourceData length];
|
||||||
|
size_t buffersize = dataLength + kCCBlockSizeAES128;
|
||||||
|
void *buffer = malloc(buffersize);
|
||||||
|
size_t numBytesEncrypted = 0;
|
||||||
|
/*
|
||||||
|
//CBC模式
|
||||||
|
kCCOptionPKCS7Padding
|
||||||
|
//ECB模式
|
||||||
|
kCCOptionPKCS7Padding | kCCOptionECBMode
|
||||||
|
*/
|
||||||
|
CCCryptorStatus cryptStatus = CCCrypt(kCCEncrypt,
|
||||||
|
kCCAlgorithmAES128,
|
||||||
|
kCCOptionPKCS7Padding,
|
||||||
|
keyPtr,
|
||||||
|
kCCBlockSizeAES128,
|
||||||
|
ivPtr,//ECB模式下可以为NULL
|
||||||
|
[sourceData bytes],
|
||||||
|
dataLength,
|
||||||
|
buffer,
|
||||||
|
buffersize,
|
||||||
|
&numBytesEncrypted);
|
||||||
|
|
||||||
|
if (cryptStatus == kCCSuccess) {
|
||||||
|
NSData *encryptData = [NSData dataWithBytesNoCopy:buffer length:numBytesEncrypted];
|
||||||
|
//对加密后的二进制数据进行base64转码
|
||||||
|
//return [encryptData base64EncodedStringWithOptions:NSDataBase64EncodingEndLineWithLineFeed];
|
||||||
|
|
||||||
|
//转换为16进制字符串
|
||||||
|
NSMutableString *output = [NSMutableString stringWithCapacity:encryptData.length * 2];
|
||||||
|
if (encryptData && encryptData.length > 0) {
|
||||||
|
Byte *datas = (Byte*)[encryptData bytes];
|
||||||
|
for(int i = 0; i < encryptData.length; i++){
|
||||||
|
[output appendFormat:@"%02x", datas[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
free(buffer);
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
+ (NSString *)aesDecrypt:(NSString *)secretStr {
|
||||||
|
if (!secretStr) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
// //先对加密的字符串进行base64解码
|
||||||
|
NSData *decodeData = [[NSData alloc] initWithBase64EncodedString:secretStr options:NSDataBase64DecodingIgnoreUnknownCharacters];
|
||||||
|
//先对加密的字符串进行16进制解码
|
||||||
|
// NSData *decodeData = [self convertHexStrToData:secretStr];
|
||||||
|
|
||||||
|
//秘钥
|
||||||
|
char keyPtr[kCCKeySizeAES256 + 1];
|
||||||
|
bzero(keyPtr, sizeof(keyPtr));
|
||||||
|
[GL_AES_KEY getCString:keyPtr maxLength:sizeof(keyPtr) encoding:NSUTF8StringEncoding];
|
||||||
|
|
||||||
|
//向量
|
||||||
|
char ivPtr[kCCBlockSizeAES128 + 1];
|
||||||
|
bzero(ivPtr, sizeof(ivPtr));
|
||||||
|
[GL_AES_IV getCString:ivPtr maxLength:sizeof(ivPtr) encoding:NSUTF8StringEncoding];
|
||||||
|
|
||||||
|
NSUInteger dataLength = [decodeData length];
|
||||||
|
size_t bufferSize = dataLength + kCCBlockSizeAES128;
|
||||||
|
void *buffer = malloc(bufferSize);
|
||||||
|
size_t numBytesDecrypted = 0;
|
||||||
|
/*
|
||||||
|
//CBC模式
|
||||||
|
kCCOptionPKCS7Padding
|
||||||
|
//ECB模式
|
||||||
|
kCCOptionPKCS7Padding | kCCOptionECBMode
|
||||||
|
*/
|
||||||
|
CCCryptorStatus cryptStatus = CCCrypt(kCCDecrypt,
|
||||||
|
kCCAlgorithmAES128,
|
||||||
|
kCCOptionPKCS7Padding,
|
||||||
|
keyPtr,
|
||||||
|
kCCBlockSizeAES128,
|
||||||
|
ivPtr,//ECB模式下可以为NULL
|
||||||
|
[decodeData bytes],
|
||||||
|
dataLength,
|
||||||
|
buffer,
|
||||||
|
bufferSize,
|
||||||
|
&numBytesDecrypted);
|
||||||
|
if (cryptStatus == kCCSuccess) {
|
||||||
|
NSData *data = [NSData dataWithBytesNoCopy:buffer length:numBytesDecrypted];
|
||||||
|
NSString *result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
free(buffer);
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16进制转NSData
|
||||||
|
+ (NSData *)convertHexStrToData:(NSString *)str {
|
||||||
|
if (!str || [str length] == 0) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
NSMutableData *hexData = [[NSMutableData alloc] initWithCapacity:20];
|
||||||
|
NSRange range;
|
||||||
|
if ([str length] % 2 == 0) {
|
||||||
|
range = NSMakeRange(0, 2);
|
||||||
|
} else {
|
||||||
|
range = NSMakeRange(0, 1);
|
||||||
|
}
|
||||||
|
for (NSInteger i = range.location; i < [str length]; i += 2) {
|
||||||
|
unsigned int anInt;
|
||||||
|
NSString *hexCharStr = [str substringWithRange:range];
|
||||||
|
NSScanner *scanner = [[NSScanner alloc] initWithString:hexCharStr];
|
||||||
|
|
||||||
|
[scanner scanHexInt:&anInt];
|
||||||
|
NSData *entity = [[NSData alloc] initWithBytes:&anInt length:1];
|
||||||
|
[hexData appendData:entity];
|
||||||
|
|
||||||
|
range.location += range.length;
|
||||||
|
range.length = 2;
|
||||||
|
}
|
||||||
|
return hexData;
|
||||||
|
}
|
||||||
|
@end
|
16
yana/Utils/Security/Base64.h
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// Base64.h
|
||||||
|
// YMhatFramework
|
||||||
|
//
|
||||||
|
// Created by chenran on 2017/5/4.
|
||||||
|
// Copyright © 2017年 chenran. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
@interface Base64 : NSObject
|
||||||
|
|
||||||
|
+(NSString *)encode:(NSData *)data;
|
||||||
|
+(NSData *)decode:(NSString *)dataString;
|
||||||
|
|
||||||
|
@end
|
133
yana/Utils/Security/Base64.m
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
//
|
||||||
|
// Base64.m
|
||||||
|
// YMhatFramework
|
||||||
|
//
|
||||||
|
// Created by chenran on 2017/5/4.
|
||||||
|
// Copyright © 2017年 chenran. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "Base64.h"
|
||||||
|
|
||||||
|
@interface Base64()
|
||||||
|
+(int)char2Int:(char)c;
|
||||||
|
@end
|
||||||
|
|
||||||
|
@implementation Base64
|
||||||
|
|
||||||
|
static const char encodingTable[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
+(NSString *)encode:(NSData *)data
|
||||||
|
{
|
||||||
|
if (data.length == 0)
|
||||||
|
return nil;
|
||||||
|
|
||||||
|
char *characters = malloc(data.length * 3 / 2);
|
||||||
|
|
||||||
|
if (characters == NULL)
|
||||||
|
return nil;
|
||||||
|
|
||||||
|
int end = data.length - 3;
|
||||||
|
int index = 0;
|
||||||
|
int charCount = 0;
|
||||||
|
int n = 0;
|
||||||
|
|
||||||
|
while (index <= end) {
|
||||||
|
int d = (((int)(((char *)[data bytes])[index]) & 0x0ff) << 16)
|
||||||
|
| (((int)(((char *)[data bytes])[index + 1]) & 0x0ff) << 8)
|
||||||
|
| ((int)(((char *)[data bytes])[index + 2]) & 0x0ff);
|
||||||
|
|
||||||
|
characters[charCount++] = encodingTable[(d >> 18) & 63];
|
||||||
|
characters[charCount++] = encodingTable[(d >> 12) & 63];
|
||||||
|
characters[charCount++] = encodingTable[(d >> 6) & 63];
|
||||||
|
characters[charCount++] = encodingTable[d & 63];
|
||||||
|
|
||||||
|
index += 3;
|
||||||
|
|
||||||
|
if(n++ >= 14)
|
||||||
|
{
|
||||||
|
n = 0;
|
||||||
|
characters[charCount++] = ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(index == data.length - 2)
|
||||||
|
{
|
||||||
|
int d = (((int)(((char *)[data bytes])[index]) & 0x0ff) << 16)
|
||||||
|
| (((int)(((char *)[data bytes])[index + 1]) & 255) << 8);
|
||||||
|
characters[charCount++] = encodingTable[(d >> 18) & 63];
|
||||||
|
characters[charCount++] = encodingTable[(d >> 12) & 63];
|
||||||
|
characters[charCount++] = encodingTable[(d >> 6) & 63];
|
||||||
|
characters[charCount++] = '=';
|
||||||
|
}
|
||||||
|
else if(index == data.length - 1)
|
||||||
|
{
|
||||||
|
int d = ((int)(((char *)[data bytes])[index]) & 0x0ff) << 16;
|
||||||
|
characters[charCount++] = encodingTable[(d >> 18) & 63];
|
||||||
|
characters[charCount++] = encodingTable[(d >> 12) & 63];
|
||||||
|
characters[charCount++] = '=';
|
||||||
|
characters[charCount++] = '=';
|
||||||
|
}
|
||||||
|
NSString * rtnStr = [[NSString alloc] initWithBytesNoCopy:characters length:charCount encoding:NSUTF8StringEncoding freeWhenDone:YES];
|
||||||
|
return rtnStr;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
+(NSData *)decode:(NSString *)data
|
||||||
|
{
|
||||||
|
if(data == nil || data.length <= 0) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
NSMutableData *rtnData = [[NSMutableData alloc]init];
|
||||||
|
int slen = data.length;
|
||||||
|
int index = 0;
|
||||||
|
while (true) {
|
||||||
|
while (index < slen && [data characterAtIndex:index] <= ' ') {
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
if (index >= slen || index + 3 >= slen) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int byte = ([self char2Int:[data characterAtIndex:index]] << 18) + ([self char2Int:[data characterAtIndex:index + 1]] << 12) + ([self char2Int:[data characterAtIndex:index + 2]] << 6) + [self char2Int:[data characterAtIndex:index + 3]];
|
||||||
|
Byte temp1 = (byte >> 16) & 255;
|
||||||
|
[rtnData appendBytes:&temp1 length:1];
|
||||||
|
if([data characterAtIndex:index + 2] == '=') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Byte temp2 = (byte >> 8) & 255;
|
||||||
|
[rtnData appendBytes:&temp2 length:1];
|
||||||
|
if([data characterAtIndex:index + 3] == '=') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Byte temp3 = byte & 255;
|
||||||
|
[rtnData appendBytes:&temp3 length:1];
|
||||||
|
index += 4;
|
||||||
|
|
||||||
|
}
|
||||||
|
return rtnData;
|
||||||
|
}
|
||||||
|
|
||||||
|
+(int)char2Int:(char)c
|
||||||
|
{
|
||||||
|
if (c >= 'A' && c <= 'Z') {
|
||||||
|
return c - 65;
|
||||||
|
} else if (c >= 'a' && c <= 'z') {
|
||||||
|
return c - 97 + 26;
|
||||||
|
} else if (c >= '0' && c <= '9') {
|
||||||
|
return c - 48 + 26 + 26;
|
||||||
|
} else {
|
||||||
|
switch(c) {
|
||||||
|
case '+':
|
||||||
|
return 62;
|
||||||
|
case '/':
|
||||||
|
return 63;
|
||||||
|
case '=':
|
||||||
|
return 0;
|
||||||
|
default:
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@end
|
16
yana/Utils/Security/DESEncrypt.h
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// DESEncrypt.h
|
||||||
|
// YMhatFramework
|
||||||
|
//
|
||||||
|
// Created by chenran on 2017/5/4.
|
||||||
|
// Copyright © 2017年 chenran. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
|
||||||
|
@interface DESEncrypt : NSObject
|
||||||
|
//加密方法
|
||||||
|
+(NSString *) encryptUseDES:(NSString *)plainText key:(NSString *)key;
|
||||||
|
//解密方法
|
||||||
|
+(NSString *) decryptUseDES:(NSString *)cipherText key:(NSString *)key;
|
||||||
|
@end
|
63
yana/Utils/Security/DESEncrypt.m
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//
|
||||||
|
// DESEncrypt.m
|
||||||
|
// YMhatFramework
|
||||||
|
//
|
||||||
|
// Created by chenran on 2017/5/4.
|
||||||
|
// Copyright © 2017年 chenran. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
#import "DESEncrypt.h"
|
||||||
|
#import <CommonCrypto/CommonCrypto.h>
|
||||||
|
#import "Base64.h"
|
||||||
|
|
||||||
|
@implementation DESEncrypt : NSObject
|
||||||
|
|
||||||
|
const Byte iv[] = {1,2,3,4,5,6,7,8};
|
||||||
|
|
||||||
|
#pragma mark- 加密算法
|
||||||
|
+(NSString *) encryptUseDES:(NSString *)plainText key:(NSString *)key
|
||||||
|
{
|
||||||
|
NSString *ciphertext = nil;
|
||||||
|
NSData *textData = [plainText dataUsingEncoding:NSUTF8StringEncoding];
|
||||||
|
NSUInteger dataLength = [textData length];
|
||||||
|
unsigned char buffer[200000];
|
||||||
|
memset(buffer, 0, sizeof(char));
|
||||||
|
size_t numBytesEncrypted = 0;
|
||||||
|
CCCryptorStatus cryptStatus = CCCrypt(kCCEncrypt, kCCAlgorithmDES,
|
||||||
|
kCCOptionPKCS7Padding|kCCOptionECBMode,
|
||||||
|
[key UTF8String], kCCKeySizeDES,
|
||||||
|
iv,
|
||||||
|
[textData bytes], dataLength,
|
||||||
|
buffer, 200000,
|
||||||
|
&numBytesEncrypted);
|
||||||
|
if (cryptStatus == kCCSuccess) {
|
||||||
|
NSData *data = [NSData dataWithBytes:buffer length:(NSUInteger)numBytesEncrypted];
|
||||||
|
ciphertext = [Base64 encode:data];
|
||||||
|
}
|
||||||
|
return ciphertext;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma mark- 解密算法
|
||||||
|
+(NSString *)decryptUseDES:(NSString *)cipherText key:(NSString *)key
|
||||||
|
{
|
||||||
|
NSString *plaintext = nil;
|
||||||
|
NSData *cipherdata = [Base64 decode:cipherText];
|
||||||
|
unsigned char buffer[200000];
|
||||||
|
memset(buffer, 0, sizeof(char));
|
||||||
|
size_t numBytesDecrypted = 0;
|
||||||
|
// kCCOptionPKCS7Padding|kCCOptionECBMode 最主要在这步
|
||||||
|
CCCryptorStatus cryptStatus = CCCrypt(kCCDecrypt, kCCAlgorithmDES,
|
||||||
|
kCCOptionPKCS7Padding|kCCOptionECBMode,
|
||||||
|
[key UTF8String], kCCKeySizeDES,
|
||||||
|
iv,
|
||||||
|
[cipherdata bytes], [cipherdata length],
|
||||||
|
buffer, 200000,
|
||||||
|
&numBytesDecrypted);
|
||||||
|
if(cryptStatus == kCCSuccess) {
|
||||||
|
NSData *plaindata = [NSData dataWithBytes:buffer length:(NSUInteger)numBytesDecrypted];
|
||||||
|
plaintext = [[NSString alloc]initWithData:plaindata encoding:NSUTF8StringEncoding];
|
||||||
|
}
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
@end
|
||||||
|
|
51
yana/Utils/Security/DESEncryptOCTest.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// OC版本DES加密测试
|
||||||
|
struct DESEncryptOCTest {
|
||||||
|
|
||||||
|
/// 测试 OC 版本的 DES 加密功能
|
||||||
|
static func testOCDESEncryption() {
|
||||||
|
print("🧪 开始测试 OC 版本的 DES 加密...")
|
||||||
|
print(String(repeating: "=", count: 50))
|
||||||
|
|
||||||
|
let key = "1ea53d260ecf11e7b56e00163e046a26"
|
||||||
|
let testCases = [
|
||||||
|
"test123",
|
||||||
|
"hello world",
|
||||||
|
"password123",
|
||||||
|
"sample_data",
|
||||||
|
"encrypt_test"
|
||||||
|
]
|
||||||
|
|
||||||
|
for testCase in testCases {
|
||||||
|
if let encrypted = DESEncrypt.encryptUseDES(testCase, key: key) {
|
||||||
|
print("✅ 加密成功:")
|
||||||
|
print(" 原文: \"\(testCase)\"")
|
||||||
|
print(" 密文: \(encrypted)")
|
||||||
|
|
||||||
|
// 测试解密
|
||||||
|
if let decrypted = DESEncrypt.decryptUseDES(encrypted, key: key) {
|
||||||
|
let isMatch = decrypted == testCase
|
||||||
|
print(" 解密: \"\(decrypted)\" \(isMatch ? "✅" : "❌")")
|
||||||
|
} else {
|
||||||
|
print(" 解密: 失败 ❌")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("❌ 加密失败: \"\(testCase)\"")
|
||||||
|
}
|
||||||
|
print()
|
||||||
|
}
|
||||||
|
|
||||||
|
print(String(repeating: "=", count: 50))
|
||||||
|
print("🏁 OC版本DES加密测试完成")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
extension DESEncryptOCTest {
|
||||||
|
/// 在 AppDelegate 中调用此方法进行测试
|
||||||
|
static func runInAppDelegate() {
|
||||||
|
DESEncryptOCTest.testOCDESEncryption()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
41
yana/Utils/ValidationHelper.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
68
yana/Views/AppRootView.swift
Normal 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()
|
||||||
|
}
|
59
yana/Views/Components/LoginButton.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Login Button Component
|
||||||
|
struct LoginButton: View {
|
||||||
|
let iconName: String
|
||||||
|
let iconColor: Color
|
||||||
|
let title: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
ZStack {
|
||||||
|
// 背景
|
||||||
|
Color.white
|
||||||
|
.cornerRadius(28)
|
||||||
|
|
||||||
|
// 居中的文本
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.frame(alignment: .center)
|
||||||
|
.foregroundColor(Color(hex: 0x313131))
|
||||||
|
|
||||||
|
// 左侧图标
|
||||||
|
HStack {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.foregroundColor(iconColor)
|
||||||
|
.font(.system(size: 30))
|
||||||
|
.padding(.leading, 33)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 56)
|
||||||
|
.padding(.horizontal, 29)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
LoginButton(
|
||||||
|
iconName: "person.circle.fill",
|
||||||
|
iconColor: .green,
|
||||||
|
title: "ID Login"
|
||||||
|
) {
|
||||||
|
// Preview action
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginButton(
|
||||||
|
iconName: "envelope.fill",
|
||||||
|
iconColor: .blue,
|
||||||
|
title: "Email Login"
|
||||||
|
) {
|
||||||
|
// Preview action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.gray.opacity(0.2))
|
||||||
|
}
|
88
yana/Views/Components/UserAgreementView.swift
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - User Agreement View Component
|
||||||
|
struct UserAgreementView: View {
|
||||||
|
@Binding var isAgreed: Bool
|
||||||
|
let onUserServiceTapped: () -> Void
|
||||||
|
let onPrivacyPolicyTapped: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
// 左侧勾选按钮
|
||||||
|
Button(action: {
|
||||||
|
isAgreed.toggle()
|
||||||
|
}) {
|
||||||
|
Image(systemName: isAgreed ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundColor(isAgreed ? Color(hex: 0x8A4FFF) : Color(hex: 0x666666))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧富文本
|
||||||
|
Text(createAttributedText())
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.environment(\.openURL, OpenURLAction { url in
|
||||||
|
if url.absoluteString == "user-service-agreement" {
|
||||||
|
onUserServiceTapped()
|
||||||
|
return .handled
|
||||||
|
} else if url.absoluteString == "privacy-policy" {
|
||||||
|
onPrivacyPolicyTapped()
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
return .systemAction
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity) // 占满可用宽度
|
||||||
|
.padding(.horizontal, 29) // 与登录按钮保持一致的边距
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
private func createAttributedText() -> AttributedString {
|
||||||
|
var attributedString = AttributedString("login.agreement_policy".localized)
|
||||||
|
|
||||||
|
// 设置默认颜色
|
||||||
|
attributedString.foregroundColor = Color(hex: 0x666666)
|
||||||
|
|
||||||
|
// 找到并设置 "用户协议" 的样式和链接
|
||||||
|
if let userServiceRange = attributedString.range(of: "login.agreement".localized) {
|
||||||
|
attributedString[userServiceRange].foregroundColor = Color(hex: 0x8A4FFF)
|
||||||
|
attributedString[userServiceRange].underlineStyle = .single
|
||||||
|
attributedString[userServiceRange].link = URL(string: "user-service-agreement")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到并设置 "隐私政策" 的样式和链接
|
||||||
|
if let privacyPolicyRange = attributedString.range(of: "login.policy".localized) {
|
||||||
|
attributedString[privacyPolicyRange].foregroundColor = Color(hex: 0x8A4FFF)
|
||||||
|
attributedString[privacyPolicyRange].underlineStyle = .single
|
||||||
|
attributedString[privacyPolicyRange].link = URL(string: "privacy-policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributedString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
UserAgreementView(
|
||||||
|
isAgreed: .constant(true),
|
||||||
|
onUserServiceTapped: {
|
||||||
|
print("User Service Agreement tapped")
|
||||||
|
},
|
||||||
|
onPrivacyPolicyTapped: {
|
||||||
|
print("Privacy Policy tapped")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
UserAgreementView(
|
||||||
|
isAgreed: .constant(true),
|
||||||
|
onUserServiceTapped: {
|
||||||
|
print("User Service Agreement tapped")
|
||||||
|
},
|
||||||
|
onPrivacyPolicyTapped: {
|
||||||
|
print("Privacy Policy tapped")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
}
|
55
yana/Views/Components/WebView.swift
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SafariServices
|
||||||
|
|
||||||
|
// MARK: - Web View Component
|
||||||
|
struct WebView: UIViewControllerRepresentable {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> SFSafariViewController {
|
||||||
|
let config = SFSafariViewController.Configuration()
|
||||||
|
config.entersReaderIfAvailable = false
|
||||||
|
config.barCollapsingEnabled = true
|
||||||
|
|
||||||
|
let safariViewController = SFSafariViewController(url: url, configuration: config)
|
||||||
|
safariViewController.preferredBarTintColor = UIColor.systemBackground
|
||||||
|
safariViewController.preferredControlTintColor = UIColor.systemBlue
|
||||||
|
|
||||||
|
return safariViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
|
||||||
|
// Safari View Controller 不需要更新
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Web View Modifier
|
||||||
|
extension View {
|
||||||
|
/// 显示 Web 页面的修饰符
|
||||||
|
/// - Parameters:
|
||||||
|
/// - isPresented: 是否显示的绑定变量
|
||||||
|
/// - url: 要显示的 URL
|
||||||
|
/// - Returns: 修饰后的视图
|
||||||
|
func webView(isPresented: Binding<Bool>, url: URL?) -> some View {
|
||||||
|
self.sheet(isPresented: isPresented) {
|
||||||
|
if let url = url {
|
||||||
|
WebView(url: url)
|
||||||
|
} else {
|
||||||
|
Text("无法加载页面")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack {
|
||||||
|
Button("打开网页") {
|
||||||
|
// 预览时不执行任何操作
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.webView(
|
||||||
|
isPresented: .constant(true),
|
||||||
|
url: URL(string: "https://www.apple.com")
|
||||||
|
)
|
||||||
|
}
|
271
yana/Views/EMailLoginView.swift
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ComposableArchitecture
|
||||||
|
|
||||||
|
struct EMailLoginView: View {
|
||||||
|
let store: StoreOf<EMailLoginFeature>
|
||||||
|
let onBack: () -> Void
|
||||||
|
|
||||||
|
// 使用本地@State管理UI状态
|
||||||
|
@State private var email: String = ""
|
||||||
|
@State private var verificationCode: String = ""
|
||||||
|
@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
@@ -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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
225
yana/Views/IDLoginView.swift
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ComposableArchitecture
|
||||||
|
|
||||||
|
struct IDLoginView: View {
|
||||||
|
let store: StoreOf<IDLoginFeature>
|
||||||
|
let onBack: () -> Void
|
||||||
|
|
||||||
|
// 使用本地@State管理UI状态
|
||||||
|
@State private var userID: String = ""
|
||||||
|
@State private var password: String = ""
|
||||||
|
@State private var isPasswordVisible: Bool = false
|
||||||
|
@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: {}
|
||||||
|
)
|
||||||
|
}
|
96
yana/Views/LanguageSettingsView.swift
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ComposableArchitecture
|
||||||
|
|
||||||
|
struct LanguageSettingsView: View {
|
||||||
|
@ObservedObject private var localizationManager = LocalizationManager.shared
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
|
init(isPresented: Binding<Bool> = .constant(true)) {
|
||||||
|
self._isPresented = isPresented
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(LocalizationManager.SupportedLanguage.allCases, id: \.rawValue) { language in
|
||||||
|
LanguageRow(
|
||||||
|
language: language,
|
||||||
|
isSelected: localizationManager.currentLanguage == language
|
||||||
|
) {
|
||||||
|
localizationManager.switchLanguage(to: language)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("选择语言 / Select Language")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("当前语言 / Current Language")
|
||||||
|
.font(.body)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(localizationManager.currentLanguage.localizedDisplayName)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("语言信息 / Language Info")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("语言设置 / Language")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("返回 / Back") {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LanguageRow: View {
|
||||||
|
let language: LocalizationManager.SupportedLanguage
|
||||||
|
let isSelected: Bool
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onTap) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(language.localizedDisplayName)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
|
Text(language.displayName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.font(.system(size: 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
LanguageSettingsView(isPresented: .constant(true))
|
||||||
|
}
|
179
yana/Views/LoginView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
309
yana/Views/RecoverPasswordView.swift
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ComposableArchitecture
|
||||||
|
|
||||||
|
struct RecoverPasswordView: View {
|
||||||
|
let store: StoreOf<RecoverPasswordFeature>
|
||||||
|
let onBack: () -> Void
|
||||||
|
|
||||||
|
// 使用本地@State管理UI状态
|
||||||
|
@State private var email: String = ""
|
||||||
|
@State private var verificationCode: String = ""
|
||||||
|
@State private var newPassword: String = ""
|
||||||
|
@State private var isNewPasswordVisible: Bool = false
|
||||||
|
|
||||||
|
// 验证码倒计时状态
|
||||||
|
@State private var countdown: Int = 0
|
||||||
|
@State private var countdownTimer: Timer?
|
||||||
|
|
||||||
|
// 计算确认按钮是否可用
|
||||||
|
private var isConfirmButtonEnabled: Bool {
|
||||||
|
return !store.isResetLoading && !email.isEmpty && !verificationCode.isEmpty && !newPassword.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算获取验证码按钮是否可用
|
||||||
|
private var isGetCodeButtonEnabled: Bool {
|
||||||
|
return !store.isCodeLoading && !email.isEmpty && countdown == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算获取验证码按钮文本
|
||||||
|
private var getCodeButtonText: String {
|
||||||
|
if store.isCodeLoading {
|
||||||
|
return ""
|
||||||
|
} else if countdown > 0 {
|
||||||
|
return "\(countdown)s"
|
||||||
|
} else {
|
||||||
|
return "recover_password.get_code".localized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
// 背景图片
|
||||||
|
Image("bg")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.ignoresSafeArea(.all)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// 顶部导航栏
|
||||||
|
HStack {
|
||||||
|
Button(action: {
|
||||||
|
onBack()
|
||||||
|
}) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 24, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 60)
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
Text("recover_password.title".localized)
|
||||||
|
.font(.system(size: 28, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.bottom, 80)
|
||||||
|
|
||||||
|
// 输入框区域
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
// 邮箱输入框
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 25)
|
||||||
|
.fill(Color.white.opacity(0.1))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 25)
|
||||||
|
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.frame(height: 56)
|
||||||
|
|
||||||
|
TextField("", text: $email)
|
||||||
|
.placeholder(when: email.isEmpty) {
|
||||||
|
Text("recover_password.placeholder_email".localized)
|
||||||
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证码输入框(带获取按钮)
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 25)
|
||||||
|
.fill(Color.white.opacity(0.1))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 25)
|
||||||
|
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.frame(height: 56)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
TextField("", text: $verificationCode)
|
||||||
|
.placeholder(when: verificationCode.isEmpty) {
|
||||||
|
Text("recover_password.placeholder_verification_code".localized)
|
||||||
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
|
||||||
|
// 获取验证码按钮
|
||||||
|
Button(action: {
|
||||||
|
// 立即开始倒计时
|
||||||
|
startCountdown()
|
||||||
|
// 发送API请求
|
||||||
|
store.send(.getVerificationCodeTapped)
|
||||||
|
}) {
|
||||||
|
ZStack {
|
||||||
|
if store.isCodeLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.scaleEffect(0.7)
|
||||||
|
} else {
|
||||||
|
Text(getCodeButtonText)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 60, height: 36)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 15)
|
||||||
|
.fill(Color.white.opacity(isGetCodeButtonEnabled ? 0.2 : 0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.disabled(!isGetCodeButtonEnabled || store.isCodeLoading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新密码输入框
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 25)
|
||||||
|
.fill(Color.white.opacity(0.1))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 25)
|
||||||
|
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
.frame(height: 56)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if isNewPasswordVisible {
|
||||||
|
TextField("", text: $newPassword)
|
||||||
|
.placeholder(when: newPassword.isEmpty) {
|
||||||
|
Text("recover_password.placeholder_new_password".localized)
|
||||||
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
} else {
|
||||||
|
SecureField("", text: $newPassword)
|
||||||
|
.placeholder(when: newPassword.isEmpty) {
|
||||||
|
Text("recover_password.placeholder_new_password".localized)
|
||||||
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
isNewPasswordVisible.toggle()
|
||||||
|
}) {
|
||||||
|
Image(systemName: isNewPasswordVisible ? "eye.slash" : "eye")
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.font(.system(size: 18))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 80)
|
||||||
|
|
||||||
|
// 确认按钮
|
||||||
|
Button(action: {
|
||||||
|
store.send(.resetPasswordTapped)
|
||||||
|
}) {
|
||||||
|
ZStack {
|
||||||
|
// 渐变背景
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.85, green: 0.37, blue: 1.0), // #D85EFF
|
||||||
|
Color(red: 0.54, green: 0.31, blue: 1.0) // #8A4FFF
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 28))
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
if store.isResetLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
}
|
||||||
|
Text(store.isResetLoading ? "recover_password.resetting".localized : "recover_password.confirm_button".localized)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 56)
|
||||||
|
}
|
||||||
|
.disabled(!isConfirmButtonEnabled)
|
||||||
|
.opacity(isConfirmButtonEnabled ? 1.0 : 0.5)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
|
||||||
|
// 错误信息
|
||||||
|
if let errorMessage = store.errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// 每次进入页面都重置状态
|
||||||
|
store.send(.resetState)
|
||||||
|
|
||||||
|
email = ""
|
||||||
|
verificationCode = ""
|
||||||
|
newPassword = ""
|
||||||
|
isNewPasswordVisible = false
|
||||||
|
countdown = 0
|
||||||
|
stopCountdown()
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
email = "exzero@126.com"
|
||||||
|
store.send(.emailChanged(email))
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
stopCountdown()
|
||||||
|
}
|
||||||
|
.onChange(of: email) { newEmail in
|
||||||
|
store.send(.emailChanged(newEmail))
|
||||||
|
}
|
||||||
|
.onChange(of: verificationCode) { newCode in
|
||||||
|
store.send(.verificationCodeChanged(newCode))
|
||||||
|
}
|
||||||
|
.onChange(of: newPassword) { newPassword in
|
||||||
|
store.send(.newPasswordChanged(newPassword))
|
||||||
|
}
|
||||||
|
.onChange(of: store.isCodeLoading) { isCodeLoading in
|
||||||
|
// 当API请求完成且成功时,自动将焦点切换到验证码输入框
|
||||||
|
if !isCodeLoading && store.errorMessage == nil {
|
||||||
|
// 可以在这里添加焦点切换逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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: {}
|
||||||
|
)
|
||||||
|
}
|
49
yana/Views/SplashView.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ComposableArchitecture
|
||||||
|
|
||||||
|
struct SplashView: View {
|
||||||
|
let store: StoreOf<SplashFeature>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
WithPerceptionTracking {
|
||||||
|
ZStack {
|
||||||
|
// 背景图片 - 全屏显示
|
||||||
|
Image("bg")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.ignoresSafeArea(.all)
|
||||||
|
|
||||||
|
VStack(spacing: 32) {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 200) // 与 storyboard 中的约束对应
|
||||||
|
|
||||||
|
// Logo 图片 - 100x100
|
||||||
|
Image("logo")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
// 应用标题 - 白色,40pt字体
|
||||||
|
Text("E-Parti")
|
||||||
|
.font(.system(size: 40, weight: .regular))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
store.send(.onAppear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
SplashView(
|
||||||
|
store: Store(
|
||||||
|
initialState: SplashFeature.State()
|
||||||
|
) {
|
||||||
|
SplashFeature()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@@ -2,3 +2,10 @@
|
|||||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
// DES 加密相关 OC 文件
|
||||||
|
#import "DESEncrypt.h"
|
||||||
|
#import "Base64.h"
|
||||||
|
|
||||||
|
// AES 加密相关 OC 文件
|
||||||
|
#import "AESUtils.h"
|
||||||
|
|
||||||
|
@@ -1,8 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict/>
|
||||||
<key>com.apple.external-accessory.wireless-configuration</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
</plist>
|
||||||
|
@@ -12,25 +12,21 @@ import ComposableArchitecture
|
|||||||
struct yanaApp: App {
|
struct yanaApp: App {
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@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 {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView(
|
AppRootView()
|
||||||
store: Store(
|
|
||||||
initialState: LoginFeature.State()
|
|
||||||
) {
|
|
||||||
LoginFeature()
|
|
||||||
},
|
|
||||||
initStore: Store(
|
|
||||||
initialState: InitFeature.State()
|
|
||||||
) {
|
|
||||||
InitFeature()
|
|
||||||
},
|
|
||||||
configStore: Store(
|
|
||||||
initialState: ConfigFeature.State()
|
|
||||||
) {
|
|
||||||
ConfigFeature()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -33,36 +33,143 @@ final class yanaAPITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testClientInit_Success() {
|
func testIDLoginRequest_Creation() {
|
||||||
let expectation = self.expectation(description: "clientInit success")
|
// 测试ID登录请求的创建
|
||||||
API.clientInit { result in
|
let userID = "399113"
|
||||||
switch result {
|
let password = "a123456"
|
||||||
case .success(let data):
|
|
||||||
XCTAssertNotNil(data)
|
let request = LoginHelper.createIDLoginRequest(userID: userID, password: password)
|
||||||
// 可根据实际返回内容进一步断言
|
XCTAssertNotNil(request, "登录请求应该创建成功")
|
||||||
case .failure(let error):
|
XCTAssertEqual(request?.endpoint, "/oauth/token", "端点应该正确")
|
||||||
XCTFail("Expected success, got error: \(error)")
|
XCTAssertEqual(request?.method, .POST, "请求方法应该是POST")
|
||||||
}
|
}
|
||||||
expectation.fulfill()
|
|
||||||
}
|
func testIDLoginResponse_Success() {
|
||||||
waitForExpectations(timeout: 5, handler: nil)
|
// 测试登录响应的成功解析
|
||||||
|
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() {
|
func testAccountModelFlow() {
|
||||||
// 可通过mock或断网等方式测试失败场景
|
// 测试完整的 AccountModel 流程
|
||||||
// 这里只做结构示例
|
|
||||||
let expectation = self.expectation(description: "clientInit failure")
|
// 1. 模拟 oauth/token 响应(基于用户提供的真实数据)
|
||||||
// 假设API支持注入baseURL或mock
|
let loginData = IDLoginData(
|
||||||
API.clientInit { result in
|
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test",
|
||||||
switch result {
|
refreshToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh",
|
||||||
case .success(_):
|
tokenType: "bearer",
|
||||||
// 若期望失败则此处应fail
|
expiresIn: 2591999,
|
||||||
XCTFail("Expected failure, got success")
|
scope: "read write",
|
||||||
case .failure(let error):
|
userInfo: nil, // 真实API没有返回user_info
|
||||||
XCTAssertNotNil(error)
|
uid: 3184,
|
||||||
}
|
netEaseToken: "6fba51065b5e32ad18a935438517a1a9",
|
||||||
expectation.fulfill()
|
jti: "d3a82ddb-ea6f-4d2f-8dc7-7bdb3d6b9e87"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. 从 IDLoginData 创建 AccountModel
|
||||||
|
let accountModel = AccountModel.from(loginData: loginData)
|
||||||
|
|
||||||
|
XCTAssertNotNil(accountModel, "应该能从IDLoginData创建AccountModel")
|
||||||
|
XCTAssertEqual(accountModel?.uid, "3184", "UID应该正确转换为字符串")
|
||||||
|
XCTAssertEqual(accountModel?.accessToken, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test", "Access Token应该正确")
|
||||||
|
XCTAssertEqual(accountModel?.tokenType, "bearer", "Token类型应该正确")
|
||||||
|
XCTAssertEqual(accountModel?.netEaseToken, "6fba51065b5e32ad18a935438517a1a9", "网易云Token应该正确")
|
||||||
|
XCTAssertNil(accountModel?.ticket, "初始ticket应该为空")
|
||||||
|
|
||||||
|
// 3. 检查认证状态
|
||||||
|
XCTAssertTrue(accountModel?.hasValidAuthentication ?? false, "应该有有效的认证")
|
||||||
|
XCTAssertFalse(accountModel?.hasValidSession ?? true, "没有ticket时不应该有有效会话")
|
||||||
|
|
||||||
|
// 4. 模拟获取ticket后更新AccountModel
|
||||||
|
let ticketString = "eyJhbGciOiJIUzI1NiJ9.ticket"
|
||||||
|
let updatedAccountModel = accountModel?.withTicket(ticketString)
|
||||||
|
|
||||||
|
XCTAssertNotNil(updatedAccountModel, "应该能更新ticket")
|
||||||
|
XCTAssertEqual(updatedAccountModel?.ticket, ticketString, "Ticket应该正确设置")
|
||||||
|
XCTAssertTrue(updatedAccountModel?.hasValidSession ?? false, "有ticket时应该有有效会话")
|
||||||
|
|
||||||
|
// 5. 测试持久化和加载
|
||||||
|
if let finalAccountModel = updatedAccountModel {
|
||||||
|
UserInfoManager.saveAccountModel(finalAccountModel)
|
||||||
|
|
||||||
|
let loadedAccountModel = UserInfoManager.getAccountModel()
|
||||||
|
XCTAssertNotNil(loadedAccountModel, "应该能加载保存的AccountModel")
|
||||||
|
XCTAssertEqual(loadedAccountModel?.uid, "3184", "加载的UID应该正确")
|
||||||
|
XCTAssertEqual(loadedAccountModel?.accessToken, finalAccountModel.accessToken, "加载的Access Token应该正确")
|
||||||
|
XCTAssertEqual(loadedAccountModel?.ticket, ticketString, "加载的Ticket应该正确")
|
||||||
|
|
||||||
|
// 6. 测试向后兼容性
|
||||||
|
let userId = UserInfoManager.getCurrentUserId()
|
||||||
|
let accessToken = UserInfoManager.getAccessToken()
|
||||||
|
let ticket = UserInfoManager.getCurrentUserTicket()
|
||||||
|
|
||||||
|
XCTAssertEqual(userId, "3184", "向后兼容的用户ID应该正确")
|
||||||
|
XCTAssertEqual(accessToken, finalAccountModel.accessToken, "向后兼容的Access Token应该正确")
|
||||||
|
XCTAssertEqual(ticket, ticketString, "向后兼容的Ticket应该正确")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 清理
|
||||||
|
UserInfoManager.clearAllAuthenticationData()
|
||||||
|
XCTAssertNil(UserInfoManager.getAccountModel(), "清理后AccountModel应该为空")
|
||||||
|
XCTAssertNil(UserInfoManager.getCurrentUserId(), "清理后用户ID应该为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAccountModelWithRealAPIData() {
|
||||||
|
// 使用用户提供的真实API返回数据进行测试
|
||||||
|
let realAPIResponseData: [String: Any] = [
|
||||||
|
"uid": 3184,
|
||||||
|
"jti": "d3a82ddb-ea6f-4d2f-8dc7-7bdb3d6b9e87",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"scope": "read write",
|
||||||
|
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiIyMzU2ODE0Iiwic2NvcGUiOlsicmVhZCIsIndyaXRlIl0sImF0aSI6ImQzYTgyZGRiLWVhNmYtNGQyZi04ZGM3LTdiZGIzZDZiOWU4NyIsImV4cCI6MTc1NTI0MjY5MiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9hY2NvdW50IiwiUk9MRV9NT0JJTEUiLCJST0xFX1VOSVRZIl0sImp0aSI6ImFiZjhjN2ZjLTllOWEtNDE2Yy04NTk2LTBkMWYxZWQyODU2MiIsImNsaWVudF9pZCI6ImVyYmFuLWNsaWVudCJ9.6i_9FnZvviuWYIoXDv9of7EDRyjRVxNbkiHayNUFxNw",
|
||||||
|
"netEaseToken": "6fba51065b5e32ad18a935438517a1a9",
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTQ2Mzc4OTIsInVzZXJfbmFtZSI6IjIzNTY4MTQiLCJhdXRob3JpdGllcyI6WyJST0xFX2FjY291bnQiLCJST0xFX01PQklMRSIsIlJPTEVfVU5JVFkiXSwianRpIjoiZDNhODJkZGItZWE2Zi00ZDJmLThkYzctN2JkYjNkNmI5ZTg3IiwiY2xpZW50X2lkIjoiZXJiYW4tY2xpZW50Iiwic2NvcGUiOlsicmVhZCIsIndyaXRlIl19.ynUptBtAoPVXz4J1AO8LbaAhmFRF4UnF4C-Ggj6Izpc",
|
||||||
|
"expires_in": 2591999
|
||||||
|
]
|
||||||
|
|
||||||
|
// 模拟JSON解析
|
||||||
|
do {
|
||||||
|
let jsonData = try JSONSerialization.data(withJSONObject: realAPIResponseData)
|
||||||
|
let loginData = try JSONDecoder().decode(IDLoginData.self, from: jsonData)
|
||||||
|
|
||||||
|
// 创建AccountModel
|
||||||
|
let accountModel = AccountModel.from(loginData: loginData)
|
||||||
|
|
||||||
|
XCTAssertNotNil(accountModel, "应该能从真实API数据创建AccountModel")
|
||||||
|
XCTAssertEqual(accountModel?.uid, "3184", "真实API数据的UID应该正确")
|
||||||
|
XCTAssertTrue(accountModel?.hasValidAuthentication ?? false, "真实API数据应该有有效认证")
|
||||||
|
|
||||||
|
print("✅ 真实API数据测试通过")
|
||||||
|
print(" UID: \(accountModel?.uid ?? "nil")")
|
||||||
|
print(" Access Token存在: \(accountModel?.accessToken != nil)")
|
||||||
|
print(" Token类型: \(accountModel?.tokenType ?? "nil")")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
XCTFail("解析真实API数据失败: \(error)")
|
||||||
}
|
}
|
||||||
waitForExpectations(timeout: 5, handler: nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|