Compare commits
9 Commits
f686480cdc
...
d4bef537d9
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d4bef537d9 | ||
![]() |
ba991598be | ||
![]() |
5f65df0e7f | ||
![]() |
9a49d591c3 | ||
![]() |
fb7ae9e0ad | ||
![]() |
128bf36c88 | ||
![]() |
4bbb4f8434 | ||
![]() |
33a558ae7b | ||
![]() |
1f98ed534d |
@@ -3,56 +3,30 @@ description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# CONTEXT
|
||||
I wish to receive advice using the latest tools and seek step-by-step guidance to fully understand the implementation process.
|
||||
|
||||
# CONTEXT
|
||||
|
||||
I am a native Chinese speaker who has just begun learning Swift 6 and Xcode 15+, and I am enthusiastic about exploring new technologies. I wish to receive advice using the latest tools and
|
||||
seek step-by-step guidance to fully understand the implementation process. Since many excellent code resources are in English, I hope my questions can be thoroughly understood. Therefore,
|
||||
I would like the AI assistant to think and reason in English, then translate the English responses into Chinese for me.
|
||||
|
||||
---
|
||||
|
||||
# OBJECTIVE
|
||||
|
||||
As an expert AI programming assistant, your task is to provide me with clear and readable SwiftUI code. You should:
|
||||
|
||||
- Utilize the latest versions of SwiftUI and Swift, being familiar with the newest features and best practices.
|
||||
- Provide careful and accurate answers that are well-founded and thoughtfully considered.
|
||||
- **Explicitly use the Chain-of-Thought (CoT) method in your reasoning and answers, explaining your thought process step by step.**
|
||||
- Strictly adhere to my requirements and meticulously complete the tasks.
|
||||
- Begin by outlining your proposed approach with detailed steps or pseudocode.
|
||||
- Upon confirming the plan, proceed to write the code.
|
||||
|
||||
---
|
||||
|
||||
|
||||
# STYLE
|
||||
|
||||
- Keep answers concise and direct, minimizing unnecessary wording.
|
||||
- Emphasize code readability over performance optimization.
|
||||
- Maintain a professional and supportive tone, ensuring clarity of content.
|
||||
|
||||
---
|
||||
|
||||
# TONE
|
||||
|
||||
- Be positive and encouraging, helping me improve my programming skills.
|
||||
- Be professional and patient, assisting me in understanding each step.
|
||||
|
||||
---
|
||||
|
||||
# AUDIENCE
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
The target audience is me, a native Chinese developer eager to learn Swift 6 and Xcode 15.9, seeking guidance and advice on utilizing the latest technologies.
|
||||
|
||||
# RESPONSE FORMAT
|
||||
|
||||
- **Utilize the Chain-of-Thought (CoT) method to reason and respond, explaining your thought process step by step.**
|
||||
- Conduct reasoning, thinking, and code writing in English.
|
||||
- The final reply should translate the English into Chinese for me.
|
||||
- The reply should include:
|
||||
|
||||
1. **Step-by-Step Plan**: Describe the implementation process with detailed pseudocode or step-by-step explanations, showcasing your thought process.
|
||||
2. **Code Implementation**: Provide correct, up-to-date, error-free, fully functional, runnable, secure, and efficient code. The code should:
|
||||
- Include all necessary imports and properly name key components.
|
||||
@@ -60,10 +34,3 @@ alwaysApply: true
|
||||
3. **Concise Response**: Minimize unnecessary verbosity, focusing only on essential information.
|
||||
|
||||
- If a correct answer may not exist, please point it out. If you do not know the answer, please honestly inform me rather than guessing.
|
||||
|
||||
---
|
||||
|
||||
# START ANALYSIS
|
||||
|
||||
If you understand, please prepare to assist me and await my question.
|
||||
|
@@ -1,7 +1,5 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
alwaysApply: false
|
||||
---
|
||||
You are an expert iOS developer using Swift and SwiftUI. Follow these guidelines:
|
||||
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,3 +8,7 @@ yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkp
|
||||
.cursor
|
||||
.swiftpm
|
||||
yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
|
||||
Doc
|
||||
DerivedData
|
||||
.kiro
|
||||
yana.xcworkspace/xcuserdata
|
||||
|
79
CreateFeedView-Analysis.md
Normal file
79
CreateFeedView-Analysis.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# CreateFeedView UI 结构分析与执行计划
|
||||
|
||||
## UI 结构分析
|
||||
|
||||
根据设计稿,CreateFeedView 应包含以下UI元素:
|
||||
|
||||
### 1. 顶部导航栏
|
||||
- 左侧:返回按钮
|
||||
- 中间:"图文发布" 标题
|
||||
- 右侧:"发布" 按钮
|
||||
|
||||
### 2. 主要内容区域
|
||||
- 文本输入框:"Enter Content" 占位符,支持多行输入,最大500字符
|
||||
- 字符计数显示:"0/500" 格式
|
||||
- 图片添加区域:
|
||||
- 默认显示一个 "+" 按钮(使用 "add photo" 图片资源)
|
||||
- 支持添加最多9张图片
|
||||
- 图片以网格形式排列
|
||||
- 每张图片可以删除
|
||||
|
||||
### 3. 底部发布按钮
|
||||
- 紫色渐变背景的"发布"按钮
|
||||
- 占据屏幕底部,固定位置
|
||||
|
||||
## 执行计划
|
||||
|
||||
### 第一步:创建 CreateFeedFeature
|
||||
- 定义状态管理结构
|
||||
- 实现文本输入、图片选择、发布等Action
|
||||
- 添加表单验证逻辑
|
||||
- 集成图片选择器
|
||||
|
||||
### 第二步:创建 CreateFeedView
|
||||
- 实现顶部导航栏
|
||||
- 创建文本输入区域
|
||||
- 实现图片选择和展示网格
|
||||
- 添加发布按钮
|
||||
- 应用深色主题样式
|
||||
|
||||
### 第三步:集成到 FeedView
|
||||
- 修改 FeedView 中的加号按钮点击事件
|
||||
- 添加导航到 CreateFeedView 的逻辑
|
||||
- 确保返回时能刷新动态列表
|
||||
|
||||
### 第四步:创建发布API模型
|
||||
- 定义发布动态的请求和响应模型
|
||||
- 添加API端点定义
|
||||
- 实现发布逻辑(模拟或真实API)
|
||||
|
||||
### 第五步:测试和优化
|
||||
- 测试各种输入场景
|
||||
- 验证图片选择和预览功能
|
||||
- 确保UI响应和交互流畅
|
||||
|
||||
## 技术要点
|
||||
|
||||
1. **状态管理**:使用 ComposableArchitecture 模式
|
||||
2. **图片选择**:使用 PhotosUI 框架
|
||||
3. **UI样式**:保持与现有深色主题一致
|
||||
4. **表单验证**:实时字符计数和输入限制
|
||||
5. **导航管理**:使用 NavigationStack 或 sheet 展示
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
yana/
|
||||
├── Features/
|
||||
│ └── CreateFeedFeature.swift # 新建
|
||||
├── Views/
|
||||
│ └── CreateFeedView.swift # 新建
|
||||
├── APIs/
|
||||
│ ├── APIEndpoints.swift # 修改:添加发布端点
|
||||
│ └── DynamicsModels.swift # 修改:添加发布模型
|
||||
└── Assets.xcassets/
|
||||
└── Home/
|
||||
└── add photo.imageset/ # 已存在
|
||||
```
|
||||
|
||||
开始实施第一步:创建 CreateFeedFeature。
|
@@ -12,10 +12,10 @@
|
||||
{
|
||||
"identity" : "swift-case-paths",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths.git",
|
||||
"state" : {
|
||||
"revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25",
|
||||
"version" : "1.7.0"
|
||||
"branch" : "main",
|
||||
"revision" : "9810c8d6c2914de251e072312f01d3bf80071852"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -15,7 +15,8 @@ let package = Package(
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.8.0"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.20.2"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-case-paths.git", branch: "main")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -29,4 +30,4 @@ let package = Package(
|
||||
dependencies: ["yana"]
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
5
Podfile
5
Podfile
@@ -1,5 +1,5 @@
|
||||
# Uncomment the next line to define a global platform for your project
|
||||
platform :ios, '13.0'
|
||||
platform :ios, '16.0'
|
||||
|
||||
target 'yana' do
|
||||
# Comment the next line if you don't want to use dynamic frameworks
|
||||
@@ -17,6 +17,9 @@ target 'yana' do
|
||||
|
||||
# Networks
|
||||
pod 'Alamofire'
|
||||
|
||||
# 腾讯云 COS 精简版 SDK
|
||||
pod 'QCloudCOSXML'
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
|
18
Podfile.lock
18
Podfile.lock
@@ -1,16 +1,32 @@
|
||||
PODS:
|
||||
- Alamofire (5.10.2)
|
||||
- QCloudCore (6.5.1):
|
||||
- QCloudCore/Default (= 6.5.1)
|
||||
- QCloudCore/Default (6.5.1):
|
||||
- QCloudTrack/Beacon (= 6.5.1)
|
||||
- QCloudCOSXML (6.5.1):
|
||||
- QCloudCOSXML/Default (= 6.5.1)
|
||||
- QCloudCOSXML/Default (6.5.1):
|
||||
- QCloudCore (= 6.5.1)
|
||||
- QCloudTrack/Beacon (6.5.1)
|
||||
|
||||
DEPENDENCIES:
|
||||
- Alamofire
|
||||
- QCloudCOSXML
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- Alamofire
|
||||
- QCloudCore
|
||||
- QCloudCOSXML
|
||||
- QCloudTrack
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||
QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798
|
||||
QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7
|
||||
QCloudTrack: 20b79388365b4c8ed150019c82a56f1569f237f8
|
||||
|
||||
PODFILE CHECKSUM: 4ccb5fbbedd3dcb71c35d00e7bfd0d280d4ced88
|
||||
PODFILE CHECKSUM: cd339c4c75faaa076936a34cac2be597c64f138a
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
@@ -10,6 +10,8 @@
|
||||
4C4C91092DE85A3E00384527 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */; };
|
||||
4C4C910B2DE85A4F00384527 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */; };
|
||||
4C4C91302DE864F000384527 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 4C4C912F2DE864F000384527 /* ComposableArchitecture */; };
|
||||
4CE9EFEA2E28FC3B0078D046 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = 4CE9EFE92E28FC3B0078D046 /* CasePaths */; };
|
||||
4CE9EFEC2E28FC3B0078D046 /* CasePathsCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4CE9EFEB2E28FC3B0078D046 /* CasePathsCore */; };
|
||||
DF26704F4C1F2ABA0405A0C3 /* Pods_yana.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E9DBA38268C435BE06F39AE2 /* Pods_yana.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -65,7 +67,9 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4CE9EFEA2E28FC3B0078D046 /* CasePaths in Frameworks */,
|
||||
4C4C91092DE85A3E00384527 /* SystemConfiguration.framework in Frameworks */,
|
||||
4CE9EFEC2E28FC3B0078D046 /* CasePathsCore in Frameworks */,
|
||||
4C4C91302DE864F000384527 /* ComposableArchitecture in Frameworks */,
|
||||
4C4C910B2DE85A4F00384527 /* CoreFoundation.framework in Frameworks */,
|
||||
DF26704F4C1F2ABA0405A0C3 /* Pods_yana.framework in Frameworks */,
|
||||
@@ -144,6 +148,7 @@
|
||||
4C3E651C2DB61F7A00E5A455 /* Frameworks */,
|
||||
4C3E651D2DB61F7A00E5A455 /* Resources */,
|
||||
0C5E1A8360250314246001A5 /* [CP] Embed Pods Frameworks */,
|
||||
0BECA6AFA61BE910372F6299 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -186,7 +191,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1630;
|
||||
LastUpgradeCheck = 1630;
|
||||
LastUpgradeCheck = 1640;
|
||||
TargetAttributes = {
|
||||
4C3E651E2DB61F7A00E5A455 = {
|
||||
CreatedOnToolsVersion = 16.3;
|
||||
@@ -209,6 +214,7 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
4C4C912E2DE864F000384527 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */,
|
||||
4CE9EFE82E28FC3B0078D046 /* XCRemoteSwiftPackageReference "swift-case-paths" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 4C3E65202DB61F7A00E5A455 /* Products */;
|
||||
@@ -239,6 +245,27 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
0BECA6AFA61BE910372F6299 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
0C5E1A8360250314246001A5 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -315,6 +342,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -371,6 +399,7 @@
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 6.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -379,6 +408,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
@@ -427,6 +457,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_VERSION = 6.0;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
@@ -439,9 +470,11 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = yana/yana.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKM7RAGNA6;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = Z7UCRF23F3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -458,7 +491,7 @@
|
||||
"\"${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers\"",
|
||||
);
|
||||
INFOPLIST_FILE = yana/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = EParti;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "E-PARTi";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "此App将可发现和连接到您所用网络上的设备。";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“eparty”需要您的同意,才可以进行定位服务,访问网络状态";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -466,22 +499,24 @@
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
OTHER_LIBTOOLFLAGS = "-framework \"SystemConfiguration\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yana.yana;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.junpeiqi.eparty;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "yana/yana-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
@@ -494,9 +529,10 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = yana/yana.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKM7RAGNA6;
|
||||
DEVELOPMENT_TEAM = Z7UCRF23F3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -513,7 +549,7 @@
|
||||
"\"${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers\"",
|
||||
);
|
||||
INFOPLIST_FILE = yana/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = EParti;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "E-PARTi";
|
||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "此App将可发现和连接到您所用网络上的设备。";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“eparty”需要您的同意,才可以进行定位服务,访问网络状态";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -521,22 +557,24 @@
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
OTHER_LIBTOOLFLAGS = "-framework \"SystemConfiguration\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yana.yana;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.junpeiqi.eparty;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "yana/yana-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
@@ -558,7 +596,7 @@
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/yana.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/yana";
|
||||
};
|
||||
@@ -581,7 +619,7 @@
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/yana.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/yana";
|
||||
};
|
||||
@@ -628,6 +666,14 @@
|
||||
minimumVersion = 1.20.2;
|
||||
};
|
||||
};
|
||||
4CE9EFE82E28FC3B0078D046 /* XCRemoteSwiftPackageReference "swift-case-paths" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/pointfreeco/swift-case-paths";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@@ -636,6 +682,16 @@
|
||||
package = 4C4C912E2DE864F000384527 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */;
|
||||
productName = ComposableArchitecture;
|
||||
};
|
||||
4CE9EFE92E28FC3B0078D046 /* CasePaths */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 4CE9EFE82E28FC3B0078D046 /* XCRemoteSwiftPackageReference "swift-case-paths" */;
|
||||
productName = CasePaths;
|
||||
};
|
||||
4CE9EFEB2E28FC3B0078D046 /* CasePathsCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 4CE9EFE82E28FC3B0078D046 /* XCRemoteSwiftPackageReference "swift-case-paths" */;
|
||||
productName = CasePathsCore;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 4C3E65172DB61F7A00E5A455 /* Project object */;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "e7024a9cd3fa1c40fa4003b3b2b186c00ba720c787de8ba274caf8fc530677e8",
|
||||
"originHash" : "411c4947a4ddec377a0aac37852b26ccdf5b2dd58cb99bedfb2d4c108efd60fd",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
@@ -15,8 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||
"state" : {
|
||||
"revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25",
|
||||
"version" : "1.7.0"
|
||||
"branch" : "main",
|
||||
"revision" : "9810c8d6c2914de251e072312f01d3bf80071852"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
91
yana.xcodeproj/xcshareddata/xcschemes/yana.xcscheme
Normal file
91
yana.xcodeproj/xcshareddata/xcschemes/yana.xcscheme
Normal file
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4C3E651E2DB61F7A00E5A455"
|
||||
BuildableName = "yana.app"
|
||||
BlueprintName = "yana"
|
||||
ReferencedContainer = "container:yana.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4C4C8FBC2DE5AF9200384527"
|
||||
BuildableName = "yanaAPITests.xctest"
|
||||
BlueprintName = "yanaAPITests"
|
||||
ReferencedContainer = "container:yana.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4C3E651E2DB61F7A00E5A455"
|
||||
BuildableName = "yana.app"
|
||||
BlueprintName = "yana"
|
||||
ReferencedContainer = "container:yana.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4C3E651E2DB61F7A00E5A455"
|
||||
BuildableName = "yana.app"
|
||||
BlueprintName = "yana"
|
||||
ReferencedContainer = "container:yana.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@@ -10,5 +10,18 @@
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>4C3E651E2DB61F7A00E5A455</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>4C4C8FBC2DE5AF9200384527</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "e7024a9cd3fa1c40fa4003b3b2b186c00ba720c787de8ba274caf8fc530677e8",
|
||||
"originHash" : "411c4947a4ddec377a0aac37852b26ccdf5b2dd58cb99bedfb2d4c108efd60fd",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
@@ -15,8 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||
"state" : {
|
||||
"revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25",
|
||||
"version" : "1.7.0"
|
||||
"branch" : "main",
|
||||
"revision" : "9810c8d6c2914de251e072312f01d3bf80071852"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -87,8 +87,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-navigation",
|
||||
"state" : {
|
||||
"revision" : "db6bc9dbfed001f21e6728fd36413d9342c235b4",
|
||||
"version" : "2.3.0"
|
||||
"revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21",
|
||||
"version" : "2.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -123,8 +123,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"state" : {
|
||||
"revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4",
|
||||
"version" : "1.5.2"
|
||||
"revision" : "23e3442166b5122f73f9e3e622cd1e4bafeab3b7",
|
||||
"version" : "1.6.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@@ -1,40 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Bucket
|
||||
uuid = "A60FAB2A-3184-45B2-920F-A3D7A086CF95"
|
||||
type = "0"
|
||||
version = "2.0">
|
||||
<Breakpoints>
|
||||
<BreakpointProxy
|
||||
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
||||
<BreakpointContent
|
||||
uuid = "4D63F38A-4F7C-46D9-8CAF-BCA831664FA0"
|
||||
shouldBeEnabled = "Yes"
|
||||
ignoreCount = "0"
|
||||
continueAfterRunningActions = "No"
|
||||
filePath = "yana/APIs/APIService.swift"
|
||||
startingColumnNumber = "9223372036854775807"
|
||||
endingColumnNumber = "9223372036854775807"
|
||||
startingLineNumber = "126"
|
||||
endingLineNumber = "126"
|
||||
landmarkName = "request(_:)"
|
||||
landmarkType = "7">
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
<BreakpointProxy
|
||||
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
||||
<BreakpointContent
|
||||
uuid = "19930D63-5B42-4287-8B22-ADF87CAD40E3"
|
||||
shouldBeEnabled = "Yes"
|
||||
ignoreCount = "0"
|
||||
continueAfterRunningActions = "No"
|
||||
filePath = "yana/APIs/APIService.swift"
|
||||
startingColumnNumber = "9223372036854775807"
|
||||
endingColumnNumber = "9223372036854775807"
|
||||
startingLineNumber = "112"
|
||||
endingLineNumber = "112"
|
||||
landmarkName = "request(_:)"
|
||||
landmarkType = "7">
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
</Breakpoints>
|
||||
</Bucket>
|
@@ -20,6 +20,7 @@ enum APIEndpoint: String, CaseIterable {
|
||||
case ticket = "/oauth/ticket"
|
||||
case emailGetCode = "/email/getCode" // 新增:邮箱验证码获取端点
|
||||
case latestDynamics = "/dynamic/square/latestDynamics" // 新增:获取最新动态列表端点
|
||||
case tcToken = "/tencent/cos/getToken" // 新增:腾讯云 COS Token 获取端点
|
||||
// Web 页面路径
|
||||
case userAgreement = "/modules/rule/protocol.html"
|
||||
case privacyPolicy = "/modules/rule/privacy-wap.html"
|
||||
@@ -85,40 +86,30 @@ struct APIConfiguration {
|
||||
/// - 用户认证信息(如果已登录)
|
||||
///
|
||||
/// 这些头部会自动添加到每个请求中,确保服务器能够正确处理请求
|
||||
static var defaultHeaders: [String: String] {
|
||||
static func defaultHeaders() async -> [String: String] {
|
||||
var headers = [
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip, br",
|
||||
"Accept-Language": Locale.current.languageCode ?? "en",
|
||||
"Accept-Language": Locale.current.language.languageCode?.identifier ?? "en",
|
||||
"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
|
||||
let authStatus = UserInfoManager.checkAuthenticationStatus()
|
||||
|
||||
let authStatus = await UserInfoManager.checkAuthenticationStatus()
|
||||
if authStatus.canAutoLogin {
|
||||
// 添加用户认证相关 headers(仅在 AccountModel 有效时)
|
||||
if let userId = UserInfoManager.getCurrentUserId() {
|
||||
if let userId = await UserInfoManager.getCurrentUserId() {
|
||||
headers["pub_uid"] = userId
|
||||
#if DEBUG
|
||||
debugInfo("🔐 添加认证 header: pub_uid = \(userId)")
|
||||
#endif
|
||||
debugInfoSync("🔐 添加认证 header: pub_uid = \(userId)")
|
||||
}
|
||||
|
||||
if let userTicket = UserInfoManager.getCurrentUserTicket() {
|
||||
if let userTicket = await UserInfoManager.getCurrentUserTicket() {
|
||||
headers["pub_ticket"] = userTicket
|
||||
#if DEBUG
|
||||
debugInfo("🔐 添加认证 header: pub_ticket = \(userTicket.prefix(20))...")
|
||||
#endif
|
||||
debugInfoSync("🔐 添加认证 header: pub_ticket = \(userTicket.prefix(20))...")
|
||||
}
|
||||
} else {
|
||||
#if DEBUG
|
||||
debugInfo("🔐 跳过认证 header 添加 - 认证状态: \(authStatus.description)")
|
||||
#endif
|
||||
debugInfoSync("🔐 跳过认证 header 添加 - 认证状态: \(authStatus.description)")
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - API Logger
|
||||
@MainActor
|
||||
class APILogger {
|
||||
enum LogLevel {
|
||||
case none
|
||||
@@ -21,7 +22,12 @@ class APILogger {
|
||||
}()
|
||||
|
||||
// MARK: - Request Logging
|
||||
static func logRequest<T: APIRequestProtocol>(_ request: T, url: URL, body: Data?, finalHeaders: [String: String]? = nil) {
|
||||
@MainActor static func logRequest<T: APIRequestProtocol>(
|
||||
_ request: T,
|
||||
url: URL,
|
||||
body: Data?,
|
||||
finalHeaders: [String: String]? = nil
|
||||
) {
|
||||
#if DEBUG
|
||||
guard logLevel != .none else { return }
|
||||
#else
|
||||
|
@@ -111,9 +111,10 @@ struct BaseRequest: Codable {
|
||||
case pubSign = "pub_sign"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
init() {
|
||||
// 获取系统首选语言
|
||||
let preferredLanguage = Locale.current.languageCode ?? "en"
|
||||
let preferredLanguage = Locale.current.language.languageCode?.identifier ?? "en"
|
||||
self.acceptLanguage = preferredLanguage
|
||||
self.lang = preferredLanguage
|
||||
|
||||
@@ -237,6 +238,7 @@ struct CarrierInfoManager {
|
||||
|
||||
// MARK: - User Info Manager (for Headers)
|
||||
struct UserInfoManager {
|
||||
@MainActor
|
||||
private static let keychain = KeychainManager.shared
|
||||
|
||||
// MARK: - Storage Keys
|
||||
@@ -246,72 +248,66 @@ struct UserInfoManager {
|
||||
}
|
||||
|
||||
// MARK: - 内存缓存
|
||||
private static var accountModelCache: AccountModel?
|
||||
private static var userInfoCache: UserInfo?
|
||||
// 已迁移到 UserInfoCacheActor
|
||||
private static let cacheQueue = DispatchQueue(label: "com.yana.userinfo.cache", attributes: .concurrent)
|
||||
|
||||
// MARK: - User ID Management (基于 AccountModel)
|
||||
static func getCurrentUserId() -> String? {
|
||||
return getAccountModel()?.uid
|
||||
static func getCurrentUserId() async -> String? {
|
||||
return await getAccountModel()?.uid
|
||||
}
|
||||
|
||||
// MARK: - Access Token Management (基于 AccountModel)
|
||||
static func getAccessToken() -> String? {
|
||||
return getAccountModel()?.accessToken
|
||||
static func getAccessToken() async -> String? {
|
||||
return await getAccountModel()?.accessToken
|
||||
}
|
||||
|
||||
// MARK: - Ticket Management (优先从 AccountModel 获取)
|
||||
private static var currentTicket: String?
|
||||
// 已迁移到 UserInfoCacheActor
|
||||
|
||||
static func getCurrentUserTicket() -> String? {
|
||||
static func getCurrentUserTicket() async -> String? {
|
||||
// 优先从 AccountModel 获取 ticket(确保一致性)
|
||||
if let accountTicket = getAccountModel()?.ticket, !accountTicket.isEmpty {
|
||||
if let accountTicket = await getAccountModel()?.ticket, !accountTicket.isEmpty {
|
||||
return accountTicket
|
||||
}
|
||||
|
||||
// 备选:从内存获取(用于兼容性)
|
||||
return currentTicket
|
||||
// 备选:从 actor 获取(用于兼容性)
|
||||
return await cacheActor.getCurrentTicket()
|
||||
}
|
||||
|
||||
static func saveTicket(_ ticket: String) {
|
||||
currentTicket = ticket
|
||||
debugInfo("💾 保存 Ticket 到内存")
|
||||
static func saveTicket(_ ticket: String) async {
|
||||
await cacheActor.setCurrentTicket(ticket)
|
||||
debugInfoSync("💾 保存 Ticket 到内存")
|
||||
}
|
||||
|
||||
static func clearTicket() {
|
||||
currentTicket = nil
|
||||
debugInfo("🗑️ 清除 Ticket")
|
||||
static func clearTicket() async {
|
||||
await cacheActor.clearCurrentTicket()
|
||||
debugInfoSync("🗑️ 清除 Ticket")
|
||||
}
|
||||
|
||||
// MARK: - User Info Management
|
||||
static func saveUserInfo(_ userInfo: UserInfo) {
|
||||
cacheQueue.async(flags: .barrier) {
|
||||
do {
|
||||
try keychain.store(userInfo, forKey: StorageKeys.userInfo)
|
||||
userInfoCache = userInfo
|
||||
debugInfo("💾 保存用户信息成功")
|
||||
} catch {
|
||||
debugError("❌ 保存用户信息失败: \(error)")
|
||||
}
|
||||
static func saveUserInfo(_ userInfo: UserInfo) async {
|
||||
do {
|
||||
try await keychain.store(userInfo, forKey: StorageKeys.userInfo)
|
||||
await cacheActor.setUserInfo(userInfo)
|
||||
debugInfoSync("💾 保存用户信息成功")
|
||||
} catch {
|
||||
debugErrorSync("❌ 保存用户信息失败: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
static func getUserInfo() -> UserInfo? {
|
||||
return cacheQueue.sync {
|
||||
// 先检查缓存
|
||||
if let cached = userInfoCache {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 从 Keychain 读取
|
||||
do {
|
||||
let userInfo = try keychain.retrieve(UserInfo.self, forKey: StorageKeys.userInfo)
|
||||
userInfoCache = userInfo
|
||||
return userInfo
|
||||
} catch {
|
||||
debugError("❌ 读取用户信息失败: \(error)")
|
||||
return nil
|
||||
}
|
||||
static func getUserInfo() async -> UserInfo? {
|
||||
// 先检查缓存
|
||||
if let cached = await cacheActor.getUserInfo() {
|
||||
return cached
|
||||
}
|
||||
// 从 Keychain 读取
|
||||
do {
|
||||
let userInfo = try await keychain.retrieve(UserInfo.self, forKey: StorageKeys.userInfo)
|
||||
await cacheActor.setUserInfo(userInfo)
|
||||
return userInfo
|
||||
} catch {
|
||||
debugErrorSync("❌ 读取用户信息失败: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +318,7 @@ struct UserInfoManager {
|
||||
ticket: String,
|
||||
uid: Int?,
|
||||
userInfo: UserInfo?
|
||||
) {
|
||||
) async {
|
||||
// 创建新的 AccountModel
|
||||
let accountModel = AccountModel(
|
||||
uid: uid != nil ? "\(uid!)" : nil,
|
||||
@@ -336,38 +332,40 @@ struct UserInfoManager {
|
||||
ticket: ticket
|
||||
)
|
||||
|
||||
saveAccountModel(accountModel)
|
||||
saveTicket(ticket)
|
||||
await saveAccountModel(accountModel)
|
||||
await saveTicket(ticket)
|
||||
|
||||
if let userInfo = userInfo {
|
||||
saveUserInfo(userInfo)
|
||||
await saveUserInfo(userInfo)
|
||||
}
|
||||
|
||||
debugInfo("✅ 完整认证信息保存成功")
|
||||
debugInfoSync("✅ 完整认证信息保存成功")
|
||||
}
|
||||
|
||||
/// 检查是否有有效的认证信息
|
||||
static func hasValidAuthentication() -> Bool {
|
||||
return getAccessToken() != nil && getCurrentUserTicket() != nil
|
||||
static func hasValidAuthentication() async -> Bool {
|
||||
let token = await getAccessToken()
|
||||
let ticket = await getCurrentUserTicket()
|
||||
return token != nil && ticket != nil
|
||||
}
|
||||
|
||||
/// 清除所有认证信息
|
||||
static func clearAllAuthenticationData() {
|
||||
clearAccountModel()
|
||||
clearUserInfo()
|
||||
clearTicket()
|
||||
static func clearAllAuthenticationData() async {
|
||||
await clearAccountModel()
|
||||
await clearUserInfo()
|
||||
await clearTicket()
|
||||
|
||||
debugInfo("🗑️ 清除所有认证信息")
|
||||
debugInfoSync("🗑️ 清除所有认证信息")
|
||||
}
|
||||
|
||||
/// 尝试恢复 Ticket(用于应用重启后)
|
||||
static func restoreTicketIfNeeded() async -> Bool {
|
||||
guard let accessToken = getAccessToken(),
|
||||
getCurrentUserTicket() == nil else {
|
||||
guard let _ = await getAccessToken(),
|
||||
await getCurrentUserTicket() == nil else {
|
||||
return false
|
||||
}
|
||||
|
||||
debugInfo("🔄 尝试使用 Access Token 恢复 Ticket...")
|
||||
debugInfoSync("🔄 尝试使用 Access Token 恢复 Ticket...")
|
||||
|
||||
// 这里需要注入 APIService 依赖,暂时返回 false
|
||||
// 实际实现中应该调用 TicketHelper.createTicketRequest
|
||||
@@ -377,50 +375,48 @@ struct UserInfoManager {
|
||||
// MARK: - Account Model Management
|
||||
/// 保存 AccountModel
|
||||
/// - Parameter accountModel: 要保存的账户模型
|
||||
static func saveAccountModel(_ accountModel: AccountModel) {
|
||||
cacheQueue.async(flags: .barrier) {
|
||||
do {
|
||||
try keychain.store(accountModel, forKey: StorageKeys.accountModel)
|
||||
accountModelCache = accountModel
|
||||
|
||||
// 同步更新 ticket 到内存
|
||||
if let ticket = accountModel.ticket {
|
||||
saveTicket(ticket)
|
||||
}
|
||||
|
||||
debugInfo("💾 AccountModel 保存成功")
|
||||
} catch {
|
||||
debugError("❌ AccountModel 保存失败: \(error)")
|
||||
static func saveAccountModel(_ accountModel: AccountModel) async {
|
||||
do {
|
||||
try await keychain.store(accountModel, forKey: StorageKeys.accountModel)
|
||||
await cacheActor.setAccountModel(accountModel)
|
||||
|
||||
// 同步更新 ticket 到内存
|
||||
if let ticket = accountModel.ticket {
|
||||
await saveTicket(ticket)
|
||||
}
|
||||
|
||||
debugInfoSync("💾 AccountModel 保存成功")
|
||||
} catch {
|
||||
debugErrorSync("❌ AccountModel 保存失败: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 AccountModel
|
||||
/// - Returns: 存储的账户模型,如果不存在或解析失败返回 nil
|
||||
static func getAccountModel() -> AccountModel? {
|
||||
return cacheQueue.sync {
|
||||
// 先检查缓存
|
||||
if let cached = accountModelCache {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 从 Keychain 读取
|
||||
do {
|
||||
let accountModel = try keychain.retrieve(AccountModel.self, forKey: StorageKeys.accountModel)
|
||||
accountModelCache = accountModel
|
||||
return accountModel
|
||||
} catch {
|
||||
debugError("❌ 读取 AccountModel 失败: \(error)")
|
||||
return nil
|
||||
}
|
||||
static func getAccountModel() async -> AccountModel? {
|
||||
// 先检查缓存
|
||||
if let cached = await cacheActor.getAccountModel() {
|
||||
return cached
|
||||
}
|
||||
// 从 Keychain 读取
|
||||
do {
|
||||
let accountModel = try await keychain.retrieve(
|
||||
AccountModel.self,
|
||||
forKey: StorageKeys.accountModel
|
||||
)
|
||||
await cacheActor.setAccountModel(accountModel)
|
||||
return accountModel
|
||||
} catch {
|
||||
debugErrorSync("❌ 读取 AccountModel 失败: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新 AccountModel 中的 ticket
|
||||
/// - Parameter ticket: 新的票据
|
||||
static func updateAccountModelTicket(_ ticket: String) {
|
||||
guard var accountModel = getAccountModel() else {
|
||||
debugError("❌ 无法更新 ticket:AccountModel 不存在")
|
||||
static func updateAccountModelTicket(_ ticket: String) async {
|
||||
guard var accountModel = await getAccountModel() else {
|
||||
debugErrorSync("❌ 无法更新 ticket:AccountModel 不存在")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -436,97 +432,78 @@ struct UserInfoManager {
|
||||
ticket: ticket
|
||||
)
|
||||
|
||||
saveAccountModel(accountModel)
|
||||
saveTicket(ticket) // 同时更新内存中的 ticket
|
||||
await saveAccountModel(accountModel)
|
||||
await saveTicket(ticket) // 同时更新内存中的 ticket
|
||||
}
|
||||
|
||||
/// 检查是否有有效的 AccountModel
|
||||
/// - Returns: 是否存在有效的账户模型
|
||||
static func hasValidAccountModel() -> Bool {
|
||||
guard let accountModel = getAccountModel() else {
|
||||
static func hasValidAccountModel() async -> Bool {
|
||||
guard let accountModel = await getAccountModel() else {
|
||||
return false
|
||||
}
|
||||
return accountModel.hasValidAuthentication
|
||||
}
|
||||
|
||||
/// 清除 AccountModel
|
||||
static func clearAccountModel() {
|
||||
cacheQueue.async(flags: .barrier) {
|
||||
do {
|
||||
try keychain.delete(forKey: StorageKeys.accountModel)
|
||||
accountModelCache = nil
|
||||
debugInfo("🗑️ AccountModel 已清除")
|
||||
} catch {
|
||||
debugError("❌ 清除 AccountModel 失败: \(error)")
|
||||
}
|
||||
static func clearAccountModel() async {
|
||||
do {
|
||||
try await keychain.delete(forKey: StorageKeys.accountModel)
|
||||
await cacheActor.clearAccountModel()
|
||||
debugInfoSync("🗑️ AccountModel 已清除")
|
||||
} catch {
|
||||
debugErrorSync("❌ 清除 AccountModel 失败: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除用户信息
|
||||
static func clearUserInfo() {
|
||||
cacheQueue.async(flags: .barrier) {
|
||||
do {
|
||||
try keychain.delete(forKey: StorageKeys.userInfo)
|
||||
userInfoCache = nil
|
||||
debugInfo("🗑️ UserInfo 已清除")
|
||||
} catch {
|
||||
debugError("❌ 清除 UserInfo 失败: \(error)")
|
||||
}
|
||||
static func clearUserInfo() async {
|
||||
do {
|
||||
try await keychain.delete(forKey: StorageKeys.userInfo)
|
||||
await cacheActor.clearUserInfo()
|
||||
debugInfoSync("🗑️ UserInfo 已清除")
|
||||
} catch {
|
||||
debugErrorSync("❌ 清除 UserInfo 失败: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除所有缓存(用于测试或重置)
|
||||
static func clearAllCache() {
|
||||
cacheQueue.async(flags: .barrier) {
|
||||
accountModelCache = nil
|
||||
userInfoCache = nil
|
||||
debugInfo("🗑️ 清除所有内存缓存")
|
||||
}
|
||||
static func clearAllCache() async {
|
||||
await cacheActor.clearAccountModel()
|
||||
await cacheActor.clearUserInfo()
|
||||
debugInfoSync("🗑️ 清除所有内存缓存")
|
||||
}
|
||||
|
||||
/// 预加载缓存(提升首次访问性能)
|
||||
static func preloadCache() {
|
||||
cacheQueue.async {
|
||||
// 预加载 AccountModel
|
||||
_ = getAccountModel()
|
||||
// 预加载 UserInfo
|
||||
_ = getUserInfo()
|
||||
debugInfo("🚀 缓存预加载完成")
|
||||
}
|
||||
static func preloadCache() async {
|
||||
await cacheActor.setAccountModel(await getAccountModel())
|
||||
await cacheActor.setUserInfo(await getUserInfo())
|
||||
debugInfoSync("🚀 缓存预加载完成")
|
||||
}
|
||||
|
||||
// MARK: - Authentication Validation
|
||||
|
||||
/// 检查当前认证状态是否有效
|
||||
/// - Returns: 认证状态结果
|
||||
static func checkAuthenticationStatus() -> AuthenticationStatus {
|
||||
return cacheQueue.sync {
|
||||
guard let accountModel = getAccountModel() else {
|
||||
debugInfo("🔍 认证检查:未找到 AccountModel")
|
||||
return .notFound
|
||||
}
|
||||
|
||||
// 检查 uid 是否有效
|
||||
guard let uid = accountModel.uid, !uid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
debugInfo("🔍 认证检查:uid 无效")
|
||||
return .invalid
|
||||
}
|
||||
|
||||
// 检查 ticket 是否有效
|
||||
guard let ticket = accountModel.ticket, !ticket.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
debugInfo("🔍 认证检查:ticket 无效")
|
||||
return .invalid
|
||||
}
|
||||
|
||||
// 可选:检查 access token 是否有效
|
||||
guard let accessToken = accountModel.accessToken, !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
debugInfo("🔍 认证检查:access token 无效")
|
||||
return .invalid
|
||||
}
|
||||
|
||||
debugInfo("🔍 认证检查:认证有效 - uid: \(uid), ticket: \(ticket.prefix(10))...")
|
||||
return .valid
|
||||
static func checkAuthenticationStatus() async -> AuthenticationStatus {
|
||||
guard let accountModel = await getAccountModel() else {
|
||||
debugInfoSync("🔍 认证检查:未找到 AccountModel")
|
||||
return .notFound
|
||||
}
|
||||
guard let uid = accountModel.uid, !uid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
debugInfoSync("🔍 认证检查:uid 无效")
|
||||
return .invalid
|
||||
}
|
||||
guard let ticket = accountModel.ticket, !ticket.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
debugInfoSync("🔍 认证检查:ticket 无效")
|
||||
return .invalid
|
||||
}
|
||||
guard let accessToken = accountModel.accessToken, !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
debugInfoSync("🔍 认证检查:access token 无效")
|
||||
return .invalid
|
||||
}
|
||||
debugInfoSync("🔍 认证检查:认证有效 - uid: \(uid), ticket: \(ticket.prefix(10))...")
|
||||
return .valid
|
||||
}
|
||||
|
||||
/// 认证状态枚举
|
||||
@@ -556,19 +533,19 @@ struct UserInfoManager {
|
||||
|
||||
/// 测试认证 header 功能(仅用于调试)
|
||||
/// 模拟用户登录状态并验证 header 添加逻辑
|
||||
static func testAuthenticationHeaders() {
|
||||
static func testAuthenticationHeaders() async {
|
||||
#if DEBUG
|
||||
debugInfo("\n🧪 开始测试认证 header 功能")
|
||||
debugInfoSync("\n🧪 开始测试认证 header 功能")
|
||||
|
||||
// 测试1:未登录状态
|
||||
debugInfo("📝 测试1:未登录状态")
|
||||
clearAllAuthenticationData()
|
||||
let headers1 = APIConfiguration.defaultHeaders
|
||||
debugInfoSync("📝 测试1:未登录状态")
|
||||
await clearAllAuthenticationData()
|
||||
let headers1 = await APIConfiguration.defaultHeaders()
|
||||
let hasAuthHeaders1 = headers1.keys.contains("pub_uid") || headers1.keys.contains("pub_ticket")
|
||||
debugInfo(" 认证 headers 存在: \(hasAuthHeaders1) (应该为 false)")
|
||||
debugInfoSync(" 认证 headers 存在: \(hasAuthHeaders1) (应该为 false)")
|
||||
|
||||
// 测试2:模拟登录状态
|
||||
debugInfo("📝 测试2:模拟登录状态")
|
||||
debugInfoSync("📝 测试2:模拟登录状态")
|
||||
let testAccount = AccountModel(
|
||||
uid: "12345",
|
||||
jti: "test-jti",
|
||||
@@ -580,22 +557,48 @@ struct UserInfoManager {
|
||||
scope: "read write",
|
||||
ticket: "test-ticket-12345678901234567890"
|
||||
)
|
||||
saveAccountModel(testAccount)
|
||||
await saveAccountModel(testAccount)
|
||||
|
||||
let headers2 = APIConfiguration.defaultHeaders
|
||||
let headers2 = await APIConfiguration.defaultHeaders()
|
||||
let hasUid = headers2["pub_uid"] == "12345"
|
||||
let hasTicket = headers2["pub_ticket"] == "test-ticket-12345678901234567890"
|
||||
debugInfo(" pub_uid 正确: \(hasUid) (应该为 true)")
|
||||
debugInfo(" pub_ticket 正确: \(hasTicket) (应该为 true)")
|
||||
debugInfoSync(" pub_uid 正确: \(hasUid) (应该为 true)")
|
||||
debugInfoSync(" pub_ticket 正确: \(hasTicket) (应该为 true)")
|
||||
|
||||
// 测试3:清理测试数据
|
||||
debugInfo("📝 测试3:清理测试数据")
|
||||
clearAllAuthenticationData()
|
||||
debugInfo("✅ 认证 header 测试完成\n")
|
||||
debugInfoSync("📝 测试3:清理测试数据")
|
||||
await clearAllAuthenticationData()
|
||||
debugInfoSync("✅ 认证 header 测试完成\n")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Info Cache Actor
|
||||
actor UserInfoCacheActor {
|
||||
private var accountModelCache: AccountModel?
|
||||
private var userInfoCache: UserInfo?
|
||||
private var currentTicket: String?
|
||||
|
||||
// AccountModel
|
||||
func getAccountModel() -> AccountModel? { accountModelCache }
|
||||
func setAccountModel(_ model: AccountModel?) { accountModelCache = model }
|
||||
func clearAccountModel() { accountModelCache = nil }
|
||||
|
||||
// UserInfo
|
||||
func getUserInfo() -> UserInfo? { userInfoCache }
|
||||
func setUserInfo(_ info: UserInfo?) { userInfoCache = info }
|
||||
func clearUserInfo() { userInfoCache = nil }
|
||||
|
||||
// Ticket
|
||||
func getCurrentTicket() -> String? { currentTicket }
|
||||
func setCurrentTicket(_ ticket: String?) { currentTicket = ticket }
|
||||
func clearCurrentTicket() { currentTicket = nil }
|
||||
}
|
||||
|
||||
extension UserInfoManager {
|
||||
static let cacheActor = UserInfoCacheActor()
|
||||
}
|
||||
|
||||
// MARK: - API Request Protocol
|
||||
|
||||
/// API 请求协议
|
||||
@@ -604,7 +607,7 @@ struct UserInfoManager {
|
||||
/// 每个具体的 API 请求都应该实现这个协议。
|
||||
///
|
||||
/// 协议要求:
|
||||
/// - Response: 关联类型,定义响应数据的类型
|
||||
/// - Response: 关联类型,定义响应数据的类型,必须 Sendable
|
||||
/// - endpoint: API 端点路径
|
||||
/// - method: HTTP 请求方法
|
||||
/// - 可选的查询参数、请求体参数、请求头等
|
||||
@@ -618,8 +621,8 @@ struct UserInfoManager {
|
||||
/// // ... 其他属性
|
||||
/// }
|
||||
/// ```
|
||||
protocol APIRequestProtocol {
|
||||
associatedtype Response: Codable
|
||||
protocol APIRequestProtocol: Sendable {
|
||||
associatedtype Response: Codable & Sendable
|
||||
|
||||
var endpoint: String { get }
|
||||
var method: HTTPMethod { get }
|
||||
@@ -658,3 +661,64 @@ struct APIResponse<T: Codable>: Codable {
|
||||
|
||||
// 注意:String+MD5 扩展已移至 Utils/Extensions/String+MD5.swift
|
||||
|
||||
// MARK: - 腾讯云 COS Token 相关模型
|
||||
|
||||
/// 腾讯云 COS Token 请求模型
|
||||
struct TcTokenRequest: APIRequestProtocol {
|
||||
typealias Response = TcTokenResponse
|
||||
|
||||
let endpoint: String = APIEndpoint.tcToken.path
|
||||
let method: HTTPMethod = .GET
|
||||
let queryParameters: [String: String]? = nil
|
||||
var bodyParameters: [String: Any]? { nil }
|
||||
let timeout: TimeInterval = 30.0
|
||||
let includeBaseParameters: Bool = true
|
||||
let shouldShowLoading: Bool = false // 不显示 loading,避免影响用户体验
|
||||
let shouldShowError: Bool = false // 不显示错误,静默处理
|
||||
}
|
||||
|
||||
/// 腾讯云 COS Token 响应模型
|
||||
struct TcTokenResponse: Codable, Equatable {
|
||||
let code: Int
|
||||
let message: String
|
||||
let data: TcTokenData?
|
||||
let timestamp: Int64
|
||||
}
|
||||
|
||||
/// 腾讯云 COS Token 数据模型
|
||||
/// 包含完整的腾讯云 COS 配置信息
|
||||
struct TcTokenData: Codable, Equatable {
|
||||
let bucket: String // 存储桶名称
|
||||
let sessionToken: String // 临时会话令牌
|
||||
let region: String // 地域
|
||||
let customDomain: String // 自定义域名
|
||||
let accelerate: Bool // 是否启用加速
|
||||
let appId: String // 应用 ID
|
||||
let secretKey: String // 临时密钥
|
||||
let expireTime: Int64 // 过期时间戳
|
||||
let startTime: Int64 // 开始时间戳
|
||||
let secretId: String // 临时密钥 ID
|
||||
|
||||
/// 检查 Token 是否已过期
|
||||
var isExpired: Bool {
|
||||
let currentTime = Int64(Date().timeIntervalSince1970)
|
||||
return currentTime >= expireTime
|
||||
}
|
||||
|
||||
/// 获取过期时间
|
||||
var expirationDate: Date {
|
||||
return Date(timeIntervalSince1970: TimeInterval(expireTime))
|
||||
}
|
||||
|
||||
/// 获取开始时间
|
||||
var startDate: Date {
|
||||
return Date(timeIntervalSince1970: TimeInterval(startTime))
|
||||
}
|
||||
|
||||
/// 获取剩余有效时间(秒)
|
||||
var remainingTime: Int64 {
|
||||
let currentTime = Int64(Date().timeIntervalSince1970)
|
||||
return max(0, expireTime - currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -14,7 +14,7 @@ import ComposableArchitecture
|
||||
/// let request = ConfigRequest()
|
||||
/// let response = try await apiService.request(request)
|
||||
/// ```
|
||||
protocol APIServiceProtocol {
|
||||
protocol APIServiceProtocol: Sendable {
|
||||
/// 发起网络请求
|
||||
/// - Parameter request: 符合 APIRequestProtocol 的请求对象
|
||||
/// - Returns: 请求对应的响应对象
|
||||
@@ -39,19 +39,22 @@ protocol APIServiceProtocol {
|
||||
/// - 完整的错误处理和重试机制
|
||||
/// - 详细的请求/响应日志记录
|
||||
/// - 防止资源超限的保护机制
|
||||
struct LiveAPIService: APIServiceProtocol {
|
||||
struct LiveAPIService: APIServiceProtocol, Sendable {
|
||||
private let session: URLSession
|
||||
private let baseURL: String
|
||||
// 缓存主 actor 配置,避免并发隔离问题
|
||||
private static let cachedBaseURL: String = APIConfiguration.baseURL
|
||||
private static let cachedTimeout: TimeInterval = APIConfiguration.timeout
|
||||
|
||||
/// 初始化 API 服务
|
||||
/// - Parameter baseURL: API 服务器基础 URL,默认使用配置中的地址
|
||||
init(baseURL: String = APIConfiguration.baseURL) {
|
||||
/// - Parameter baseURL: API 服务器基础 URL,默认使用静态缓存
|
||||
init(baseURL: String = LiveAPIService.cachedBaseURL) {
|
||||
self.baseURL = baseURL
|
||||
|
||||
// 配置 URLSession 以防止资源超限问题
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = APIConfiguration.timeout
|
||||
config.timeoutIntervalForResource = APIConfiguration.timeout * 2
|
||||
config.timeoutIntervalForRequest = LiveAPIService.cachedTimeout
|
||||
config.timeoutIntervalForResource = LiveAPIService.cachedTimeout * 2
|
||||
config.waitsForConnectivity = true
|
||||
config.allowsCellularAccess = true
|
||||
|
||||
@@ -78,14 +81,14 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
let startTime = Date()
|
||||
|
||||
// 开始 Loading 管理
|
||||
let loadingId = APILoadingManager.shared.startLoading(
|
||||
let loadingId = await APILoadingManager.shared.startLoading(
|
||||
shouldShowLoading: request.shouldShowLoading,
|
||||
shouldShowError: request.shouldShowError
|
||||
)
|
||||
|
||||
// 构建 URL
|
||||
guard let url = buildURL(for: request) else {
|
||||
APILoadingManager.shared.setError(loadingId, errorMessage: APIError.invalidURL.localizedDescription)
|
||||
guard let url = await buildURL(for: request) else {
|
||||
await APILoadingManager.shared.setError(loadingId, errorMessage: APIError.invalidURL.localizedDescription)
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
@@ -95,8 +98,8 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
urlRequest.timeoutInterval = request.timeout
|
||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
// 设置请求头
|
||||
var headers = APIConfiguration.defaultHeaders
|
||||
// 设置请求头(必须 await 获取)
|
||||
var headers = await APIConfiguration.defaultHeaders()
|
||||
if let customHeaders = request.headers {
|
||||
headers.merge(customHeaders) { _, new in new }
|
||||
}
|
||||
@@ -119,7 +122,7 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
// 如果需要包含基础参数,则先合并所有参数,再统一生成签名
|
||||
if request.includeBaseParameters {
|
||||
// 第一步:创建基础参数实例(不包含签名)
|
||||
var baseParams = BaseRequest()
|
||||
var baseParams = await BaseRequest()
|
||||
|
||||
// 第二步:基于所有参数(bodyParams + 基础参数)统一生成签名
|
||||
baseParams.generateSignature(with: bodyParams)
|
||||
@@ -127,8 +130,7 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
// 第三步:将包含正确签名的基础参数合并到最终请求体
|
||||
let baseDict = try baseParams.toDictionary()
|
||||
finalBody.merge(baseDict) { _, new in new } // 基础参数(包括签名)优先
|
||||
|
||||
debugInfo("🔐 签名生成完成 - 基于所有参数统一生成: \(baseParams.pubSign)")
|
||||
debugInfoSync("🔐 签名生成完成 - 基于所有参数统一生成: \(baseParams.pubSign)")
|
||||
}
|
||||
|
||||
requestBody = try JSONSerialization.data(withJSONObject: finalBody, options: [])
|
||||
@@ -136,17 +138,18 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
// urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
if let httpBody = urlRequest.httpBody,
|
||||
let bodyString = String(data: httpBody, encoding: .utf8) {
|
||||
debugInfo("HTTP Body: \(bodyString)")
|
||||
debugInfoSync("HTTP Body: \(bodyString)")
|
||||
}
|
||||
} catch {
|
||||
let encodingError = APIError.decodingError("请求体编码失败: \(error.localizedDescription)")
|
||||
APILoadingManager.shared.setError(loadingId, errorMessage: encodingError.localizedDescription)
|
||||
await APILoadingManager.shared.setError(loadingId, errorMessage: encodingError.localizedDescription)
|
||||
throw encodingError
|
||||
}
|
||||
}
|
||||
|
||||
// 记录请求日志,传递完整的 headers 信息
|
||||
APILogger.logRequest(request, url: url, body: requestBody, finalHeaders: headers)
|
||||
await APILogger
|
||||
.logRequest(request, url: url, body: requestBody, finalHeaders: headers)
|
||||
|
||||
do {
|
||||
// 发起请求
|
||||
@@ -156,34 +159,36 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
// 检查响应
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
let networkError = APIError.networkError("无效的响应类型")
|
||||
APILoadingManager.shared.setError(loadingId, errorMessage: networkError.localizedDescription)
|
||||
await APILoadingManager.shared.setError(loadingId, errorMessage: networkError.localizedDescription)
|
||||
throw networkError
|
||||
}
|
||||
|
||||
// 检查数据大小
|
||||
if data.count > APIConfiguration.maxDataSize {
|
||||
APILogger.logError(APIError.resourceTooLarge, url: url, duration: duration)
|
||||
APILoadingManager.shared.setError(loadingId, errorMessage: APIError.resourceTooLarge.localizedDescription)
|
||||
await APILogger
|
||||
.logError(APIError.resourceTooLarge, url: url, duration: duration)
|
||||
await APILoadingManager.shared.setError(loadingId, errorMessage: APIError.resourceTooLarge.localizedDescription)
|
||||
throw APIError.resourceTooLarge
|
||||
}
|
||||
|
||||
// 记录响应日志
|
||||
APILogger.logResponse(data: data, response: httpResponse, duration: duration)
|
||||
await APILogger
|
||||
.logResponse(data: data, response: httpResponse, duration: duration)
|
||||
|
||||
// 性能警告
|
||||
APILogger.logPerformanceWarning(duration: duration)
|
||||
await APILogger.logPerformanceWarning(duration: duration)
|
||||
|
||||
// 检查 HTTP 状态码
|
||||
guard 200...299 ~= httpResponse.statusCode else {
|
||||
let errorMessage = extractErrorMessage(from: data)
|
||||
let httpError = APIError.httpError(statusCode: httpResponse.statusCode, message: errorMessage)
|
||||
APILoadingManager.shared.setError(loadingId, errorMessage: httpError.localizedDescription)
|
||||
await APILoadingManager.shared.setError(loadingId, errorMessage: httpError.localizedDescription)
|
||||
throw httpError
|
||||
}
|
||||
|
||||
// 检查数据是否为空
|
||||
guard !data.isEmpty else {
|
||||
APILoadingManager.shared.setError(loadingId, errorMessage: APIError.noData.localizedDescription)
|
||||
await APILoadingManager.shared.setError(loadingId, errorMessage: APIError.noData.localizedDescription)
|
||||
throw APIError.noData
|
||||
}
|
||||
|
||||
@@ -191,28 +196,28 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let decodedResponse = try decoder.decode(T.Response.self, from: data)
|
||||
APILogger.logDecodedResponse(decodedResponse, type: T.Response.self)
|
||||
await APILogger.logDecodedResponse(decodedResponse, type: T.Response.self)
|
||||
|
||||
// 请求成功,完成 loading
|
||||
APILoadingManager.shared.finishLoading(loadingId)
|
||||
await APILoadingManager.shared.finishLoading(loadingId)
|
||||
|
||||
return decodedResponse
|
||||
} catch {
|
||||
let decodingError = APIError.decodingError("响应解析失败: \(error.localizedDescription)")
|
||||
APILoadingManager.shared.setError(loadingId, errorMessage: decodingError.localizedDescription)
|
||||
await APILoadingManager.shared.setError(loadingId, errorMessage: decodingError.localizedDescription)
|
||||
throw decodingError
|
||||
}
|
||||
|
||||
} catch let error as APIError {
|
||||
let duration = Date().timeIntervalSince(startTime)
|
||||
APILogger.logError(error, url: url, duration: duration)
|
||||
APILoadingManager.shared.setError(loadingId, errorMessage: error.localizedDescription)
|
||||
await APILogger.logError(error, url: url, duration: duration)
|
||||
await APILoadingManager.shared.setError(loadingId, errorMessage: error.localizedDescription)
|
||||
throw error
|
||||
} catch {
|
||||
let duration = Date().timeIntervalSince(startTime)
|
||||
let apiError = mapSystemError(error)
|
||||
APILogger.logError(apiError, url: url, duration: duration)
|
||||
APILoadingManager.shared.setError(loadingId, errorMessage: apiError.localizedDescription)
|
||||
await APILogger.logError(apiError, url: url, duration: duration)
|
||||
await APILoadingManager.shared.setError(loadingId, errorMessage: apiError.localizedDescription)
|
||||
throw apiError
|
||||
}
|
||||
}
|
||||
@@ -228,7 +233,7 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
///
|
||||
/// - Parameter request: API 请求对象
|
||||
/// - Returns: 构建完成的 URL,如果构建失败则返回 nil
|
||||
private func buildURL<T: APIRequestProtocol>(for request: T) -> URL? {
|
||||
@MainActor private func buildURL<T: APIRequestProtocol>(for request: T) -> URL? {
|
||||
guard var urlComponents = URLComponents(string: baseURL + request.endpoint) else {
|
||||
return nil
|
||||
}
|
||||
@@ -252,9 +257,9 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
queryItems.append(URLQueryItem(name: key, value: "\(value)"))
|
||||
}
|
||||
|
||||
debugInfo("🔐 GET请求签名生成完成 - 基于所有参数统一生成: \(baseParams.pubSign)")
|
||||
debugInfoSync("🔐 GET请求签名生成完成 - 基于所有参数统一生成: \(baseParams.pubSign)")
|
||||
} catch {
|
||||
debugWarn("警告:无法添加基础参数到查询字符串")
|
||||
debugWarnSync("警告:无法添加基础参数到查询字符串")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,46 +327,31 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
|
||||
// MARK: - Mock API Service (for testing)
|
||||
|
||||
/// 模拟 API 服务,用于测试和开发
|
||||
///
|
||||
/// 该类提供了一个可配置的模拟 API 服务,可以:
|
||||
/// - 设置预定义的响应数据
|
||||
/// - 模拟网络延迟
|
||||
/// - 用于单元测试和 UI 预览
|
||||
///
|
||||
/// 使用示例:
|
||||
/// ```swift
|
||||
/// var mockService = MockAPIService()
|
||||
/// mockService.setMockResponse(for: "/client/config", response: mockConfigResponse)
|
||||
/// let response = try await mockService.request(ConfigRequest())
|
||||
/// ```
|
||||
struct MockAPIService: APIServiceProtocol {
|
||||
/// 并发安全的 Mock API Service
|
||||
actor MockAPIServiceActor: APIServiceProtocol, Sendable {
|
||||
private var mockResponses: [String: Any] = [:]
|
||||
|
||||
mutating func setMockResponse<T>(for endpoint: String, response: T) {
|
||||
|
||||
func setMockResponse<T>(for endpoint: String, response: T) {
|
||||
mockResponses[endpoint] = response
|
||||
}
|
||||
|
||||
|
||||
func request<T: APIRequestProtocol>(_ request: T) async throws -> T.Response {
|
||||
// 模拟网络延迟
|
||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5 秒
|
||||
|
||||
if let mockResponse = mockResponses[request.endpoint] as? T.Response {
|
||||
return mockResponse
|
||||
}
|
||||
|
||||
throw APIError.noData
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TCA Dependency Integration
|
||||
private enum APIServiceKey: DependencyKey {
|
||||
static let liveValue: APIServiceProtocol = LiveAPIService()
|
||||
static let testValue: APIServiceProtocol = MockAPIService()
|
||||
static let liveValue: any APIServiceProtocol & Sendable = LiveAPIService()
|
||||
static let testValue: any APIServiceProtocol & Sendable = MockAPIServiceActor()
|
||||
}
|
||||
|
||||
extension DependencyValues {
|
||||
var apiService: APIServiceProtocol {
|
||||
var apiService: (any APIServiceProtocol & Sendable) {
|
||||
get { self[APIServiceKey.self] }
|
||||
set { self[APIServiceKey.self] = newValue }
|
||||
}
|
||||
|
@@ -78,7 +78,7 @@ struct IDLoginAPIRequest: APIRequestProtocol {
|
||||
let method: HTTPMethod = .POST
|
||||
let includeBaseParameters = true
|
||||
let queryParameters: [String: String]?
|
||||
let bodyParameters: [String: Any]? = nil
|
||||
var bodyParameters: [String: Any]? { nil }
|
||||
let timeout: TimeInterval = 30.0
|
||||
|
||||
/// 初始化ID登录请求
|
||||
@@ -98,14 +98,6 @@ struct IDLoginAPIRequest: APIRequestProtocol {
|
||||
"client_id": clientId,
|
||||
"grant_type": grantType
|
||||
];
|
||||
// self.bodyParameters = [
|
||||
// "phone": phone,
|
||||
// "password": password,
|
||||
// "client_secret": clientSecret,
|
||||
// "version": version,
|
||||
// "client_id": clientId,
|
||||
// "grant_type": grantType
|
||||
// ];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,21 +178,21 @@ struct LoginHelper {
|
||||
/// - userID: 原始用户ID
|
||||
/// - password: 原始密码
|
||||
/// - Returns: 配置好的API请求,如果加密失败返回nil
|
||||
static func createIDLoginRequest(userID: String, password: String) -> IDLoginAPIRequest? {
|
||||
static func createIDLoginRequest(userID: String, password: String) async -> IDLoginAPIRequest? {
|
||||
// 使用DES加密ID和密码
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedID = DESEncrypt.encryptUseDES(userID, key: encryptionKey),
|
||||
let encryptedPassword = DESEncrypt.encryptUseDES(password, key: encryptionKey) else {
|
||||
debugError("❌ DES加密失败")
|
||||
debugErrorSync("❌ DES加密失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
debugInfo("🔐 DES加密成功")
|
||||
debugInfo(" 原始ID: \(userID)")
|
||||
debugInfo(" 加密后ID: \(encryptedID)")
|
||||
debugInfo(" 原始密码: \(password)")
|
||||
debugInfo(" 加密后密码: \(encryptedPassword)")
|
||||
debugInfoSync("🔐 DES加密成功")
|
||||
debugInfoSync(" 原始ID: \(userID)")
|
||||
debugInfoSync(" 加密后ID: \(encryptedID)")
|
||||
debugInfoSync(" 原始密码: \(password)")
|
||||
debugInfoSync(" 加密后密码: \(encryptedPassword)")
|
||||
|
||||
return IDLoginAPIRequest(
|
||||
phone: userID,
|
||||
@@ -219,7 +211,7 @@ struct TicketAPIRequest: APIRequestProtocol {
|
||||
let method: HTTPMethod = .POST
|
||||
let includeBaseParameters = true
|
||||
let queryParameters: [String: String]?
|
||||
let bodyParameters: [String: Any]? = nil
|
||||
var bodyParameters: [String: Any]? { nil }
|
||||
let timeout: TimeInterval = 30.0
|
||||
let customHeaders: [String: String]?
|
||||
|
||||
@@ -292,13 +284,13 @@ struct TicketHelper {
|
||||
/// - accessToken: OAuth 访问令牌
|
||||
/// - uid: 用户唯一标识
|
||||
static func debugTicketRequest(accessToken: String, uid: Int?) {
|
||||
debugInfo("🎫 Ticket 请求调试信息")
|
||||
debugInfo(" AccessToken: \(accessToken)")
|
||||
debugInfo(" UID: \(uid?.description ?? "nil")")
|
||||
debugInfo(" Endpoint: /oauth/ticket")
|
||||
debugInfo(" Method: POST")
|
||||
debugInfo(" Headers: pub_uid = \(uid?.description ?? "nil")")
|
||||
debugInfo(" Parameters: access_token=\(accessToken), issue_type=multi")
|
||||
debugInfoSync("🎫 Ticket 请求调试信息")
|
||||
debugInfoSync(" AccessToken: \(accessToken)")
|
||||
debugInfoSync(" UID: \(uid?.description ?? "nil")")
|
||||
debugInfoSync(" Endpoint: /oauth/ticket")
|
||||
debugInfoSync(" Method: POST")
|
||||
debugInfoSync(" Headers: pub_uid = \(uid?.description ?? "nil")")
|
||||
debugInfoSync(" Parameters: access_token=\(accessToken), issue_type=multi")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +307,7 @@ struct EmailGetCodeRequest: APIRequestProtocol {
|
||||
let method: HTTPMethod = .POST
|
||||
let includeBaseParameters = true
|
||||
let queryParameters: [String: String]?
|
||||
let bodyParameters: [String: Any]? = nil
|
||||
var bodyParameters: [String: Any]? { nil }
|
||||
let timeout: TimeInterval = 30.0
|
||||
|
||||
/// 初始化邮箱验证码获取请求
|
||||
@@ -356,7 +348,7 @@ struct EmailLoginRequest: APIRequestProtocol {
|
||||
let method: HTTPMethod = .POST
|
||||
let includeBaseParameters = true
|
||||
let queryParameters: [String: String]?
|
||||
let bodyParameters: [String: Any]? = nil
|
||||
var bodyParameters: [String: Any]? { nil }
|
||||
let timeout: TimeInterval = 30.0
|
||||
|
||||
/// 初始化邮箱验证码登录请求
|
||||
@@ -389,13 +381,13 @@ extension LoginHelper {
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
||||
debugError("❌ 邮箱DES加密失败")
|
||||
debugErrorSync("❌ 邮箱DES加密失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
debugInfo("🔐 邮箱DES加密成功")
|
||||
debugInfo(" 原始邮箱: \(email)")
|
||||
debugInfo(" 加密邮箱: \(encryptedEmail)")
|
||||
debugInfoSync("🔐 邮箱DES加密成功")
|
||||
debugInfoSync(" 原始邮箱: \(email)")
|
||||
debugInfoSync(" 加密邮箱: \(encryptedEmail)")
|
||||
|
||||
return EmailGetCodeRequest(emailAddress: email, type: 1)
|
||||
}
|
||||
@@ -405,18 +397,18 @@ extension LoginHelper {
|
||||
/// - email: 原始邮箱地址
|
||||
/// - code: 验证码
|
||||
/// - Returns: 配置好的API请求,如果加密失败返回nil
|
||||
static func createEmailLoginRequest(email: String, code: String) -> EmailLoginRequest? {
|
||||
static func createEmailLoginRequest(email: String, code: String) async -> EmailLoginRequest? {
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
||||
debugError("❌ 邮箱DES加密失败")
|
||||
await debugErrorSync("❌ 邮箱DES加密失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
debugInfo("🔐 邮箱验证码登录DES加密成功")
|
||||
debugInfo(" 原始邮箱: \(email)")
|
||||
debugInfo(" 加密邮箱: \(encryptedEmail)")
|
||||
debugInfo(" 验证码: \(code)")
|
||||
debugInfoSync("🔐 邮箱验证码登录DES加密成功")
|
||||
debugInfoSync(" 原始邮箱: \(email)")
|
||||
debugInfoSync(" 加密邮箱: \(encryptedEmail)")
|
||||
debugInfoSync(" 验证码: \(code)")
|
||||
|
||||
return EmailLoginRequest(email: encryptedEmail, code: code)
|
||||
}
|
||||
|
@@ -2,13 +2,13 @@ import UIKit
|
||||
//import NIMSDK
|
||||
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
|
||||
|
||||
// 执行数据迁移(从 UserDefaults 到 Keychain)
|
||||
DataMigrationManager.performStartupMigration()
|
||||
|
||||
// 预加载用户信息缓存
|
||||
UserInfoManager.preloadCache()
|
||||
await UserInfoManager.preloadCache()
|
||||
|
||||
// 开启网络监控
|
||||
// NetworkManager.shared.networkStatusChanged = { status in
|
||||
|
21
yana/Assets.xcassets/Home/Volume.imageset/Contents.json
vendored
Normal file
21
yana/Assets.xcassets/Home/Volume.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Volume@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Home/Volume.imageset/Volume@3x.png
vendored
Normal file
BIN
yana/Assets.xcassets/Home/Volume.imageset/Volume@3x.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
21
yana/Assets.xcassets/Home/add photo.imageset/Contents.json
vendored
Normal file
21
yana/Assets.xcassets/Home/add photo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "组 8030@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Home/add photo.imageset/组 8030@3x.png
vendored
Normal file
BIN
yana/Assets.xcassets/Home/add photo.imageset/组 8030@3x.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.5 KiB |
@@ -4,7 +4,7 @@ enum Environment {
|
||||
}
|
||||
|
||||
struct AppConfig {
|
||||
static var current: Environment = {
|
||||
static let current: Environment = {
|
||||
#if DEBUG
|
||||
return .development
|
||||
#else
|
||||
@@ -43,9 +43,9 @@ struct AppConfig {
|
||||
}
|
||||
|
||||
// 运行时切换环境(用于测试)
|
||||
static func switchEnvironment(to env: Environment) {
|
||||
current = env
|
||||
}
|
||||
// static func switchEnvironment(to env: Environment) {
|
||||
// current = env
|
||||
// }
|
||||
|
||||
// 网络调试配置
|
||||
static var enableNetworkDebug: Bool {
|
||||
|
@@ -2,17 +2,18 @@ import Foundation
|
||||
import UIKit // 用于设备信息
|
||||
@_exported import Alamofire // 全局导入
|
||||
|
||||
@MainActor
|
||||
final class ClientConfig {
|
||||
static let shared = ClientConfig()
|
||||
private init() {}
|
||||
|
||||
func initializeClient() {
|
||||
debugInfo("✅ 开始初始化客户端 - URL: \(AppConfig.baseURL)/client/init")
|
||||
debugInfoSync("✅ 开始初始化客户端 - URL: \(AppConfig.baseURL)/client/init")
|
||||
callClientInitAPI() // 调用新方法
|
||||
}
|
||||
|
||||
func callClientInitAPI() {
|
||||
debugInfo("🆕 使用GET方法调用初始化接口")
|
||||
debugInfoSync("🆕 使用GET方法调用初始化接口")
|
||||
|
||||
// let queryParams = [
|
||||
// "debug": "1",
|
||||
|
@@ -28,6 +28,144 @@ enum UILogLevel: String, CaseIterable {
|
||||
case detailed = "详细日志"
|
||||
}
|
||||
|
||||
struct LoginTabView: View {
|
||||
let store: StoreOf<LoginFeature>
|
||||
let initStore: StoreOf<InitFeature>
|
||||
@Binding var selectedLogLevel: APILogger.LogLevel
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
// 日志级别选择器
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("日志级别:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Picker("日志级别", selection: $selectedLogLevel) {
|
||||
Text("无日志").tag(APILogger.LogLevel.none)
|
||||
Text("基础日志").tag(APILogger.LogLevel.basic)
|
||||
Text("详细日志").tag(APILogger.LogLevel.detailed)
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
Spacer()
|
||||
VStack(spacing: 20) {
|
||||
Text("eparty")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
VStack(spacing: 15) {
|
||||
TextField("账号", text: Binding(
|
||||
get: { store.account },
|
||||
set: { store.send(.updateAccount($0)) }
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.autocorrectionDisabled(true)
|
||||
SecureField("密码", text: Binding(
|
||||
get: { store.password },
|
||||
set: { store.send(.updatePassword($0)) }
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
}
|
||||
.padding(.horizontal)
|
||||
if let error = store.error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
Button(action: {
|
||||
store.send(.login)
|
||||
}) {
|
||||
HStack {
|
||||
if store.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(store.isLoading ? "登录中..." : "登录")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(store.isLoading ? Color.gray : Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(store.isLoading || store.account.isEmpty || store.password.isEmpty)
|
||||
Button(action: {
|
||||
initStore.send(.initialize)
|
||||
}) {
|
||||
HStack {
|
||||
if initStore.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(initStore.isLoading ? "测试中..." : "测试初始化")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(initStore.isLoading ? Color.gray : Color.green)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(initStore.isLoading)
|
||||
if let response = initStore.response {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("API 测试结果:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("状态: \(response.status)")
|
||||
if let message = response.message {
|
||||
Text("消息: \(message)")
|
||||
}
|
||||
if let data = response.data {
|
||||
Text("版本: \(data.version ?? "未知")")
|
||||
Text("时间戳: \(data.timestamp ?? 0)")
|
||||
if let config = data.config {
|
||||
Text("配置:")
|
||||
ForEach(Array(config.keys), id: \.self) { key in
|
||||
Text(" \(key): \(config[key] ?? "")")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
let store: StoreOf<LoginFeature>
|
||||
let initStore: StoreOf<InitFeature>
|
||||
@@ -38,155 +176,11 @@ struct ContentView: View {
|
||||
var body: some View {
|
||||
WithPerceptionTracking {
|
||||
TabView(selection: $selectedTab) {
|
||||
// 原有登录界面
|
||||
VStack {
|
||||
// 日志级别选择器
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("日志级别:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Picker("日志级别", selection: $selectedLogLevel) {
|
||||
Text("无日志").tag(APILogger.LogLevel.none)
|
||||
Text("基础日志").tag(APILogger.LogLevel.basic)
|
||||
Text("详细日志").tag(APILogger.LogLevel.detailed)
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
LoginTabView(store: store, initStore: initStore, selectedLogLevel: $selectedLogLevel)
|
||||
.tabItem {
|
||||
Label("登录", systemImage: "person.circle")
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 20) {
|
||||
Text("eparty")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
VStack(spacing: 15) {
|
||||
TextField("账号", text: Binding(
|
||||
get: { store.account },
|
||||
set: { store.send(.updateAccount($0)) }
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.autocorrectionDisabled(true)
|
||||
|
||||
SecureField("密码", text: Binding(
|
||||
get: { store.password },
|
||||
set: { store.send(.updatePassword($0)) }
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if let error = store.error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button(action: {
|
||||
store.send(.login)
|
||||
}) {
|
||||
HStack {
|
||||
if store.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(store.isLoading ? "登录中..." : "登录")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(store.isLoading ? Color.gray : Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(store.isLoading || store.account.isEmpty || store.password.isEmpty)
|
||||
|
||||
Button(action: {
|
||||
initStore.send(.initialize)
|
||||
}) {
|
||||
HStack {
|
||||
if initStore.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(initStore.isLoading ? "测试中..." : "测试初始化")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(initStore.isLoading ? Color.gray : Color.green)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(initStore.isLoading)
|
||||
|
||||
// API 测试结果显示区域
|
||||
if let response = initStore.response {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("API 测试结果:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("状态: \(response.status)")
|
||||
if let message = response.message {
|
||||
Text("消息: \(message)")
|
||||
}
|
||||
if let data = response.data {
|
||||
Text("版本: \(data.version ?? "未知")")
|
||||
Text("时间戳: \(data.timestamp ?? 0)")
|
||||
if let config = data.config {
|
||||
Text("配置:")
|
||||
ForEach(Array(config.keys), id: \.self) { key in
|
||||
Text(" \(key): \(config[key] ?? "")")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.tabItem {
|
||||
Label("登录", systemImage: "person.circle")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
// 新的 API 配置测试界面
|
||||
.tag(0)
|
||||
ConfigView(store: configStore)
|
||||
.tabItem {
|
||||
Label("API 测试", systemImage: "network")
|
||||
|
185
yana/Features/CreateFeedFeature.swift
Normal file
185
yana/Features/CreateFeedFeature.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
@Reducer
|
||||
struct CreateFeedFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var content: String = ""
|
||||
var processedImages: [UIImage] = []
|
||||
var errorMessage: String? = nil
|
||||
var characterCount: Int = 0
|
||||
var selectedImages: [PhotosPickerItem] = []
|
||||
var canAddMoreImages: Bool {
|
||||
processedImages.count < 9
|
||||
}
|
||||
var canPublish: Bool {
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isLoading
|
||||
}
|
||||
var isLoading: Bool = false
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case contentChanged(String)
|
||||
case publishButtonTapped
|
||||
case publishResponse(Result<PublishDynamicResponse, Error>)
|
||||
case clearError
|
||||
case dismissView
|
||||
case photosPickerItemsChanged([PhotosPickerItem])
|
||||
case processPhotosPickerItems([PhotosPickerItem])
|
||||
case removeImage(Int)
|
||||
case updateProcessedImages([UIImage])
|
||||
}
|
||||
|
||||
@Dependency(\.apiService) var apiService
|
||||
@Dependency(\.dismiss) var dismiss
|
||||
@Dependency(\.isPresented) var isPresented
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .contentChanged(let newContent):
|
||||
state.content = newContent
|
||||
state.characterCount = newContent.count
|
||||
return .none
|
||||
case .photosPickerItemsChanged(let items):
|
||||
state.selectedImages = items
|
||||
return .run { send in
|
||||
await send(.processPhotosPickerItems(items))
|
||||
}
|
||||
case .processPhotosPickerItems(let items):
|
||||
let currentImages = state.processedImages
|
||||
return .run { send in
|
||||
var newImages = currentImages
|
||||
for item in items {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) else { continue }
|
||||
if newImages.count < 9 {
|
||||
newImages.append(image)
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
send(.updateProcessedImages(newImages))
|
||||
}
|
||||
}
|
||||
case .updateProcessedImages(let images):
|
||||
state.processedImages = images
|
||||
return .none
|
||||
case .removeImage(let index):
|
||||
guard index < state.processedImages.count else { return .none }
|
||||
state.processedImages.remove(at: index)
|
||||
if index < state.selectedImages.count {
|
||||
state.selectedImages.remove(at: index)
|
||||
}
|
||||
return .none
|
||||
case .publishButtonTapped:
|
||||
guard state.canPublish else {
|
||||
state.errorMessage = "请输入内容"
|
||||
return .none
|
||||
}
|
||||
state.isLoading = true
|
||||
state.errorMessage = nil
|
||||
let request = PublishDynamicRequest(
|
||||
content: state.content.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
images: state.processedImages
|
||||
)
|
||||
return .run { send in
|
||||
do {
|
||||
let response = try await apiService.request(request)
|
||||
await send(.publishResponse(.success(response)))
|
||||
} catch {
|
||||
await send(.publishResponse(.failure(error)))
|
||||
}
|
||||
}
|
||||
case .publishResponse(.success(let response)):
|
||||
state.isLoading = false
|
||||
if response.code == 200 {
|
||||
return .send(.dismissView)
|
||||
} else {
|
||||
state.errorMessage = response.message.isEmpty ? "发布失败" : response.message
|
||||
return .none
|
||||
}
|
||||
case .publishResponse(.failure(let error)):
|
||||
state.isLoading = false
|
||||
state.errorMessage = error.localizedDescription
|
||||
return .none
|
||||
case .clearError:
|
||||
state.errorMessage = nil
|
||||
return .none
|
||||
case .dismissView:
|
||||
// 检查是否在presentation context中
|
||||
guard isPresented else {
|
||||
// 如果不在presentation context中,不执行dismiss
|
||||
return .none
|
||||
}
|
||||
return .run { _ in
|
||||
await dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CreateFeedFeature.Action: Equatable {
|
||||
static func == (lhs: CreateFeedFeature.Action, rhs: CreateFeedFeature.Action) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.contentChanged(a), .contentChanged(b)):
|
||||
return a == b
|
||||
case (.publishButtonTapped, .publishButtonTapped):
|
||||
return true
|
||||
case (.clearError, .clearError):
|
||||
return true
|
||||
case (.dismissView, .dismissView):
|
||||
return true
|
||||
case let (.removeImage(a), .removeImage(b)):
|
||||
return a == b
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 发布动态相关模型
|
||||
|
||||
struct PublishDynamicRequest: APIRequestProtocol {
|
||||
typealias Response = PublishDynamicResponse
|
||||
let endpoint: String = "/dynamic/square/publish"
|
||||
let method: HTTPMethod = .POST
|
||||
let includeBaseParameters: Bool = true
|
||||
let queryParameters: [String: String]? = nil
|
||||
let timeout: TimeInterval = 30.0
|
||||
let content: String
|
||||
let images: [UIImage]
|
||||
let type: Int // 0: 纯文字, 2: 图片
|
||||
init(content: String, images: [UIImage] = []) {
|
||||
self.content = content
|
||||
self.images = images
|
||||
self.type = images.isEmpty ? 0 : 2
|
||||
}
|
||||
var bodyParameters: [String: Any]? {
|
||||
var params: [String: Any] = [
|
||||
"content": content,
|
||||
"type": type
|
||||
]
|
||||
if !images.isEmpty {
|
||||
let imageData = images.compactMap { image in
|
||||
image.jpegData(compressionQuality: 0.8)?.base64EncodedString()
|
||||
}
|
||||
params["images"] = imageData
|
||||
}
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
struct PublishDynamicResponse: Codable {
|
||||
let code: Int
|
||||
let message: String
|
||||
let data: PublishDynamicData?
|
||||
}
|
||||
|
||||
struct PublishDynamicData: Codable {
|
||||
let dynamicId: Int
|
||||
let publishTime: Int
|
||||
}
|
@@ -11,11 +11,20 @@ struct EMailLoginFeature {
|
||||
var isCodeLoading: Bool = false
|
||||
var errorMessage: String? = nil
|
||||
var isCodeSent: Bool = false
|
||||
// 新增:登录流程状态
|
||||
var loginStep: LoginStep = .initial
|
||||
enum LoginStep: Equatable {
|
||||
case initial
|
||||
case authenticating
|
||||
case completed
|
||||
case failed
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
init() {
|
||||
self.email = "exzero@126.com"
|
||||
self.verificationCode = ""
|
||||
self.loginStep = .initial
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -48,12 +57,12 @@ struct EMailLoginFeature {
|
||||
|
||||
case .getVerificationCodeTapped:
|
||||
guard !state.email.isEmpty else {
|
||||
state.errorMessage = "email_login.email_required".localized
|
||||
state.errorMessage = NSLocalizedString("email_login.email_required", comment: "")
|
||||
return .none
|
||||
}
|
||||
|
||||
guard ValidationHelper.isValidEmail(state.email) else {
|
||||
state.errorMessage = "email_login.invalid_email".localized
|
||||
state.errorMessage = NSLocalizedString("email_login.invalid_email", comment: "")
|
||||
return .none
|
||||
}
|
||||
|
||||
@@ -98,21 +107,22 @@ struct EMailLoginFeature {
|
||||
|
||||
case .loginButtonTapped(let email, let verificationCode):
|
||||
guard !email.isEmpty && !verificationCode.isEmpty else {
|
||||
state.errorMessage = "email_login.fields_required".localized
|
||||
state.errorMessage = NSLocalizedString("email_login.fields_required", comment: "")
|
||||
return .none
|
||||
}
|
||||
|
||||
guard ValidationHelper.isValidEmail(email) else {
|
||||
state.errorMessage = "email_login.invalid_email".localized
|
||||
state.errorMessage = NSLocalizedString("email_login.invalid_email", comment: "")
|
||||
return .none
|
||||
}
|
||||
|
||||
state.isLoading = true
|
||||
state.errorMessage = nil
|
||||
state.loginStep = .authenticating
|
||||
|
||||
return .run { send in
|
||||
do {
|
||||
guard let request = LoginHelper.createEmailLoginRequest(email: email, code: verificationCode) else {
|
||||
guard let request = await LoginHelper.createEmailLoginRequest(email: email, code: verificationCode) else {
|
||||
await send(.loginResponse(.failure(APIError.encryptionFailed)))
|
||||
return
|
||||
}
|
||||
@@ -149,17 +159,16 @@ struct EMailLoginFeature {
|
||||
|
||||
case .loginResponse(.success(let accountModel)):
|
||||
state.isLoading = false
|
||||
|
||||
// 保存AccountModel到本地存储
|
||||
UserInfoManager.saveAccountModel(accountModel)
|
||||
|
||||
// 发送成功通知,触发导航到主界面
|
||||
NotificationCenter.default.post(name: .ticketSuccess, object: nil)
|
||||
|
||||
return .none
|
||||
state.loginStep = .completed
|
||||
// Effect 保存AccountModel并发送通知
|
||||
return .run { _ in
|
||||
await UserInfoManager.saveAccountModel(accountModel)
|
||||
// 移除:NotificationCenter.default.post(name: .ticketSuccess, object: nil)
|
||||
}
|
||||
|
||||
case .loginResponse(.failure(let error)):
|
||||
state.isLoading = false
|
||||
state.loginStep = .failed
|
||||
if let apiError = error as? APIError {
|
||||
state.errorMessage = apiError.localizedDescription
|
||||
} else {
|
||||
@@ -177,6 +186,7 @@ struct EMailLoginFeature {
|
||||
state.isCodeLoading = false
|
||||
state.errorMessage = nil
|
||||
state.isCodeSent = false
|
||||
state.loginStep = .initial
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
@@ -7,113 +7,105 @@ struct FeedFeature {
|
||||
struct State: Equatable {
|
||||
var moments: [MomentsInfo] = []
|
||||
var isLoading = false
|
||||
var isRefreshing = false
|
||||
var hasMoreData = true
|
||||
var error: String?
|
||||
var nextDynamicId: Int = 0
|
||||
|
||||
// 是否已初始化
|
||||
var isInitialized = false
|
||||
// CreateFeedView 相关状态
|
||||
var createFeedState = CreateFeedFeature.State()
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
case onAppear
|
||||
case refresh
|
||||
case loadLatestMoments
|
||||
case loadMoreMoments
|
||||
case momentsResponse(TaskResult<MomentsLatestResponse>)
|
||||
case clearError
|
||||
case retryLoad
|
||||
// CreateFeedView 相关 Action
|
||||
case createFeedCompleted
|
||||
case createFeedDismissed
|
||||
// CreateFeedFeature 的 action
|
||||
case createFeed(CreateFeedFeature.Action)
|
||||
}
|
||||
|
||||
@Dependency(\.apiService) var apiService
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Scope(state: \.createFeedState, action: \.createFeed) {
|
||||
CreateFeedFeature()
|
||||
}
|
||||
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .onAppear:
|
||||
// 只在首次出现时触发加载
|
||||
guard !state.isInitialized else { return .none }
|
||||
state.isInitialized = true
|
||||
guard state.moments.isEmpty && !state.isLoading else { return .none }
|
||||
return .send(.loadLatestMoments)
|
||||
|
||||
case .refresh:
|
||||
guard !state.isRefreshing else { return .none }
|
||||
state.isRefreshing = true
|
||||
state.error = nil
|
||||
let request = LatestDynamicsRequest(dynamicId: "", pageSize: 20, types: [.text, .picture])
|
||||
return .run { send in
|
||||
await send(.momentsResponse(TaskResult { try await apiService.request(request) }))
|
||||
}
|
||||
case .loadLatestMoments:
|
||||
// 加载最新数据(下拉刷新)
|
||||
guard !state.isLoading else { return .none }
|
||||
state.isLoading = true
|
||||
state.error = nil
|
||||
|
||||
let request = LatestDynamicsRequest(
|
||||
dynamicId: "", // 首次加载传空字符串
|
||||
pageSize: 20,
|
||||
types: [.text, .picture]
|
||||
)
|
||||
|
||||
let request = LatestDynamicsRequest(dynamicId: "", pageSize: 20, types: [.text, .picture])
|
||||
return .run { send in
|
||||
await send(.momentsResponse(TaskResult {
|
||||
try await apiService.request(request)
|
||||
}))
|
||||
await send(.momentsResponse(TaskResult { try await apiService.request(request) }))
|
||||
}
|
||||
|
||||
case .loadMoreMoments:
|
||||
// 加载更多数据(分页加载)
|
||||
guard !state.isLoading && state.hasMoreData else { return .none }
|
||||
|
||||
state.isLoading = true
|
||||
state.error = nil
|
||||
|
||||
let request = LatestDynamicsRequest(
|
||||
dynamicId: state.nextDynamicId == 0 ? "" : String(state.nextDynamicId),
|
||||
pageSize: 20,
|
||||
types: [.text, .picture]
|
||||
)
|
||||
|
||||
let request = LatestDynamicsRequest(dynamicId: state.nextDynamicId == 0 ? "" : String(state.nextDynamicId), pageSize: 20, types: [.text, .picture])
|
||||
return .run { send in
|
||||
await send(.momentsResponse(TaskResult {
|
||||
try await apiService.request(request)
|
||||
}))
|
||||
await send(.momentsResponse(TaskResult { try await apiService.request(request) }))
|
||||
}
|
||||
|
||||
case let .momentsResponse(.success(response)):
|
||||
state.isLoading = false
|
||||
|
||||
// 检查响应状态
|
||||
state.isRefreshing = false
|
||||
guard response.code == 200, let data = response.data else {
|
||||
state.error = response.message.isEmpty ? "获取动态失败" : response.message
|
||||
let errorMsg = response.message.isEmpty ? "获取动态失败" : response.message
|
||||
state.error = errorMsg
|
||||
return .none
|
||||
}
|
||||
|
||||
// 判断是刷新还是加载更多
|
||||
let isRefresh = state.nextDynamicId == 0
|
||||
|
||||
let isRefresh = state.nextDynamicId == 0 || state.isRefreshing
|
||||
if isRefresh {
|
||||
// 刷新:替换所有数据
|
||||
state.moments = data.dynamicList
|
||||
} else {
|
||||
// 加载更多:追加到现有数据
|
||||
state.moments.append(contentsOf: data.dynamicList)
|
||||
}
|
||||
|
||||
// 更新分页状态
|
||||
state.nextDynamicId = data.nextDynamicId
|
||||
state.hasMoreData = !data.dynamicList.isEmpty
|
||||
|
||||
return .none
|
||||
|
||||
case let .momentsResponse(.failure(error)):
|
||||
state.isLoading = false
|
||||
state.isRefreshing = false
|
||||
state.error = error.localizedDescription
|
||||
return .none
|
||||
|
||||
case .clearError:
|
||||
state.error = nil
|
||||
return .none
|
||||
|
||||
case .retryLoad:
|
||||
// 重试加载
|
||||
if state.moments.isEmpty {
|
||||
return .send(.loadLatestMoments)
|
||||
} else {
|
||||
return .send(.loadMoreMoments)
|
||||
}
|
||||
case .createFeedCompleted:
|
||||
return .send(.refresh)
|
||||
case .createFeedDismissed:
|
||||
return .none
|
||||
case .createFeed(.dismissView):
|
||||
return .send(.createFeedDismissed)
|
||||
case .createFeed:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
50
yana/Features/FeedListFeature.swift
Normal file
50
yana/Features/FeedListFeature.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
|
||||
struct FeedListFeature: Reducer {
|
||||
struct State: Equatable {
|
||||
var feeds: [Feed] = [] // 预留 feed 内容
|
||||
var isLoading: Bool = false
|
||||
var error: String? = nil
|
||||
var isEditFeedPresented: Bool = false // 新增:控制 EditFeedView 弹窗
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
case onAppear
|
||||
case reload
|
||||
case loadMore
|
||||
case editFeedButtonTapped // 新增:点击 add 按钮
|
||||
case editFeedDismissed // 新增:关闭编辑页
|
||||
// 预留后续 Action
|
||||
}
|
||||
|
||||
func reduce(into state: inout State, action: Action) -> Effect<Action> {
|
||||
switch action {
|
||||
case .onAppear:
|
||||
// 预留数据加载逻辑
|
||||
return .none
|
||||
case .reload:
|
||||
// 预留刷新逻辑
|
||||
return .none
|
||||
case .loadMore:
|
||||
// 预留分页加载逻辑
|
||||
return .none
|
||||
case .editFeedButtonTapped:
|
||||
state.isEditFeedPresented = true
|
||||
return .none
|
||||
case .editFeedDismissed:
|
||||
state.isEditFeedPresented = false
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Feed 数据模型占位,后续可替换为真实模型
|
||||
enum Feed: Equatable, Identifiable {
|
||||
case placeholder(id: UUID = UUID())
|
||||
var id: UUID {
|
||||
switch self {
|
||||
case .placeholder(let id): return id
|
||||
}
|
||||
}
|
||||
}
|
@@ -3,8 +3,12 @@ import ComposableArchitecture
|
||||
|
||||
@Reducer
|
||||
struct HomeFeature {
|
||||
enum Route: Equatable {
|
||||
case createFeed
|
||||
}
|
||||
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
struct State: Equatable, Sendable {
|
||||
var isInitialized = false
|
||||
var userInfo: UserInfo?
|
||||
var accountModel: AccountModel?
|
||||
@@ -13,8 +17,18 @@ struct HomeFeature {
|
||||
// 设置页面相关状态
|
||||
var isSettingPresented = false
|
||||
var settingState = SettingFeature.State()
|
||||
|
||||
// 新增:Feed 状态
|
||||
var feedState = FeedFeature.State()
|
||||
|
||||
// 新增:登出状态
|
||||
var isLoggedOut = false
|
||||
|
||||
// 新增:路由状态
|
||||
var route: Route? = nil
|
||||
}
|
||||
|
||||
@CasePathable
|
||||
enum Action: Equatable {
|
||||
case onAppear
|
||||
case loadUserInfo
|
||||
@@ -27,64 +41,87 @@ struct HomeFeature {
|
||||
// 设置页面相关actions
|
||||
case settingDismissed
|
||||
case setting(SettingFeature.Action)
|
||||
|
||||
// 新增:Feed actions
|
||||
case feed(FeedFeature.Action)
|
||||
|
||||
// 新增:登出完成
|
||||
case logoutCompleted
|
||||
|
||||
// 新增:路由 actions
|
||||
case showCreateFeed
|
||||
case createFeedDismissed
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Scope(state: \.settingState, action: \.setting) {
|
||||
SettingFeature()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
case .settingDismissed:
|
||||
state.isSettingPresented = false
|
||||
return .none
|
||||
|
||||
case .setting:
|
||||
// 由子reducer处理
|
||||
return .none
|
||||
}
|
||||
}
|
||||
var body: some Reducer<State, Action> {
|
||||
// Reducer<State, Action>.combine([
|
||||
// Reducer { state, action in
|
||||
// switch action {
|
||||
// case .onAppear:
|
||||
// guard !state.isInitialized else {
|
||||
// return Effect.none
|
||||
// }
|
||||
// state.isInitialized = true
|
||||
// return .concatenate(
|
||||
// .send(.loadUserInfo),
|
||||
// .send(.loadAccountModel)
|
||||
// )
|
||||
// case .loadUserInfo:
|
||||
// return .run { send in
|
||||
// let userInfo = await UserInfoManager.getUserInfo()
|
||||
// await send(.userInfoLoaded(userInfo))
|
||||
// }
|
||||
// case let .userInfoLoaded(userInfo):
|
||||
// state.userInfo = userInfo
|
||||
// return Effect.none
|
||||
// case .loadAccountModel:
|
||||
// return .run { send in
|
||||
// let accountModel = await UserInfoManager.getAccountModel()
|
||||
// await send(.accountModelLoaded(accountModel))
|
||||
// }
|
||||
// case let .accountModelLoaded(accountModel):
|
||||
// state.accountModel = accountModel
|
||||
// return Effect.none
|
||||
// case .logoutTapped:
|
||||
// return .send(.logout)
|
||||
// case .logout:
|
||||
// return .run { send in
|
||||
// await UserInfoManager.clearAllAuthenticationData()
|
||||
// await send(.logoutCompleted)
|
||||
// }
|
||||
// case .logoutCompleted:
|
||||
// state.isLoggedOut = true
|
||||
// return Effect.none
|
||||
// case .settingDismissed:
|
||||
// state.isSettingPresented = false
|
||||
// return Effect.none
|
||||
// case .setting:
|
||||
// return Effect.none
|
||||
// case .showCreateFeed:
|
||||
// state.route = .createFeed
|
||||
// return Effect.none
|
||||
// case .createFeedDismissed:
|
||||
// state.route = nil
|
||||
// return Effect.none
|
||||
// case .feed:
|
||||
// return Effect.none
|
||||
// }
|
||||
// },
|
||||
// Scope(
|
||||
// state: \State.settingState,
|
||||
// action: /Action.setting,
|
||||
// child: SettingFeature()
|
||||
// ),
|
||||
// Scope(
|
||||
// state: \State.feedState,
|
||||
// action: /Action.feed,
|
||||
// child: FeedFeature()
|
||||
// )
|
||||
// ])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Extension
|
||||
extension Notification.Name {
|
||||
static let homeLogout = Notification.Name("homeLogout")
|
||||
}
|
||||
// 移除:未使用的通知名称定义
|
||||
// extension Notification.Name {
|
||||
// static let homeLogout = Notification.Name("homeLogout")
|
||||
// }
|
||||
|
@@ -27,9 +27,8 @@ struct IDLoginFeature {
|
||||
|
||||
#if DEBUG
|
||||
init() {
|
||||
// 移除测试用的硬编码凭据
|
||||
self.userID = ""
|
||||
self.password = ""
|
||||
self.userID = "2356814"
|
||||
self.password = "a123456"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -56,7 +55,6 @@ struct IDLoginFeature {
|
||||
case .togglePasswordVisibility:
|
||||
state.isPasswordVisible.toggle()
|
||||
return .none
|
||||
|
||||
case let .loginButtonTapped(userID, password):
|
||||
state.userID = userID
|
||||
state.password = password
|
||||
@@ -64,17 +62,13 @@ struct IDLoginFeature {
|
||||
state.errorMessage = nil
|
||||
state.ticketError = nil
|
||||
state.loginStep = .authenticating
|
||||
|
||||
// 实现真实的ID登录API调用
|
||||
// 真实登录 API 调用 Effect
|
||||
return .run { send in
|
||||
do {
|
||||
// 使用LoginHelper创建加密的登录请求
|
||||
guard let loginRequest = LoginHelper.createIDLoginRequest(userID: userID, password: password) else {
|
||||
guard let loginRequest = await 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 {
|
||||
@@ -85,35 +79,21 @@ struct IDLoginFeature {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 保存用户信息(如果有)
|
||||
// 触发 Effect 保存 userInfo(如有)
|
||||
if let userInfo = loginData.userInfo {
|
||||
UserInfoManager.saveUserInfo(userInfo)
|
||||
return .run { _ in await UserInfoManager.saveUserInfo(userInfo) }
|
||||
}
|
||||
|
||||
debugInfo("✅ ID 登录 OAuth 认证成功")
|
||||
debugInfo("🔑 Access Token: \(accountModel.accessToken ?? "nil")")
|
||||
debugInfo("🆔 用户 UID: \(accountModel.uid ?? "nil")")
|
||||
|
||||
// 自动获取 ticket
|
||||
return .send(.requestTicket(accessToken: accountModel.accessToken!))
|
||||
} else {
|
||||
@@ -125,90 +105,77 @@ struct IDLoginFeature {
|
||||
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
|
||||
// 先拷贝所需字段,避免并发捕获
|
||||
let uid: Int? = {
|
||||
if let am = state.accountModel, let uidStr = am.uid { return Int(uidStr) } else { return nil }
|
||||
}()
|
||||
return .run { 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 {
|
||||
debugError("❌ ID登录 Ticket 获取失败: \(error)")
|
||||
debugErrorSync("❌ 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
|
||||
|
||||
debugInfo("✅ ID 登录完整流程成功")
|
||||
debugInfo("🎫 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 {
|
||||
debugError("❌ AccountModel 不存在,无法保存 ticket")
|
||||
state.ticketError = "内部错误:账户信息丢失"
|
||||
state.loginStep = .failed
|
||||
debugInfoSync("✅ ID 登录完整流程成功")
|
||||
debugInfoSync("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
|
||||
// --- 并发安全修正:彻底避免 Effect 闭包捕获 state/accountModel ---
|
||||
if let ticket = response.ticket, let oldAccountModel = state.accountModel {
|
||||
// 用 withTicket 生成新 struct,闭包只捕获 newAccountModel
|
||||
let newAccountModel = oldAccountModel.withTicket(ticket)
|
||||
state.accountModel = newAccountModel
|
||||
// 只捕获 newAccountModel,绝不捕获 state
|
||||
return .run { _ in
|
||||
// 这里不能捕获 state/accountModel,否则 Swift 并发会报错
|
||||
await UserInfoManager.saveAccountModel(newAccountModel)
|
||||
}
|
||||
} else {
|
||||
} else if response.ticket == nil {
|
||||
state.ticketError = "Ticket 为空"
|
||||
state.loginStep = .failed
|
||||
} else {
|
||||
debugErrorSync("❌ AccountModel 不存在,无法保存 ticket")
|
||||
state.ticketError = "内部错误:账户信息丢失"
|
||||
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
|
||||
debugError("❌ ID 登录 Ticket 获取失败: \(error.localizedDescription)")
|
||||
debugErrorSync("❌ 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.accountModel = nil
|
||||
state.loginStep = .initial
|
||||
|
||||
// 清除本地存储的认证信息
|
||||
UserInfoManager.clearAllAuthenticationData()
|
||||
|
||||
return .none
|
||||
// Effect 清除认证信息
|
||||
return .run { _ in await UserInfoManager.clearAllAuthenticationData() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -11,6 +11,8 @@ struct LoginFeature {
|
||||
var error: String?
|
||||
var idLoginState = IDLoginFeature.State()
|
||||
var emailLoginState = EMailLoginFeature.State() // 新增:邮箱登录状态
|
||||
// 新增:HomeFeature 状态
|
||||
var homeState = HomeFeature.State()
|
||||
|
||||
// 新增:Account Model 和 Ticket 相关状态
|
||||
var accountModel: AccountModel?
|
||||
@@ -18,6 +20,14 @@ struct LoginFeature {
|
||||
var ticketError: String?
|
||||
var loginStep: LoginStep = .initial
|
||||
|
||||
// 新增:初始化状态管理 - 防止重复执行
|
||||
var isInitialized = false
|
||||
|
||||
// 新增:任一登录方式完成时为 true
|
||||
var isAnyLoginCompleted: Bool {
|
||||
idLoginState.loginStep == .completed || emailLoginState.loginStep == .completed
|
||||
}
|
||||
|
||||
enum LoginStep: Equatable {
|
||||
case initial // 初始状态
|
||||
case authenticating // 正在进行 OAuth 认证
|
||||
@@ -36,13 +46,15 @@ struct LoginFeature {
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case onAppear
|
||||
case updateAccount(String)
|
||||
case updatePassword(String)
|
||||
case login
|
||||
case loginResponse(TaskResult<IDLoginResponse>)
|
||||
case idLogin(IDLoginFeature.Action)
|
||||
case emailLogin(EMailLoginFeature.Action) // 新增:邮箱登录action
|
||||
|
||||
// 新增:HomeFeature action
|
||||
case home(HomeFeature.Action)
|
||||
// 新增:Ticket 相关 actions
|
||||
case requestTicket(accessToken: String)
|
||||
case ticketResponse(TaskResult<TicketResponse>)
|
||||
@@ -60,9 +72,26 @@ struct LoginFeature {
|
||||
Scope(state: \.emailLoginState, action: \.emailLogin) {
|
||||
EMailLoginFeature()
|
||||
}
|
||||
// 新增:HomeFeature 作用域
|
||||
Scope(state: \.homeState, action: \.home) {
|
||||
HomeFeature()
|
||||
}
|
||||
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .onAppear:
|
||||
// 防止重复初始化
|
||||
guard !state.isInitialized else {
|
||||
debugInfoSync("🚀 LoginFeature: 已初始化,跳过重复执行")
|
||||
return .none
|
||||
}
|
||||
|
||||
state.isInitialized = true
|
||||
debugInfoSync("🚀 LoginFeature: 首次初始化")
|
||||
|
||||
// 登录页面出现时的初始化逻辑
|
||||
return .none
|
||||
|
||||
case let .updateAccount(account):
|
||||
state.account = account
|
||||
return .none
|
||||
@@ -81,7 +110,7 @@ struct LoginFeature {
|
||||
return .run { [account = state.account, password = state.password] send in
|
||||
do {
|
||||
// 使用LoginHelper创建加密的登录请求
|
||||
guard let loginRequest = LoginHelper.createIDLoginRequest(userID: account, password: password) else {
|
||||
guard let loginRequest = await LoginHelper.createIDLoginRequest(userID: account, password: password) else {
|
||||
await send(.loginResponse(.failure(APIError.decodingError("加密失败"))))
|
||||
return
|
||||
}
|
||||
@@ -108,10 +137,9 @@ struct LoginFeature {
|
||||
if let loginData = response.data,
|
||||
let accountModel = AccountModel.from(loginData: loginData) {
|
||||
state.accountModel = accountModel
|
||||
|
||||
debugInfo("✅ OAuth 认证成功")
|
||||
debugInfo("🔑 Access Token: \(accountModel.accessToken ?? "nil")")
|
||||
debugInfo("🆔 用户 UID: \(accountModel.uid ?? "nil")")
|
||||
debugInfoSync("✅ OAuth 认证成功")
|
||||
debugInfoSync("🔑 Access Token: \(accountModel.accessToken ?? "nil")")
|
||||
debugInfoSync("🆔 用户 UID: \(accountModel.uid ?? "nil")")
|
||||
|
||||
// 自动获取 ticket
|
||||
return .send(.requestTicket(accessToken: accountModel.accessToken!))
|
||||
@@ -144,7 +172,7 @@ struct LoginFeature {
|
||||
let response = try await apiService.request(ticketRequest)
|
||||
await send(.ticketResponse(.success(response)))
|
||||
} catch {
|
||||
debugError("❌ Ticket 获取失败: \(error)")
|
||||
debugErrorSync("❌ Ticket 获取失败: \(error)")
|
||||
await send(.ticketResponse(.failure(APIError.networkError(error.localizedDescription))))
|
||||
}
|
||||
}
|
||||
@@ -155,22 +183,20 @@ struct LoginFeature {
|
||||
state.ticketError = nil
|
||||
state.loginStep = .completed
|
||||
|
||||
debugInfo("✅ 完整登录流程成功")
|
||||
debugInfo("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
|
||||
debugInfoSync("✅ 完整登录流程成功")
|
||||
debugInfoSync("🎫 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)
|
||||
if let oldAccountModel = state.accountModel {
|
||||
let newAccountModel = oldAccountModel.withTicket(ticket)
|
||||
state.accountModel = newAccountModel
|
||||
// Effect 保存完整的 AccountModel
|
||||
return .run { _ in
|
||||
await UserInfoManager.saveAccountModel(newAccountModel)
|
||||
}
|
||||
} else {
|
||||
debugError("❌ AccountModel 不存在,无法保存 ticket")
|
||||
debugErrorSync("❌ AccountModel 不存在,无法保存 ticket")
|
||||
state.ticketError = "内部错误:账户信息丢失"
|
||||
state.loginStep = .failed
|
||||
}
|
||||
@@ -189,7 +215,7 @@ struct LoginFeature {
|
||||
state.isTicketLoading = false
|
||||
state.ticketError = error.localizedDescription
|
||||
state.loginStep = .failed
|
||||
debugError("❌ Ticket 获取失败: \(error.localizedDescription)")
|
||||
debugErrorSync("❌ Ticket 获取失败: \(error.localizedDescription)")
|
||||
return .none
|
||||
|
||||
case .clearTicketError:
|
||||
@@ -203,11 +229,10 @@ struct LoginFeature {
|
||||
state.ticketError = nil
|
||||
state.accountModel = nil // 清除 AccountModel
|
||||
state.loginStep = .initial
|
||||
|
||||
// 清除本地存储的认证信息
|
||||
UserInfoManager.clearAllAuthenticationData()
|
||||
|
||||
return .none
|
||||
// Effect 清除本地存储的认证信息
|
||||
return .run { _ in
|
||||
await UserInfoManager.clearAllAuthenticationData()
|
||||
}
|
||||
|
||||
case .idLogin:
|
||||
// IDLogin动作由子feature处理
|
||||
@@ -216,7 +241,14 @@ struct LoginFeature {
|
||||
case .emailLogin:
|
||||
// EmailLogin动作由子feature处理
|
||||
return .none
|
||||
case .home(_):
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除:未使用的通知名称定义
|
||||
// extension Notification.Name {
|
||||
// static let ticketSuccess = Notification.Name("ticketSuccess")
|
||||
// }
|
||||
|
35
yana/Features/MainFeature.swift
Normal file
35
yana/Features/MainFeature.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
import CasePaths
|
||||
|
||||
struct MainFeature: Reducer {
|
||||
enum Tab: Int, Equatable, CaseIterable {
|
||||
case feed, other
|
||||
}
|
||||
|
||||
struct State: Equatable {
|
||||
var selectedTab: Tab = .feed
|
||||
var feedList: FeedListFeature.State = .init()
|
||||
}
|
||||
|
||||
@CasePathable
|
||||
enum Action: Equatable {
|
||||
case selectTab(Tab)
|
||||
case feedList(FeedListFeature.Action)
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Scope(state: \.feedList, action: \.feedList) {
|
||||
FeedListFeature()
|
||||
}
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .selectTab(let tab):
|
||||
state.selectedTab = tab
|
||||
return .none
|
||||
case .feedList:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -57,12 +57,12 @@ struct RecoverPasswordFeature {
|
||||
|
||||
case .getVerificationCodeTapped:
|
||||
guard !state.email.isEmpty else {
|
||||
state.errorMessage = "recover_password.email_required".localized
|
||||
state.errorMessage = NSLocalizedString("recover_password.email_required", comment: "")
|
||||
return .none
|
||||
}
|
||||
|
||||
guard ValidationHelper.isValidEmail(state.email) else {
|
||||
state.errorMessage = "recover_password.invalid_email".localized
|
||||
state.errorMessage = NSLocalizedString("recover_password.invalid_email", comment: "")
|
||||
return .none
|
||||
}
|
||||
|
||||
@@ -101,23 +101,23 @@ struct RecoverPasswordFeature {
|
||||
if let apiError = error as? APIError {
|
||||
state.errorMessage = apiError.localizedDescription
|
||||
} else {
|
||||
state.errorMessage = "recover_password.code_send_failed".localized
|
||||
state.errorMessage = NSLocalizedString("recover_password.code_send_failed", comment: "")
|
||||
}
|
||||
return .none
|
||||
|
||||
case .resetPasswordTapped:
|
||||
guard !state.email.isEmpty && !state.verificationCode.isEmpty && !state.newPassword.isEmpty else {
|
||||
state.errorMessage = "recover_password.fields_required".localized
|
||||
state.errorMessage = NSLocalizedString("recover_password.fields_required", comment: "")
|
||||
return .none
|
||||
}
|
||||
|
||||
guard ValidationHelper.isValidEmail(state.email) else {
|
||||
state.errorMessage = "recover_password.invalid_email".localized
|
||||
state.errorMessage = NSLocalizedString("recover_password.invalid_email", comment: "")
|
||||
return .none
|
||||
}
|
||||
|
||||
guard ValidationHelper.isValidPassword(state.newPassword) else {
|
||||
state.errorMessage = "recover_password.invalid_password".localized
|
||||
state.errorMessage = NSLocalizedString("recover_password.invalid_password", comment: "")
|
||||
return .none
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ struct RecoverPasswordFeature {
|
||||
if let apiError = error as? APIError {
|
||||
state.errorMessage = apiError.localizedDescription
|
||||
} else {
|
||||
state.errorMessage = "recover_password.reset_failed".localized
|
||||
state.errorMessage = NSLocalizedString("recover_password.reset_failed", comment: "")
|
||||
}
|
||||
return .none
|
||||
|
||||
@@ -199,7 +199,7 @@ struct ResetPasswordResponse: Codable, Equatable {
|
||||
|
||||
/// 错误消息(如果有)
|
||||
var errorMessage: String {
|
||||
return message ?? "recover_password.reset_failed".localized
|
||||
return message ?? NSLocalizedString("recover_password.reset_failed", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ struct ResetPasswordRequest: APIRequestProtocol {
|
||||
let method: HTTPMethod = .POST
|
||||
let includeBaseParameters = true
|
||||
let queryParameters: [String: String]?
|
||||
let bodyParameters: [String: Any]? = nil
|
||||
var bodyParameters: [String: Any]? { nil }
|
||||
let timeout: TimeInterval = 30.0
|
||||
|
||||
/// 初始化密码重置请求
|
||||
@@ -238,13 +238,13 @@ struct RecoverPasswordHelper {
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey) else {
|
||||
debugError("❌ 邮箱DES加密失败")
|
||||
debugErrorSync("❌ 邮箱DES加密失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
debugInfo("🔐 密码恢复邮箱DES加密成功")
|
||||
debugInfo(" 原始邮箱: \(email)")
|
||||
debugInfo(" 加密邮箱: \(encryptedEmail)")
|
||||
debugInfoSync("🔐 密码恢复邮箱DES加密成功")
|
||||
debugInfoSync(" 原始邮箱: \(email)")
|
||||
debugInfoSync(" 加密邮箱: \(encryptedEmail)")
|
||||
|
||||
// 使用type=3表示密码重置验证码
|
||||
return EmailGetCodeRequest(emailAddress: email, type: 3)
|
||||
@@ -261,16 +261,16 @@ struct RecoverPasswordHelper {
|
||||
|
||||
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey),
|
||||
let encryptedPassword = DESEncrypt.encryptUseDES(newPassword, key: encryptionKey) else {
|
||||
debugError("❌ 密码重置DES加密失败")
|
||||
debugErrorSync("❌ 密码重置DES加密失败")
|
||||
return nil
|
||||
}
|
||||
|
||||
debugInfo("🔐 密码重置DES加密成功")
|
||||
debugInfo(" 原始邮箱: \(email)")
|
||||
debugInfo(" 加密邮箱: \(encryptedEmail)")
|
||||
debugInfo(" 验证码: \(code)")
|
||||
debugInfo(" 原始新密码: \(newPassword)")
|
||||
debugInfo(" 加密新密码: \(encryptedPassword)")
|
||||
debugInfoSync("🔐 密码重置DES加密成功")
|
||||
debugInfoSync(" 原始邮箱: \(email)")
|
||||
debugInfoSync(" 加密邮箱: \(encryptedEmail)")
|
||||
debugInfoSync(" 验证码: \(code)")
|
||||
debugInfoSync(" 原始新密码: \(newPassword)")
|
||||
debugInfoSync(" 加密新密码: \(encryptedPassword)")
|
||||
|
||||
return ResetPasswordRequest(
|
||||
email: email,
|
||||
|
@@ -32,16 +32,20 @@ struct SettingFeature {
|
||||
)
|
||||
|
||||
case .loadUserInfo:
|
||||
let userInfo = UserInfoManager.getUserInfo()
|
||||
return .send(.userInfoLoaded(userInfo))
|
||||
return .run { send in
|
||||
let userInfo = await UserInfoManager.getUserInfo()
|
||||
await send(.userInfoLoaded(userInfo))
|
||||
}
|
||||
|
||||
case let .userInfoLoaded(userInfo):
|
||||
state.userInfo = userInfo
|
||||
return .none
|
||||
|
||||
case .loadAccountModel:
|
||||
let accountModel = UserInfoManager.getAccountModel()
|
||||
return .send(.accountModelLoaded(accountModel))
|
||||
return .run { send in
|
||||
let accountModel = await UserInfoManager.getAccountModel()
|
||||
await send(.accountModelLoaded(accountModel))
|
||||
}
|
||||
|
||||
case let .accountModelLoaded(accountModel):
|
||||
state.accountModel = accountModel
|
||||
@@ -52,24 +56,20 @@ struct SettingFeature {
|
||||
|
||||
case .logout:
|
||||
state.isLoading = true
|
||||
|
||||
// 清除所有认证数据
|
||||
UserInfoManager.clearAllAuthenticationData()
|
||||
|
||||
// 发送通知返回登录页面
|
||||
NotificationCenter.default.post(name: .homeLogout, object: nil)
|
||||
return .none
|
||||
return .run { _ in
|
||||
await UserInfoManager.clearAllAuthenticationData()
|
||||
}
|
||||
|
||||
case .dismissTapped:
|
||||
// 发送关闭设置页面的通知
|
||||
NotificationCenter.default.post(name: .settingsDismiss, object: nil)
|
||||
// 移除:NotificationCenter.default.post(name: .settingsDismiss, object: nil)
|
||||
// 直接通过父级 action 关闭设置页面
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Extension
|
||||
extension Notification.Name {
|
||||
static let settingsDismiss = Notification.Name("settingsDismiss")
|
||||
}
|
||||
// 移除:未使用的通知名称定义
|
||||
// extension Notification.Name {
|
||||
// static let settingsDismiss = Notification.Name("settingsDismiss")
|
||||
// }
|
@@ -9,6 +9,15 @@ struct SplashFeature {
|
||||
var shouldShowMainApp = false
|
||||
var authenticationStatus: UserInfoManager.AuthenticationStatus = .notFound
|
||||
var isCheckingAuthentication = false
|
||||
|
||||
// 新增:导航目标
|
||||
var navigationDestination: NavigationDestination?
|
||||
}
|
||||
|
||||
// 新增:导航目标枚举
|
||||
enum NavigationDestination: Equatable {
|
||||
case login // 跳转到登录页面
|
||||
case main // 跳转到主页面
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
@@ -16,6 +25,10 @@ struct SplashFeature {
|
||||
case splashFinished
|
||||
case checkAuthentication
|
||||
case authenticationChecked(UserInfoManager.AuthenticationStatus)
|
||||
|
||||
// 新增:导航 actions
|
||||
case navigateToLogin
|
||||
case navigateToMain
|
||||
}
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
@@ -24,18 +37,17 @@ struct SplashFeature {
|
||||
case .onAppear:
|
||||
state.isLoading = true
|
||||
state.shouldShowMainApp = false
|
||||
state.authenticationStatus = .notFound
|
||||
state.authenticationStatus = UserInfoManager.AuthenticationStatus.notFound
|
||||
state.isCheckingAuthentication = false
|
||||
state.navigationDestination = nil
|
||||
|
||||
// 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
|
||||
|
||||
// Splash 完成后,开始检查认证状态
|
||||
return .send(.checkAuthentication)
|
||||
@@ -45,25 +57,32 @@ struct SplashFeature {
|
||||
|
||||
// 异步检查认证状态
|
||||
return .run { send in
|
||||
let authStatus = UserInfoManager.checkAuthenticationStatus()
|
||||
let authStatus = await UserInfoManager.checkAuthenticationStatus()
|
||||
await send(.authenticationChecked(authStatus))
|
||||
}
|
||||
|
||||
case let .authenticationChecked(status):
|
||||
state.isCheckingAuthentication = false
|
||||
state.authenticationStatus = status
|
||||
|
||||
// 根据认证状态发送相应的导航通知
|
||||
|
||||
// 根据认证状态决定导航目标
|
||||
if status.canAutoLogin {
|
||||
debugInfo("🎉 自动登录成功,进入主页")
|
||||
NotificationCenter.default.post(name: .autoLoginSuccess, object: nil)
|
||||
debugInfoSync("🎉 自动登录成功,进入主页")
|
||||
return .send(.navigateToMain)
|
||||
} else {
|
||||
debugInfo("🔑 需要手动登录")
|
||||
NotificationCenter.default.post(name: .autoLoginFailed, object: nil)
|
||||
debugInfoSync("🔑 需要手动登录")
|
||||
return .send(.navigateToLogin)
|
||||
}
|
||||
|
||||
case .navigateToLogin:
|
||||
state.navigationDestination = .login
|
||||
return .none
|
||||
|
||||
case .navigateToMain:
|
||||
state.navigationDestination = .main
|
||||
state.shouldShowMainApp = true
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -2,10 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>E-PARTi</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>E-PARTi</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
|
@@ -9,6 +9,7 @@ public enum LogLevel: Int {
|
||||
case error
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public class LogManager {
|
||||
/// 单例
|
||||
public static let shared = LogManager()
|
||||
@@ -45,43 +46,99 @@ public class LogManager {
|
||||
}
|
||||
|
||||
// MARK: - 原有快捷方法(保持向后兼容)
|
||||
@MainActor
|
||||
public func logVerbose(_ message: @autoclosure () -> String, onlyRelease: Bool = false) {
|
||||
LogManager.shared.log(.verbose, message(), onlyRelease: onlyRelease)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func logDebug(_ message: @autoclosure () -> String, onlyRelease: Bool = false) {
|
||||
LogManager.shared.log(.debug, message(), onlyRelease: onlyRelease)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func logInfo(_ message: @autoclosure () -> String, onlyRelease: Bool = false) {
|
||||
LogManager.shared.log(.info, message(), onlyRelease: onlyRelease)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func logWarn(_ message: @autoclosure () -> String, onlyRelease: Bool = false) {
|
||||
LogManager.shared.log(.warn, message(), onlyRelease: onlyRelease)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func logError(_ message: @autoclosure () -> String, onlyRelease: Bool = false) {
|
||||
LogManager.shared.log(.error, message(), onlyRelease: onlyRelease)
|
||||
}
|
||||
|
||||
// MARK: - 新的DEBUG专用快捷方法(推荐使用)
|
||||
public func debugVerbose(_ message: @autoclosure () -> String) {
|
||||
LogManager.shared.debugLog(.verbose, message())
|
||||
public func debugVerbose(_ message: @autoclosure () -> String) async {
|
||||
let msg = message()
|
||||
await MainActor.run {
|
||||
LogManager.shared.debugLog(.verbose, msg)
|
||||
}
|
||||
}
|
||||
|
||||
public func debugLog(_ message: @autoclosure () -> String) {
|
||||
LogManager.shared.debugLog(.debug, message())
|
||||
public func debugLog(_ message: @autoclosure () -> String) async {
|
||||
let msg = message()
|
||||
await MainActor.run {
|
||||
LogManager.shared.debugLog(.debug, msg)
|
||||
}
|
||||
}
|
||||
|
||||
public func debugInfo(_ message: @autoclosure () -> String) {
|
||||
LogManager.shared.debugLog(.info, message())
|
||||
public func debugInfo(_ message: @autoclosure () -> String) async {
|
||||
let msg = message()
|
||||
await MainActor.run {
|
||||
LogManager.shared.debugLog(.info, msg)
|
||||
}
|
||||
}
|
||||
|
||||
public func debugWarn(_ message: @autoclosure () -> String) {
|
||||
LogManager.shared.debugLog(.warn, message())
|
||||
public func debugWarn(_ message: @autoclosure () -> String) async {
|
||||
let msg = message()
|
||||
await MainActor.run {
|
||||
LogManager.shared.debugLog(.warn, msg)
|
||||
}
|
||||
}
|
||||
|
||||
public func debugError(_ message: @autoclosure () -> String) {
|
||||
LogManager.shared.debugLog(.error, message())
|
||||
}
|
||||
public func debugError(_ message: @autoclosure () -> String) async {
|
||||
let msg = message()
|
||||
await MainActor.run {
|
||||
LogManager.shared.debugLog(.error, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// fire-and-forget 同步重载(方法名加 Sync 后缀)
|
||||
public func debugVerboseSync(_ message: @autoclosure () -> String) {
|
||||
let msg = message()
|
||||
Task {
|
||||
await debugVerbose(msg)
|
||||
}
|
||||
}
|
||||
|
||||
public func debugLogSync(_ message: @autoclosure () -> String) {
|
||||
let msg = message()
|
||||
Task {
|
||||
await debugLog(msg)
|
||||
}
|
||||
}
|
||||
|
||||
public func debugInfoSync(_ message: @autoclosure () -> String) {
|
||||
let msg = message()
|
||||
Task {
|
||||
await debugInfo(msg)
|
||||
}
|
||||
}
|
||||
|
||||
public func debugWarnSync(_ message: @autoclosure () -> String) {
|
||||
let msg = message()
|
||||
Task {
|
||||
await debugWarn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
public func debugErrorSync(_ message: @autoclosure () -> String) {
|
||||
let msg = message()
|
||||
Task {
|
||||
await debugError(msg)
|
||||
}
|
||||
}
|
||||
|
@@ -18,25 +18,25 @@ struct APILoadingEffectView: View {
|
||||
if let firstItem = getFirstDisplayItem() {
|
||||
SingleLoadingView(item: firstItem)
|
||||
.onAppear {
|
||||
debugInfo("🔍 Loading item appeared: \(firstItem.id)")
|
||||
debugInfoSync("🔍 Loading item appeared: \(firstItem.id)")
|
||||
}
|
||||
.onDisappear {
|
||||
debugInfo("🔍 Loading item disappeared: \(firstItem.id)")
|
||||
debugInfoSync("🔍 Loading item disappeared: \(firstItem.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false) // 不阻挡用户点击
|
||||
.ignoresSafeArea(.all) // 覆盖整个屏幕
|
||||
.onReceive(loadingManager.$loadingItems) { items in
|
||||
debugInfo("🔍 Loading items updated: \(items.count) items")
|
||||
debugInfoSync("🔍 Loading items updated: \(items.count) items")
|
||||
}
|
||||
}
|
||||
|
||||
/// 安全地获取第一个需要显示的项目
|
||||
private func getFirstDisplayItem() -> APILoadingItem? {
|
||||
guard Thread.isMainThread else {
|
||||
debugWarn("⚠️ getFirstDisplayItem called from background thread")
|
||||
return nil
|
||||
debugWarnSync("⚠️ getFirstDisplayItem called from background thread")
|
||||
return nil
|
||||
}
|
||||
|
||||
return loadingManager.loadingItems.first { $0.shouldDisplay }
|
||||
@@ -151,7 +151,7 @@ struct APILoadingEffectView_Previews: PreviewProvider {
|
||||
.font(.title)
|
||||
|
||||
Button("测试按钮") {
|
||||
debugInfo("按钮被点击了!")
|
||||
debugInfoSync("按钮被点击了!")
|
||||
}
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
@@ -169,12 +169,12 @@ struct APILoadingEffectView_Previews: PreviewProvider {
|
||||
let manager = APILoadingManager.shared
|
||||
|
||||
// 添加 loading
|
||||
let id1 = await manager.startLoading()
|
||||
let id1 = manager.startLoading()
|
||||
|
||||
// 2秒后添加错误
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
Task {
|
||||
await manager.setError(id1, errorMessage: "网络连接失败,请检查网络设置")
|
||||
manager.setError(id1, errorMessage: "网络连接失败,请检查网络设置")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,13 +197,13 @@ private struct PreviewStateModifier: ViewModifier {
|
||||
let manager = APILoadingManager.shared
|
||||
|
||||
if showLoading {
|
||||
let _ = await manager.startLoading()
|
||||
let _ = manager.startLoading()
|
||||
}
|
||||
|
||||
if showError {
|
||||
let id = await manager.startLoading()
|
||||
let id = manager.startLoading()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1秒
|
||||
await manager.setError(id, errorMessage: errorMessage)
|
||||
manager.setError(id, errorMessage: errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,4 +224,4 @@ extension View {
|
||||
))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
@@ -11,24 +11,18 @@ import Combine
|
||||
/// - 管理 loading 和错误信息的显示
|
||||
/// - 自动清理过期的错误信息
|
||||
/// - 提供线程安全的状态更新
|
||||
@MainActor
|
||||
class APILoadingManager: ObservableObject {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
/// 单例实例
|
||||
static let shared = APILoadingManager()
|
||||
|
||||
/// 当前活动的加载项
|
||||
@Published private(set) var loadingItems: [APILoadingItem] = []
|
||||
|
||||
/// 错误清理任务
|
||||
private var errorCleanupTasks: [UUID: DispatchWorkItem] = [:]
|
||||
|
||||
/// 私有初始化器,确保单例
|
||||
private init() {}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// 开始显示 loading
|
||||
/// - Parameters:
|
||||
/// - shouldShowLoading: 是否显示 loading 动画
|
||||
@@ -36,127 +30,74 @@ class APILoadingManager: ObservableObject {
|
||||
/// - Returns: 唯一的加载 ID,用于后续更新状态
|
||||
func startLoading(shouldShowLoading: Bool = true, shouldShowError: Bool = true) -> UUID {
|
||||
let loadingId = UUID()
|
||||
|
||||
let loadingItem = APILoadingItem(
|
||||
id: loadingId,
|
||||
state: .loading,
|
||||
shouldShowError: shouldShowError,
|
||||
shouldShowLoading: shouldShowLoading
|
||||
)
|
||||
|
||||
// 🚨 重要:必须在主线程更新 @Published 属性,否则会崩溃!
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.loadingItems.append(loadingItem)
|
||||
}
|
||||
|
||||
loadingItems.append(loadingItem)
|
||||
return loadingId
|
||||
}
|
||||
|
||||
/// 更新 loading 状态为成功
|
||||
/// - Parameter id: 加载 ID
|
||||
func finishLoading(_ id: UUID) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.removeLoading(id)
|
||||
}
|
||||
removeLoading(id)
|
||||
}
|
||||
|
||||
/// 更新 loading 状态为错误
|
||||
/// - Parameters:
|
||||
/// - id: 加载 ID
|
||||
/// - errorMessage: 错误信息
|
||||
func setError(_ id: UUID, errorMessage: String) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// 查找并更新项目
|
||||
if let index = self.loadingItems.firstIndex(where: { $0.id == id }) {
|
||||
let currentItem = self.loadingItems[index]
|
||||
|
||||
// 只有需要显示错误时才更新状态
|
||||
if currentItem.shouldShowError {
|
||||
let errorItem = APILoadingItem(
|
||||
id: id,
|
||||
state: .error(message: errorMessage),
|
||||
shouldShowError: true,
|
||||
shouldShowLoading: currentItem.shouldShowLoading
|
||||
)
|
||||
self.loadingItems[index] = errorItem
|
||||
|
||||
// 设置自动清理
|
||||
self.setupErrorCleanup(for: id)
|
||||
} else {
|
||||
// 不显示错误,直接移除
|
||||
self.loadingItems.removeAll { $0.id == id }
|
||||
}
|
||||
}
|
||||
guard let index = loadingItems.firstIndex(where: { $0.id == id }) else { return }
|
||||
let currentItem = loadingItems[index]
|
||||
if currentItem.shouldShowError {
|
||||
let errorItem = APILoadingItem(
|
||||
id: id,
|
||||
state: .error(message: errorMessage),
|
||||
shouldShowError: true,
|
||||
shouldShowLoading: currentItem.shouldShowLoading
|
||||
)
|
||||
loadingItems[index] = errorItem
|
||||
setupErrorCleanup(for: id)
|
||||
} else {
|
||||
loadingItems.removeAll { $0.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动移除特定的加载项
|
||||
/// - Parameter id: 加载 ID
|
||||
private func removeLoading(_ id: UUID) {
|
||||
cancelErrorCleanup(for: id)
|
||||
// 🚨 重要:必须在主线程更新 @Published 属性
|
||||
if Thread.isMainThread {
|
||||
loadingItems.removeAll { $0.id == id }
|
||||
} else {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.loadingItems.removeAll { $0.id == id }
|
||||
}
|
||||
}
|
||||
loadingItems.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 清空所有加载项(用于应急情况)
|
||||
func clearAll() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// 取消所有清理任务
|
||||
self.errorCleanupTasks.values.forEach { $0.cancel() }
|
||||
self.errorCleanupTasks.removeAll()
|
||||
|
||||
// 清空所有项目
|
||||
self.loadingItems.removeAll()
|
||||
}
|
||||
errorCleanupTasks.values.forEach { $0.cancel() }
|
||||
errorCleanupTasks.removeAll()
|
||||
loadingItems.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// 是否有正在显示的 loading
|
||||
var hasActiveLoading: Bool {
|
||||
if Thread.isMainThread {
|
||||
return loadingItems.contains { $0.state == .loading && $0.shouldDisplay }
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
loadingItems.contains { $0.state == .loading && $0.shouldDisplay }
|
||||
}
|
||||
|
||||
/// 是否有正在显示的错误
|
||||
var hasActiveError: Bool {
|
||||
if Thread.isMainThread {
|
||||
return loadingItems.contains { $0.isError && $0.shouldDisplay }
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
loadingItems.contains { $0.isError && $0.shouldDisplay }
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
/// 设置错误信息自动清理
|
||||
/// - Parameter id: 加载 ID
|
||||
private func setupErrorCleanup(for id: UUID) {
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.removeLoading(id)
|
||||
}
|
||||
|
||||
errorCleanupTasks[id] = workItem
|
||||
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: .now() + APILoadingConfiguration.errorDisplayDuration,
|
||||
execute: workItem
|
||||
)
|
||||
}
|
||||
|
||||
/// 取消错误清理任务
|
||||
/// - Parameter id: 加载 ID
|
||||
private func cancelErrorCleanup(for id: UUID) {
|
||||
@@ -168,14 +109,14 @@ class APILoadingManager: ObservableObject {
|
||||
// MARK: - Convenience Extensions
|
||||
|
||||
extension APILoadingManager {
|
||||
|
||||
/// 便捷方法:执行带 loading 的异步操作
|
||||
/// - Parameters:
|
||||
/// - shouldShowLoading: 是否显示 loading
|
||||
/// - shouldShowError: 是否显示错误
|
||||
/// - operation: 异步操作
|
||||
/// - Returns: 操作结果
|
||||
func withLoading<T>(
|
||||
@MainActor
|
||||
func withLoading<T: Sendable>(
|
||||
shouldShowLoading: Bool = true,
|
||||
shouldShowError: Bool = true,
|
||||
operation: @escaping () async throws -> T
|
||||
@@ -184,7 +125,6 @@ extension APILoadingManager {
|
||||
shouldShowLoading: shouldShowLoading,
|
||||
shouldShowError: shouldShowError
|
||||
)
|
||||
|
||||
do {
|
||||
let result = try await operation()
|
||||
finishLoading(loadingId)
|
||||
|
137
yana/Utils/COSManager.swift
Normal file
137
yana/Utils/COSManager.swift
Normal file
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
|
||||
// MARK: - 腾讯云 COS 管理器
|
||||
|
||||
/// 腾讯云 COS 管理器
|
||||
///
|
||||
/// 负责管理腾讯云 COS 相关的操作,包括:
|
||||
/// - Token 获取和缓存
|
||||
/// - 文件上传、下载、删除
|
||||
/// - 凭证管理和过期处理
|
||||
@MainActor
|
||||
class COSManager: ObservableObject {
|
||||
static let shared = COSManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Token 管理
|
||||
|
||||
/// 当前缓存的 Token 信息
|
||||
private var cachedToken: TcTokenData?
|
||||
private var tokenExpirationDate: Date?
|
||||
|
||||
/// 获取腾讯云 COS Token
|
||||
/// - Parameter apiService: API 服务实例
|
||||
/// - Returns: Token 数据,如果获取失败返回 nil
|
||||
func getToken(apiService: any APIServiceProtocol & Sendable) async -> TcTokenData? {
|
||||
// 检查缓存是否有效
|
||||
if let cached = cachedToken, let expiration = tokenExpirationDate, Date() < expiration {
|
||||
debugInfoSync("🔐 使用缓存的 COS Token")
|
||||
return cached
|
||||
}
|
||||
|
||||
// 清除过期缓存
|
||||
clearCachedToken()
|
||||
|
||||
// 请求新的 Token
|
||||
debugInfoSync("🔐 开始请求腾讯云 COS Token...")
|
||||
|
||||
do {
|
||||
let request = TcTokenRequest()
|
||||
let response: TcTokenResponse = try await apiService.request(request)
|
||||
|
||||
guard response.code == 200, let tokenData = response.data else {
|
||||
debugInfoSync("❌ COS Token 请求失败: \(response.message)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 缓存 Token 和过期时间
|
||||
cachedToken = tokenData
|
||||
tokenExpirationDate = tokenData.expirationDate
|
||||
|
||||
debugInfoSync("✅ COS Token 获取成功")
|
||||
debugInfoSync(" - 存储桶: \(tokenData.bucket)")
|
||||
debugInfoSync(" - 地域: \(tokenData.region)")
|
||||
debugInfoSync(" - 过期时间: \(tokenData.expirationDate)")
|
||||
debugInfoSync(" - 剩余时间: \(tokenData.remainingTime)秒")
|
||||
|
||||
return tokenData
|
||||
|
||||
} catch {
|
||||
debugInfoSync("❌ COS Token 请求异常: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 缓存 Token 信息
|
||||
/// - Parameter tokenData: Token 数据
|
||||
private func cacheToken(_ tokenData: TcTokenData) async {
|
||||
cachedToken = tokenData
|
||||
|
||||
// 解析过期时间(假设 expiration 是 ISO 8601 格式)
|
||||
if let expirationDate = ISO8601DateFormatter().date(from: String(tokenData.expireTime)) {
|
||||
// 提前 5 分钟过期,确保安全
|
||||
tokenExpirationDate = expirationDate.addingTimeInterval(-300)
|
||||
} else {
|
||||
// 如果解析失败,设置默认过期时间(1小时)
|
||||
tokenExpirationDate = Date().addingTimeInterval(3600)
|
||||
}
|
||||
|
||||
debugInfoSync("💾 COS Token 已缓存,过期时间: \(tokenExpirationDate?.description ?? "未知")")
|
||||
}
|
||||
|
||||
/// 清除缓存的 Token
|
||||
private func clearCachedToken() {
|
||||
cachedToken = nil
|
||||
tokenExpirationDate = nil
|
||||
debugInfoSync("🗑️ 清除缓存的 COS Token")
|
||||
}
|
||||
|
||||
/// 强制刷新 Token
|
||||
func refreshToken(apiService: any APIServiceProtocol & Sendable) async -> TcTokenData? {
|
||||
clearCachedToken()
|
||||
return await getToken(apiService: apiService)
|
||||
}
|
||||
|
||||
// MARK: - 只读属性
|
||||
/// 外部安全访问 Token
|
||||
var token: TcTokenData? { cachedToken }
|
||||
|
||||
// MARK: - 调试信息
|
||||
|
||||
/// 获取当前 Token 状态信息
|
||||
func getTokenStatus() -> String {
|
||||
if let cached = cachedToken, let expiration = tokenExpirationDate {
|
||||
let isExpired = Date() >= expiration
|
||||
return "Token 状态: \(isExpired ? "已过期" : "有效"), 过期时间: \(expiration)"
|
||||
} else {
|
||||
return "Token 状态: 未缓存"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 调试扩展
|
||||
|
||||
extension COSManager {
|
||||
/// 测试 Token 获取功能(仅用于调试)
|
||||
func testTokenRetrieval(apiService: any APIServiceProtocol & Sendable) async {
|
||||
#if DEBUG
|
||||
debugInfoSync("\n🧪 开始测试腾讯云 COS Token 获取功能")
|
||||
|
||||
let token = await getToken(apiService: apiService)
|
||||
if let tokenData = token {
|
||||
debugInfoSync("✅ Token 获取成功")
|
||||
debugInfoSync(" bucket: \(tokenData.bucket)")
|
||||
debugInfoSync(" Expiration: \(tokenData.expireTime)")
|
||||
debugInfoSync(" Token: \(tokenData.sessionToken.prefix(20))...")
|
||||
debugInfoSync(" SecretId: \(tokenData.secretId.prefix(20))...")
|
||||
} else {
|
||||
debugInfoSync("❌ Token 获取失败")
|
||||
}
|
||||
|
||||
debugInfoSync("📊 Token 状态: \(getTokenStatus())")
|
||||
debugInfoSync("✅ 腾讯云 COS Token 测试完成\n")
|
||||
#endif
|
||||
}
|
||||
}
|
@@ -6,7 +6,7 @@ struct StringHashTest {
|
||||
|
||||
/// 测试哈希方法
|
||||
static func runTests() {
|
||||
debugInfo("🧪 开始测试字符串哈希方法...")
|
||||
debugInfoSync("🧪 开始测试字符串哈希方法...")
|
||||
|
||||
let testStrings = [
|
||||
"hello world",
|
||||
@@ -16,27 +16,27 @@ struct StringHashTest {
|
||||
]
|
||||
|
||||
for testString in testStrings {
|
||||
debugInfo("\n📝 测试字符串: \"\(testString)\"")
|
||||
debugInfoSync("\n📝 测试字符串: \"\(testString)\"")
|
||||
|
||||
// 测试 MD5
|
||||
let md5Result = testString.md5()
|
||||
debugInfo(" MD5: \(md5Result)")
|
||||
debugInfoSync(" MD5: \(md5Result)")
|
||||
|
||||
// 测试 SHA256 (iOS 13+)
|
||||
if #available(iOS 13.0, *) {
|
||||
let sha256Result = testString.sha256()
|
||||
debugInfo(" SHA256: \(sha256Result)")
|
||||
debugInfoSync(" SHA256: \(sha256Result)")
|
||||
} else {
|
||||
debugInfo(" SHA256: 不支持 (需要 iOS 13+)")
|
||||
debugInfoSync(" SHA256: 不支持 (需要 iOS 13+)")
|
||||
}
|
||||
}
|
||||
|
||||
debugInfo("\n✅ 哈希方法测试完成")
|
||||
debugInfoSync("\n✅ 哈希方法测试完成")
|
||||
}
|
||||
|
||||
/// 验证已知的哈希值
|
||||
static func verifyKnownHashes() {
|
||||
debugInfo("\n🔍 验证已知哈希值...")
|
||||
debugInfoSync("\n🔍 验证已知哈希值...")
|
||||
|
||||
// 验证 "hello world" 的 MD5 应该是 "5d41402abc4b2a76b9719d911017c592"
|
||||
let testString = "hello world"
|
||||
@@ -44,11 +44,11 @@ struct StringHashTest {
|
||||
let actualMD5 = testString.md5()
|
||||
|
||||
if actualMD5 == expectedMD5 {
|
||||
debugInfo("✅ MD5 验证通过: \(actualMD5)")
|
||||
debugInfoSync("✅ MD5 验证通过: \(actualMD5)")
|
||||
} else {
|
||||
debugError("❌ MD5 验证失败:")
|
||||
debugError(" 期望: \(expectedMD5)")
|
||||
debugError(" 实际: \(actualMD5)")
|
||||
debugErrorSync("❌ MD5 验证失败:")
|
||||
debugErrorSync(" 期望: \(expectedMD5)")
|
||||
debugErrorSync(" 实际: \(actualMD5)")
|
||||
}
|
||||
|
||||
// 验证 SHA256
|
||||
@@ -57,11 +57,11 @@ struct StringHashTest {
|
||||
let actualSHA256 = testString.sha256()
|
||||
|
||||
if actualSHA256 == expectedSHA256 {
|
||||
debugInfo("✅ SHA256 验证通过: \(actualSHA256)")
|
||||
debugInfoSync("✅ SHA256 验证通过: \(actualSHA256)")
|
||||
} else {
|
||||
debugError("❌ SHA256 验证失败:")
|
||||
debugError(" 期望: \(expectedSHA256)")
|
||||
debugError(" 实际: \(actualSHA256)")
|
||||
debugErrorSync("❌ SHA256 验证失败:")
|
||||
debugErrorSync(" 期望: \(expectedSHA256)")
|
||||
debugErrorSync(" 实际: \(actualSHA256)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,9 +75,9 @@ struct StringHashTest {
|
||||
StringHashTest.verifyKnownHashes()
|
||||
|
||||
// 或者在开发时快速测试
|
||||
debugInfo("Test MD5:", "hello".md5())
|
||||
debugInfoSync("Test MD5:", "hello".md5())
|
||||
if #available(iOS 13.0, *) {
|
||||
debugInfo("Test SHA256:", "hello".sha256())
|
||||
debugInfoSync("Test SHA256:", "hello".sha256())
|
||||
}
|
||||
|
||||
*/
|
||||
*/
|
||||
|
@@ -67,11 +67,11 @@ struct FontManager {
|
||||
|
||||
/// 打印所有可用字体(调试用)
|
||||
static func printAllAvailableFonts() {
|
||||
debugInfo("=== 所有可用字体 ===")
|
||||
debugInfoSync("=== 所有可用字体 ===")
|
||||
for font in getAllAvailableFonts() {
|
||||
debugInfo(font)
|
||||
debugInfoSync(font)
|
||||
}
|
||||
debugInfo("==================")
|
||||
debugInfoSync("==================")
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -8,6 +8,7 @@ import SwiftUI
|
||||
/// - 应用全局默认语言为英文,不依赖系统语言设置
|
||||
/// - 用户可通过语言设置界面手动切换到其他支持的语言
|
||||
/// - 用户的语言选择会保存在UserDefaults中,下次启动时保持
|
||||
@MainActor
|
||||
class LocalizationManager: ObservableObject {
|
||||
|
||||
// MARK: - 单例
|
||||
@@ -42,9 +43,9 @@ class LocalizationManager: ObservableObject {
|
||||
didSet {
|
||||
do {
|
||||
try KeychainManager.shared.storeString(currentLanguage.rawValue, forKey: "AppLanguage")
|
||||
} catch {
|
||||
debugError("❌ 保存语言设置失败: \(error)")
|
||||
}
|
||||
} catch {
|
||||
debugErrorSync("❌ 保存语言设置失败: \(error)")
|
||||
}
|
||||
// 通知视图更新
|
||||
objectWillChange.send()
|
||||
}
|
||||
@@ -56,7 +57,7 @@ class LocalizationManager: ObservableObject {
|
||||
do {
|
||||
savedLanguage = try KeychainManager.shared.retrieveString(forKey: "AppLanguage")
|
||||
} catch {
|
||||
debugError("❌ 读取语言设置失败: \(error)")
|
||||
debugErrorSync("❌ 读取语言设置失败: \(error)")
|
||||
savedLanguage = nil
|
||||
}
|
||||
|
||||
@@ -113,34 +114,26 @@ class LocalizationManager: ObservableObject {
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// extension View {
|
||||
// /// 应用本地化字符串
|
||||
// /// - Parameter key: 本地化 key
|
||||
// /// - Returns: 带有本地化文本的视图
|
||||
// @MainActor
|
||||
// func localized(_ key: String) -> some View {
|
||||
// self.modifier(LocalizedTextModifier(key: key))
|
||||
// }
|
||||
// }
|
||||
|
||||
// MARK: - 便捷方法
|
||||
extension String {
|
||||
/// 获取本地化字符串
|
||||
var localized: String {
|
||||
return LocalizationManager.shared.localizedString(self)
|
||||
}
|
||||
|
||||
/// 获取本地化字符串(带参数)
|
||||
func localized(arguments: CVarArg...) -> String {
|
||||
return LocalizationManager.shared.localizedString(self, arguments: arguments)
|
||||
}
|
||||
}
|
||||
// extension String {
|
||||
// /// 获取本地化字符串
|
||||
// @MainActor
|
||||
// var localized: String {
|
||||
// return LocalizationManager.shared.localizedString(self)
|
||||
// }
|
||||
// /// 获取本地化字符串(带参数)
|
||||
// @MainActor
|
||||
// func localized(arguments: CVarArg...) -> String {
|
||||
// return LocalizationManager.shared.localizedString(self, arguments: arguments)
|
||||
// }
|
||||
// }
|
||||
|
@@ -5,8 +5,8 @@ struct DESEncryptOCTest {
|
||||
|
||||
/// 测试 OC 版本的 DES 加密功能
|
||||
static func testOCDESEncryption() {
|
||||
debugInfo("🧪 开始测试 OC 版本的 DES 加密...")
|
||||
debugInfo(String(repeating: "=", count: 50))
|
||||
debugInfoSync("🧪 开始测试 OC 版本的 DES 加密...")
|
||||
debugInfoSync(String(repeating: "=", count: 50))
|
||||
|
||||
let key = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
let testCases = [
|
||||
@@ -19,25 +19,25 @@ struct DESEncryptOCTest {
|
||||
|
||||
for testCase in testCases {
|
||||
if let encrypted = DESEncrypt.encryptUseDES(testCase, key: key) {
|
||||
debugInfo("✅ 加密成功:")
|
||||
debugInfo(" 原文: \"\(testCase)\"")
|
||||
debugInfo(" 密文: \(encrypted)")
|
||||
debugInfoSync("✅ 加密成功:")
|
||||
debugInfoSync(" 原文: \"\(testCase)\"")
|
||||
debugInfoSync(" 密文: \(encrypted)")
|
||||
|
||||
// 测试解密
|
||||
if let decrypted = DESEncrypt.decryptUseDES(encrypted, key: key) {
|
||||
let isMatch = decrypted == testCase
|
||||
debugInfo(" 解密: \"\(decrypted)\" \(isMatch ? "✅" : "❌")")
|
||||
debugInfoSync(" 解密: \"\(decrypted)\" \(isMatch ? "✅" : "❌")")
|
||||
} else {
|
||||
debugError(" 解密: 失败 ❌")
|
||||
debugErrorSync(" 解密: 失败 ❌")
|
||||
}
|
||||
} else {
|
||||
debugError("❌ 加密失败: \"\(testCase)\"")
|
||||
debugErrorSync("❌ 加密失败: \"\(testCase)\"")
|
||||
}
|
||||
debugInfo("")
|
||||
debugInfoSync("")
|
||||
}
|
||||
|
||||
debugInfo(String(repeating: "=", count: 50))
|
||||
debugInfo("🏁 OC版本DES加密测试完成")
|
||||
debugInfoSync(String(repeating: "=", count: 50))
|
||||
debugInfoSync("🏁 OC版本DES加密测试完成")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,4 @@ extension DESEncryptOCTest {
|
||||
DESEncryptOCTest.testOCDESEncryption()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
@@ -10,6 +10,7 @@ import Foundation
|
||||
/// 2. 迁移到 Keychain
|
||||
/// 3. 验证迁移结果
|
||||
/// 4. 清理旧数据
|
||||
@MainActor
|
||||
final class DataMigrationManager {
|
||||
|
||||
// MARK: - 单例
|
||||
@@ -54,23 +55,23 @@ final class DataMigrationManager {
|
||||
/// 执行数据迁移
|
||||
/// - Returns: 迁移结果
|
||||
func performMigration() -> MigrationResult {
|
||||
debugInfo("🔄 开始检查数据迁移...")
|
||||
debugInfoSync("🔄 开始检查数据迁移...")
|
||||
|
||||
// 检查是否已经迁移过
|
||||
if isMigrationCompleted() {
|
||||
debugInfo("✅ 数据已经迁移过,跳过迁移")
|
||||
debugInfoSync("✅ 数据已经迁移过,跳过迁移")
|
||||
return .alreadyMigrated
|
||||
}
|
||||
|
||||
// 检查是否有需要迁移的数据
|
||||
let legacyData = collectLegacyData()
|
||||
if legacyData.isEmpty {
|
||||
debugInfo("ℹ️ 没有发现需要迁移的数据")
|
||||
debugInfoSync("ℹ️ 没有发现需要迁移的数据")
|
||||
markMigrationCompleted()
|
||||
return .noDataToMigrate
|
||||
}
|
||||
|
||||
debugInfo("📦 发现需要迁移的数据: \(legacyData.keys.joined(separator: ", "))")
|
||||
debugInfoSync("📦 发现需要迁移的数据: \(legacyData.keys.joined(separator: ", "))")
|
||||
|
||||
do {
|
||||
// 执行迁移
|
||||
@@ -85,11 +86,11 @@ final class DataMigrationManager {
|
||||
// 标记迁移完成
|
||||
markMigrationCompleted()
|
||||
|
||||
debugInfo("✅ 数据迁移完成")
|
||||
debugInfoSync("✅ 数据迁移完成")
|
||||
return .completed
|
||||
|
||||
} catch {
|
||||
debugError("❌ 数据迁移失败: \(error)")
|
||||
debugErrorSync("❌ 数据迁移失败: \(error)")
|
||||
return .failed(error)
|
||||
}
|
||||
}
|
||||
@@ -157,9 +158,9 @@ final class DataMigrationManager {
|
||||
do {
|
||||
let accountModel = try JSONDecoder().decode(AccountModel.self, from: accountModelData)
|
||||
try keychain.store(accountModel, forKey: "account_model")
|
||||
debugInfo("✅ AccountModel 迁移成功")
|
||||
debugInfoSync("✅ AccountModel 迁移成功")
|
||||
} catch {
|
||||
debugError("❌ AccountModel 迁移失败: \(error)")
|
||||
debugErrorSync("❌ AccountModel 迁移失败: \(error)")
|
||||
// 如果 AccountModel 迁移失败,尝试从独立字段重建
|
||||
try migrateAccountModelFromIndependentFields(legacyData)
|
||||
}
|
||||
@@ -173,9 +174,9 @@ final class DataMigrationManager {
|
||||
do {
|
||||
let userInfo = try JSONDecoder().decode(UserInfo.self, from: userInfoData)
|
||||
try keychain.store(userInfo, forKey: "user_info")
|
||||
debugInfo("✅ UserInfo 迁移成功")
|
||||
debugInfoSync("✅ UserInfo 迁移成功")
|
||||
} catch {
|
||||
debugError("❌ UserInfo 迁移失败: \(error)")
|
||||
debugErrorSync("❌ UserInfo 迁移失败: \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -183,7 +184,7 @@ final class DataMigrationManager {
|
||||
// 迁移语言设置
|
||||
if let appLanguage = legacyData[LegacyStorageKeys.appLanguage] as? String {
|
||||
try keychain.storeString(appLanguage, forKey: "AppLanguage")
|
||||
debugInfo("✅ 语言设置迁移成功")
|
||||
debugInfoSync("✅ 语言设置迁移成功")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +192,7 @@ final class DataMigrationManager {
|
||||
private func migrateAccountModelFromIndependentFields(_ legacyData: [String: Any]) throws {
|
||||
guard let userId = legacyData[LegacyStorageKeys.userId] as? String,
|
||||
let accessToken = legacyData[LegacyStorageKeys.accessToken] as? String else {
|
||||
debugInfo("ℹ️ 没有足够的独立字段来重建 AccountModel")
|
||||
debugInfoSync("ℹ️ 没有足够的独立字段来重建 AccountModel")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -208,7 +209,7 @@ final class DataMigrationManager {
|
||||
)
|
||||
|
||||
try KeychainManager.shared.store(accountModel, forKey: "account_model")
|
||||
debugInfo("✅ 从独立字段重建 AccountModel 成功")
|
||||
debugInfoSync("✅ 从独立字段重建 AccountModel 成功")
|
||||
}
|
||||
|
||||
/// 验证迁移结果
|
||||
@@ -240,7 +241,7 @@ final class DataMigrationManager {
|
||||
}
|
||||
}
|
||||
|
||||
debugInfo("✅ 迁移数据验证成功")
|
||||
debugInfoSync("✅ 迁移数据验证成功")
|
||||
}
|
||||
|
||||
/// 清理旧数据
|
||||
@@ -249,11 +250,11 @@ final class DataMigrationManager {
|
||||
|
||||
for key in keys {
|
||||
userDefaults.removeObject(forKey: key)
|
||||
debugInfo("🗑️ 清理旧数据: \(key)")
|
||||
debugInfoSync("🗑️ 清理旧数据: \(key)")
|
||||
}
|
||||
|
||||
userDefaults.synchronize()
|
||||
debugInfo("✅ 旧数据清理完成")
|
||||
debugInfoSync("✅ 旧数据清理完成")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,13 +288,13 @@ extension DataMigrationManager {
|
||||
|
||||
switch migrationResult {
|
||||
case .completed:
|
||||
debugInfo("🎉 应用启动时数据迁移完成")
|
||||
debugInfoSync("🎉 应用启动时数据迁移完成")
|
||||
case .alreadyMigrated:
|
||||
break // 静默处理
|
||||
case .noDataToMigrate:
|
||||
break // 静默处理
|
||||
case .failed(let error):
|
||||
debugError("⚠️ 应用启动时数据迁移失败: \(error)")
|
||||
debugErrorSync("⚠️ 应用启动时数据迁移失败: \(error)")
|
||||
// 这里可以添加错误上报或降级策略
|
||||
}
|
||||
}
|
||||
@@ -307,9 +308,9 @@ extension DataMigrationManager {
|
||||
/// 调试:打印旧数据信息
|
||||
func debugPrintLegacyData() {
|
||||
let legacyData = collectLegacyData()
|
||||
debugInfo("🔍 旧版本数据:")
|
||||
debugInfoSync("🔍 旧版本数据:")
|
||||
for (key, value) in legacyData {
|
||||
debugInfo(" - \(key): \(type(of: value))")
|
||||
debugInfoSync(" - \(key): \(type(of: value))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +323,7 @@ extension DataMigrationManager {
|
||||
userDefaults.set("zh-Hans", forKey: LegacyStorageKeys.appLanguage)
|
||||
userDefaults.synchronize()
|
||||
|
||||
debugInfo("🧪 已创建测试用的旧版本数据")
|
||||
debugInfoSync("🧪 已创建测试用的旧版本数据")
|
||||
}
|
||||
|
||||
/// 调试:清除所有迁移相关数据
|
||||
@@ -331,7 +332,7 @@ extension DataMigrationManager {
|
||||
do {
|
||||
try KeychainManager.shared.clearAll()
|
||||
} catch {
|
||||
debugError("❌ 清除 Keychain 数据失败: \(error)")
|
||||
debugErrorSync("❌ 清除 Keychain 数据失败: \(error)")
|
||||
}
|
||||
|
||||
// 清除 UserDefaults 数据
|
||||
@@ -350,7 +351,7 @@ extension DataMigrationManager {
|
||||
}
|
||||
userDefaults.synchronize()
|
||||
|
||||
debugInfo("🧪 已清除所有迁移相关数据")
|
||||
debugInfoSync("🧪 已清除所有迁移相关数据")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
@@ -12,10 +12,11 @@ import Security
|
||||
/// - 完善的错误处理
|
||||
/// - 线程安全操作
|
||||
/// - 可配置的访问控制级别
|
||||
@MainActor
|
||||
final class KeychainManager {
|
||||
|
||||
// MARK: - 单例
|
||||
static let shared = KeychainManager()
|
||||
@MainActor static let shared = KeychainManager()
|
||||
private init() {}
|
||||
|
||||
// MARK: - 配置常量
|
||||
@@ -108,7 +109,7 @@ final class KeychainManager {
|
||||
throw KeychainError.keychainOperationFailed(status)
|
||||
}
|
||||
|
||||
debugInfo("🔐 Keychain 存储成功: \(key)")
|
||||
debugInfoSync("🔐 Keychain 存储成功: \(key)")
|
||||
}
|
||||
|
||||
/// 从 Keychain 检索 Codable 对象
|
||||
@@ -137,7 +138,7 @@ final class KeychainManager {
|
||||
// 4. 解码数据
|
||||
do {
|
||||
let object = try JSONDecoder().decode(type, from: data)
|
||||
debugInfo("🔐 Keychain 读取成功: \(key)")
|
||||
debugInfoSync("🔐 Keychain 读取成功: \(key)")
|
||||
return object
|
||||
} catch {
|
||||
throw KeychainError.decodingFailed(error)
|
||||
@@ -176,7 +177,7 @@ final class KeychainManager {
|
||||
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
debugInfo("🔐 Keychain 更新成功: \(key)")
|
||||
debugInfoSync("🔐 Keychain 更新成功: \(key)")
|
||||
|
||||
case errSecItemNotFound:
|
||||
// 如果项目不存在,则创建新项目
|
||||
@@ -196,7 +197,7 @@ final class KeychainManager {
|
||||
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
debugInfo("🔐 Keychain 删除成功: \(key)")
|
||||
debugInfoSync("🔐 Keychain 删除成功: \(key)")
|
||||
|
||||
case errSecItemNotFound:
|
||||
// 项目不存在,视为删除成功
|
||||
@@ -231,7 +232,7 @@ final class KeychainManager {
|
||||
|
||||
switch status {
|
||||
case errSecSuccess, errSecItemNotFound:
|
||||
debugInfo("🔐 Keychain 清除完成")
|
||||
debugInfoSync("🔐 Keychain 清除完成")
|
||||
|
||||
default:
|
||||
throw KeychainError.keychainOperationFailed(status)
|
||||
@@ -353,10 +354,10 @@ extension KeychainManager {
|
||||
/// 打印所有存储的键(仅用于调试)
|
||||
func debugPrintAllKeys() {
|
||||
let keys = debugListAllKeys()
|
||||
debugInfo("🔐 Keychain 中存储的键:")
|
||||
debugInfoSync("🔐 Keychain 中存储的键:")
|
||||
for key in keys {
|
||||
debugInfo(" - \(key)")
|
||||
debugInfoSync(" - \(key)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
@@ -2,83 +2,32 @@ 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()
|
||||
}
|
||||
@State private var isLoggedIn = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
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)))
|
||||
if isLoggedIn {
|
||||
MainView(
|
||||
store: Store(
|
||||
initialState: MainFeature.State()
|
||||
) {
|
||||
MainFeature()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .autoLoginSuccess)) { _ in
|
||||
// 自动登录成功,直接进入主页
|
||||
withAnimation(.easeInOut(duration: 0.5)) {
|
||||
shouldShowHomePage = true
|
||||
)
|
||||
} else {
|
||||
LoginView(
|
||||
store: Store(
|
||||
initialState: LoginFeature.State()
|
||||
) {
|
||||
LoginFeature()
|
||||
},
|
||||
onLoginSuccess: {
|
||||
isLoggedIn = true
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .autoLoginFailed)) { _ in
|
||||
// 自动登录失败,进入登录页面
|
||||
withAnimation(.easeInOut(duration: 0.5)) {
|
||||
shouldShowMainApp = true
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .ticketSuccess)) { _ in
|
||||
// 手动登录成功,切换到主页
|
||||
withAnimation(.easeInOut(duration: 0.5)) {
|
||||
shouldShowHomePage = true
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .homeLogout)) { _ in
|
||||
// 从主页登出,返回登录页面
|
||||
withAnimation(.easeInOut(duration: 0.5)) {
|
||||
shouldShowHomePage = false
|
||||
shouldShowMainApp = true
|
||||
}
|
||||
}
|
||||
|
||||
// 全局 API Loading 效果视图 - 显示在最顶层
|
||||
APILoadingEffectView()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let splashFinished = Notification.Name("splashFinished")
|
||||
static let ticketSuccess = Notification.Name("ticketSuccess")
|
||||
static let autoLoginSuccess = Notification.Name("autoLoginSuccess")
|
||||
static let autoLoginFailed = Notification.Name("autoLoginFailed")
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AppRootView()
|
||||
}
|
@@ -38,20 +38,20 @@ struct UserAgreementView: View {
|
||||
|
||||
// MARK: - Private Methods
|
||||
private func createAttributedText() -> AttributedString {
|
||||
var attributedString = AttributedString("login.agreement_policy".localized)
|
||||
var attributedString = AttributedString(NSLocalizedString("login.agreement_policy", comment: ""))
|
||||
|
||||
// 设置默认颜色
|
||||
attributedString.foregroundColor = Color(hex: 0x666666)
|
||||
|
||||
// 找到并设置 "用户协议" 的样式和链接
|
||||
if let userServiceRange = attributedString.range(of: "login.agreement".localized) {
|
||||
if let userServiceRange = attributedString.range(of: NSLocalizedString("login.agreement", comment: "")) {
|
||||
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) {
|
||||
if let privacyPolicyRange = attributedString.range(of: NSLocalizedString("login.policy", comment: "")) {
|
||||
attributedString[privacyPolicyRange].foregroundColor = Color(hex: 0x8A4FFF)
|
||||
attributedString[privacyPolicyRange].underlineStyle = .single
|
||||
attributedString[privacyPolicyRange].link = URL(string: "privacy-policy")
|
||||
@@ -61,28 +61,28 @@ struct UserAgreementView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 20) {
|
||||
UserAgreementView(
|
||||
isAgreed: .constant(true),
|
||||
onUserServiceTapped: {
|
||||
debugInfo("User Service Agreement tapped")
|
||||
},
|
||||
onPrivacyPolicyTapped: {
|
||||
debugInfo("Privacy Policy tapped")
|
||||
}
|
||||
)
|
||||
|
||||
UserAgreementView(
|
||||
isAgreed: .constant(true),
|
||||
onUserServiceTapped: {
|
||||
debugInfo("User Service Agreement tapped")
|
||||
},
|
||||
onPrivacyPolicyTapped: {
|
||||
debugInfo("Privacy Policy tapped")
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
//#Preview {
|
||||
// VStack(spacing: 20) {
|
||||
// UserAgreementView(
|
||||
// isAgreed: .constant(true),
|
||||
// onUserServiceTapped: {
|
||||
// debugInfoSync("User Service Agreement tapped")
|
||||
// },
|
||||
// onPrivacyPolicyTapped: {
|
||||
// debugInfoSync("Privacy Policy tapped")
|
||||
// }
|
||||
// )
|
||||
//
|
||||
// UserAgreementView(
|
||||
// isAgreed: .constant(true),
|
||||
// onUserServiceTapped: {
|
||||
// debugInfoSync("User Service Agreement tapped")
|
||||
// },
|
||||
// onPrivacyPolicyTapped: {
|
||||
// debugInfoSync("Privacy Policy tapped")
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// .padding()
|
||||
// .background(Color.gray.opacity(0.1))
|
||||
//}
|
||||
|
239
yana/Views/CreateFeedView.swift
Normal file
239
yana/Views/CreateFeedView.swift
Normal file
@@ -0,0 +1,239 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
import PhotosUI
|
||||
|
||||
struct CreateFeedView: View {
|
||||
let store: StoreOf<CreateFeedFeature>
|
||||
@State private var keyboardHeight: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GeometryReader { geometry in
|
||||
VStack(spacing: 0) {
|
||||
// 背景色
|
||||
Color(hex: 0x0C0527)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// 主要内容区域(无ScrollView)
|
||||
VStack(spacing: 20) {
|
||||
// 内容输入区域
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// 文本输入框
|
||||
ZStack(alignment: .topLeading) {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.white.opacity(0.1))
|
||||
.frame(height: 200) // 高度固定为200
|
||||
|
||||
if store.content.isEmpty {
|
||||
Text("Enter Content")
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
TextEditor(text: .init(
|
||||
get: { store.content },
|
||||
set: { store.send(.contentChanged($0)) }
|
||||
))
|
||||
.foregroundColor(.white)
|
||||
.background(Color.clear)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(height: 200) // 高度固定为200
|
||||
}
|
||||
|
||||
// 字符计数
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("\(store.characterCount)/500")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(
|
||||
store.characterCount > 500 ? .red : .white.opacity(0.6)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
|
||||
// 图片选择区域
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if !store.processedImages.isEmpty || store.canAddMoreImages {
|
||||
ModernImageSelectionGrid(
|
||||
images: store.processedImages,
|
||||
selectedItems: store.selectedImages,
|
||||
canAddMore: store.canAddMoreImages,
|
||||
onItemsChanged: { items in
|
||||
store.send(.photosPickerItemsChanged(items))
|
||||
},
|
||||
onRemoveImage: { index in
|
||||
store.send(.removeImage(index))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
// 加载状态
|
||||
if store.isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
Text("处理图片中...")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
// 错误提示
|
||||
if let error = store.errorMessage {
|
||||
Text(error)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.red)
|
||||
.padding(.horizontal, 20)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
// 底部间距,确保内容不被键盘遮挡
|
||||
Color.clear.frame(height: max(keyboardHeight, geometry.safeAreaInsets.bottom) + 100)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
|
||||
// 底部发布按钮 - 固定在底部
|
||||
VStack {
|
||||
Button(action: {
|
||||
store.send(.publishButtonTapped)
|
||||
}) {
|
||||
HStack {
|
||||
if store.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(0.8)
|
||||
Text("发布中...")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
} else {
|
||||
Text("发布")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(
|
||||
Color(hex: 0x0C0527)
|
||||
)
|
||||
.cornerRadius(25)
|
||||
.disabled(store.isLoading || !store.canPublish)
|
||||
.opacity(store.isLoading || !store.canPublish ? 0.6 : 1.0)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, max(keyboardHeight, geometry.safeAreaInsets.bottom) + 20)
|
||||
}
|
||||
.background(
|
||||
Color(hex: 0x0C0527)
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("图文发布")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(action: {
|
||||
store.send(.dismissView)
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
// 移除右上角发布按钮
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { notification in
|
||||
if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
|
||||
keyboardHeight = keyboardFrame.height
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
|
||||
keyboardHeight = 0
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - iOS 16+ 图片选择网格组件
|
||||
struct ModernImageSelectionGrid: View {
|
||||
let images: [UIImage]
|
||||
let selectedItems: [PhotosPickerItem]
|
||||
let canAddMore: Bool
|
||||
let onItemsChanged: ([PhotosPickerItem]) -> Void
|
||||
let onRemoveImage: (Int) -> Void
|
||||
|
||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
|
||||
|
||||
var body: some View {
|
||||
WithPerceptionTracking {
|
||||
LazyVGrid(columns: columns, spacing: 8) {
|
||||
// 显示已选择的图片
|
||||
ForEach(Array(images.enumerated()), id: \.offset) { index, image in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 100)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
|
||||
// 删除按钮
|
||||
Button(action: {
|
||||
onRemoveImage(index)
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.white)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加图片按钮
|
||||
if canAddMore {
|
||||
PhotosPicker(
|
||||
selection: .init(
|
||||
get: { selectedItems },
|
||||
set: onItemsChanged
|
||||
),
|
||||
maxSelectionCount: 9,
|
||||
matching: .images
|
||||
) {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.white.opacity(0.1))
|
||||
.frame(height: 100)
|
||||
.overlay(
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 预览
|
||||
//#Preview {
|
||||
// CreateFeedView(
|
||||
// store: Store(initialState: CreateFeedFeature.State()) {
|
||||
// CreateFeedFeature()
|
||||
// }
|
||||
// )
|
||||
//}
|
@@ -1,17 +1,16 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
import Combine
|
||||
|
||||
struct EMailLoginView: View {
|
||||
let store: StoreOf<EMailLoginFeature>
|
||||
let onBack: () -> Void
|
||||
@Binding var showEmailLogin: Bool
|
||||
|
||||
// 使用本地@State管理UI状态
|
||||
@State private var email: String = ""
|
||||
@State private var verificationCode: String = ""
|
||||
@State private var codeCountdown: Int = 0
|
||||
@State private var timer: Timer?
|
||||
|
||||
// 管理输入框焦点状态
|
||||
@State private var timerCancellable: AnyCancellable?
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
enum Field {
|
||||
@@ -19,253 +18,256 @@ struct EMailLoginView: View {
|
||||
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
|
||||
return NSLocalizedString("email_login.get_code", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
// 计算获取验证码按钮是否可用
|
||||
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: {
|
||||
// 发送API请求
|
||||
store.send(.getVerificationCodeTapped)
|
||||
// 立即开始倒计时
|
||||
startCountdown()
|
||||
}) {
|
||||
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()
|
||||
WithViewStore(store, observe: { $0.loginStep }) { viewStore in
|
||||
LoginContentView(
|
||||
store: store,
|
||||
onBack: onBack,
|
||||
email: $email,
|
||||
verificationCode: $verificationCode,
|
||||
codeCountdown: $codeCountdown,
|
||||
focusedField: $focusedField,
|
||||
isLoginButtonEnabled: isLoginButtonEnabled,
|
||||
getCodeButtonText: getCodeButtonText,
|
||||
isCodeButtonEnabled: isCodeButtonEnabled
|
||||
)
|
||||
.onChange(of: viewStore.state) { newStep in
|
||||
debugInfoSync("🔄 EMailLoginView: loginStep 变化为 \(newStep)")
|
||||
if newStep == .completed {
|
||||
debugInfoSync("✅ EMailLoginView: 登录成功,准备关闭自身")
|
||||
showEmailLogin = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// 每次进入页面都重置状态
|
||||
store.send(.resetState)
|
||||
|
||||
email = ""
|
||||
verificationCode = ""
|
||||
codeCountdown = 0
|
||||
stopCountdown()
|
||||
|
||||
#if DEBUG
|
||||
email = "exzero@126.com"
|
||||
store.send(.emailChanged(email))
|
||||
#endif
|
||||
let _ = WithPerceptionTracking {
|
||||
store.send(.resetState)
|
||||
email = ""
|
||||
verificationCode = ""
|
||||
codeCountdown = 0
|
||||
stopCountdown()
|
||||
#if DEBUG
|
||||
email = "exzero@126.com"
|
||||
store.send(.emailChanged(email))
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
stopCountdown()
|
||||
let _ = WithPerceptionTracking {
|
||||
stopCountdown()
|
||||
}
|
||||
}
|
||||
.onChange(of: email) { newEmail in
|
||||
store.send(.emailChanged(newEmail))
|
||||
let _ = WithPerceptionTracking {
|
||||
store.send(.emailChanged(newEmail))
|
||||
}
|
||||
}
|
||||
.onChange(of: verificationCode) { newCode in
|
||||
store.send(.verificationCodeChanged(newCode))
|
||||
let _ = WithPerceptionTracking {
|
||||
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
|
||||
let _ = WithPerceptionTracking {
|
||||
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 {
|
||||
timerCancellable = Timer.publish(every: 1.0, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.sink { _ in
|
||||
if codeCountdown > 0 {
|
||||
codeCountdown -= 1
|
||||
} else {
|
||||
stopCountdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCountdown() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
timerCancellable?.cancel()
|
||||
timerCancellable = nil
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
EMailLoginView(
|
||||
store: Store(
|
||||
initialState: EMailLoginFeature.State()
|
||||
) {
|
||||
EMailLoginFeature()
|
||||
},
|
||||
onBack: {}
|
||||
)
|
||||
}
|
||||
private struct LoginContentView: View {
|
||||
let store: StoreOf<EMailLoginFeature>
|
||||
let onBack: () -> Void
|
||||
@Binding var email: String
|
||||
@Binding var verificationCode: String
|
||||
@Binding var codeCountdown: Int
|
||||
@FocusState.Binding var focusedField: EMailLoginView.Field?
|
||||
let isLoginButtonEnabled: Bool
|
||||
let getCodeButtonText: String
|
||||
let isCodeButtonEnabled: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
WithPerceptionTracking {
|
||||
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(NSLocalizedString("email_login.title", comment: ""))
|
||||
.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(NSLocalizedString("placeholder.enter_email", comment: ""))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
.padding(.horizontal, 24)
|
||||
.keyboardType(.emailAddress)
|
||||
.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(NSLocalizedString("placeholder.enter_code", comment: ""))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
.keyboardType(.numberPad)
|
||||
.focused($focusedField, equals: .verificationCode)
|
||||
Button(action: {
|
||||
store.send(.getVerificationCodeTapped)
|
||||
}) {
|
||||
ZStack {
|
||||
if store.isCodeLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(0.7)
|
||||
} else {
|
||||
Text(getCodeButtonText)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
.frame(width: 60, height: 36)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(Color.white.opacity(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),
|
||||
Color(red: 0.54, green: 0.31, blue: 1.0)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28))
|
||||
HStack {
|
||||
if store.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
Text(store.isLoading ? NSLocalizedString("email_login.logging_in", comment: "") : NSLocalizedString("email_login.login_button", comment: ""))
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// EMailLoginView(
|
||||
// store: Store(
|
||||
// initialState: EMailLoginFeature.State()
|
||||
// ) {
|
||||
// EMailLoginFeature()
|
||||
// },
|
||||
// onBack: {},
|
||||
// showEmailLogin: .constant(true)
|
||||
// )
|
||||
//}
|
||||
|
19
yana/Views/EditFeedView.swift
Normal file
19
yana/Views/EditFeedView.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EditFeedView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("编辑动态")
|
||||
.font(.title)
|
||||
.bold()
|
||||
Text("这里是 EditFeedView 占位内容")
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
EditFeedView()
|
||||
}
|
65
yana/Views/FeedListView.swift
Normal file
65
yana/Views/FeedListView.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
|
||||
struct FeedListView: View {
|
||||
let store: StoreOf<FeedListFeature>
|
||||
|
||||
var body: some View {
|
||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// 背景图片
|
||||
Image("bg")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
.ignoresSafeArea(.all)
|
||||
VStack(alignment: .center, spacing: 0) {
|
||||
// 顶部栏
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Spacer(minLength: 0)
|
||||
Text("Enjoy your Life Time")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer(minLength: 0)
|
||||
Button(action: {
|
||||
viewStore.send(.editFeedButtonTapped)
|
||||
}) {
|
||||
Image("add icon")
|
||||
.resizable()
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, geometry.safeAreaInsets.top)
|
||||
// 其他内容
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.red)
|
||||
.padding(.top, 40)
|
||||
Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.")
|
||||
.font(.system(size: 16))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.bottom, 30)
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewStore.send(.onAppear)
|
||||
}
|
||||
.sheet(isPresented: viewStore.binding(
|
||||
get: \.isEditFeedPresented,
|
||||
send: { $0 ? .editFeedButtonTapped : .editFeedDismissed }
|
||||
)) {
|
||||
EditFeedView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,107 +1,158 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
|
||||
struct FeedView: View {
|
||||
struct FeedTopBarView: View {
|
||||
let store: StoreOf<FeedFeature>
|
||||
|
||||
let onShowCreateFeed: () -> Void
|
||||
var body: some View {
|
||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// 顶部区域 - 标题和加号按钮
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
// 标题
|
||||
Text("Enjoy your Life Time")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Spacer()
|
||||
|
||||
// 右侧加号按钮
|
||||
Button(action: {
|
||||
// 加号按钮操作
|
||||
}) {
|
||||
Image("add icon")
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
// 心脏图标
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.red)
|
||||
.padding(.top, 40)
|
||||
|
||||
// 励志文字
|
||||
Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.")
|
||||
WithPerceptionTracking {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Enjoy your Life Time")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
onShowCreateFeed() // 只调用回调
|
||||
}) {
|
||||
Image("add icon")
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedMomentsListView: View {
|
||||
let store: StoreOf<FeedFeature>
|
||||
var body: some View {
|
||||
WithPerceptionTracking {
|
||||
LazyVStack(spacing: 16) {
|
||||
if store.moments.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "heart.text.square")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
Text("暂无动态内容")
|
||||
.font(.system(size: 16))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.top, 20)
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
if let error = store.error {
|
||||
Text("错误: \(error)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.red.opacity(0.8))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
// 真实动态数据
|
||||
LazyVStack(spacing: 16) {
|
||||
if viewStore.moments.isEmpty {
|
||||
// 空状态
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "heart.text.square")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
|
||||
Text("暂无动态内容")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
|
||||
if let error = viewStore.error {
|
||||
Text("错误: \(error)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.red.opacity(0.8))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 20)
|
||||
// 重试按钮
|
||||
if store.error != nil {
|
||||
Button(action: {
|
||||
store.send(.retryLoad)
|
||||
}) {
|
||||
Text("重试")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.blue.opacity(0.8))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(.top, 40)
|
||||
} else {
|
||||
ForEach(Array(store.moments.enumerated()), id: \.element.dynamicId) { index, moment in
|
||||
WithPerceptionTracking {
|
||||
Text(moment.avatar)
|
||||
// OptimizedDynamicCardView(
|
||||
// moment: moment,
|
||||
// allMoments: store.moments,
|
||||
// currentIndex: index
|
||||
// )
|
||||
.onAppear {
|
||||
// 当显示最后一个动态时,加载更多数据
|
||||
if index == store.moments.count - 1 && store.hasMoreData && !store.isLoading {
|
||||
store.send(.loadMoreMoments)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多指示器
|
||||
if store.isLoading && !store.moments.isEmpty {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
Text("加载更多...")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
.padding(.top, 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20) // 调整顶部间距
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedView: View {
|
||||
let store: StoreOf<FeedFeature>
|
||||
let onShowCreateFeed: () -> Void
|
||||
|
||||
var body: some View {
|
||||
WithPerceptionTracking {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// 背景图片 - 与 HomeView 保持一致
|
||||
Image("bg")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
.ignoresSafeArea(.all)
|
||||
|
||||
// 主要内容布局
|
||||
VStack(spacing: 0) {
|
||||
// 固定内容区域
|
||||
VStack(spacing: 20) {
|
||||
FeedTopBarView(store: store, onShowCreateFeed: onShowCreateFeed)
|
||||
Image(systemName: "heart.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.red)
|
||||
.padding(.top, 40)
|
||||
} else {
|
||||
// 显示真实动态数据
|
||||
ForEach(Array(viewStore.moments.enumerated()), id: \.element.dynamicId) { index, moment in
|
||||
OptimizedDynamicCardView(
|
||||
moment: moment,
|
||||
allMoments: viewStore.moments,
|
||||
currentIndex: index
|
||||
)
|
||||
Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.")
|
||||
.font(.system(size: 16))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
// .padding(.top, 60) // 为状态栏留出空间
|
||||
|
||||
// 滚动内容区域 - 只有动态列表
|
||||
ScrollView {
|
||||
FeedMomentsListView(store: store)
|
||||
.padding(.bottom, 20) // 底部留出空间
|
||||
}
|
||||
.refreshable {
|
||||
// 下拉刷新
|
||||
await withCheckedContinuation { continuation in
|
||||
store.send(.refresh)
|
||||
// 简单延迟确保刷新完成
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 30)
|
||||
|
||||
// 加载状态
|
||||
if viewStore.isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
Text("加载中...")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
.padding(.top, 20)
|
||||
}
|
||||
|
||||
// 底部安全区域
|
||||
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
viewStore.send(.loadLatestMoments)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewStore.send(.onAppear)
|
||||
store.send(.onAppear)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,88 +165,90 @@ struct OptimizedDynamicCardView: View {
|
||||
let currentIndex: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// 用户信息
|
||||
HStack {
|
||||
// 使用缓存的头像
|
||||
CachedAsyncImage(url: moment.avatar) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.overlay(
|
||||
Text(String(moment.nick.prefix(1)))
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(moment.nick)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
WithPerceptionTracking{
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// 用户信息
|
||||
HStack {
|
||||
// 使用缓存的头像
|
||||
CachedAsyncImage(url: moment.avatar) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.overlay(
|
||||
Text(String(moment.nick.prefix(1)))
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(Circle())
|
||||
|
||||
Text(formatTime(moment.publishTime))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// VIP 标识
|
||||
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
||||
Text("VIP\(vipLevel)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(.yellow)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.yellow.opacity(0.2))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
|
||||
// 动态内容
|
||||
if !moment.content.isEmpty {
|
||||
Text(moment.content)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
// 优化的图片网格
|
||||
if let images = moment.dynamicResList, !images.isEmpty {
|
||||
OptimizedImageGrid(images: images)
|
||||
}
|
||||
|
||||
// 互动按钮
|
||||
HStack(spacing: 20) {
|
||||
Button(action: {}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "message")
|
||||
.font(.system(size: 16))
|
||||
Text("\(moment.commentCount)")
|
||||
.font(.system(size: 14))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(moment.nick)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text(formatTime(moment.publishTime))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
Button(action: {}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||
.font(.system(size: 16))
|
||||
Text("\(moment.likeCount)")
|
||||
.font(.system(size: 14))
|
||||
|
||||
Spacer()
|
||||
|
||||
// VIP 标识
|
||||
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
||||
Text("VIP\(vipLevel)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(.yellow)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.yellow.opacity(0.2))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
// 动态内容
|
||||
if !moment.content.isEmpty {
|
||||
Text(moment.content)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
// 优化的图片网格
|
||||
if let images = moment.dynamicResList, !images.isEmpty {
|
||||
OptimizedImageGrid(images: images)
|
||||
}
|
||||
|
||||
// 互动按钮
|
||||
HStack(spacing: 20) {
|
||||
Button(action: {}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "message")
|
||||
.font(.system(size: 16))
|
||||
Text("\(moment.commentCount)")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
Button(action: {}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||
.font(.system(size: 16))
|
||||
Text("\(moment.likeCount)")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
@@ -257,44 +310,45 @@ struct OptimizedImageGrid: View {
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let availableWidth = geometry.size.width
|
||||
let availableWidth = max(geometry.size.width, 1) // 防止为0或负数
|
||||
let spacing: CGFloat = 8
|
||||
|
||||
switch images.count {
|
||||
case 1:
|
||||
// 单张图片:大正方形居中显示
|
||||
let imageSize: CGFloat = min(availableWidth * 0.6, 200)
|
||||
HStack {
|
||||
Spacer()
|
||||
SquareImageView(image: images[0], size: imageSize)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
case 2:
|
||||
// 两张图片:并排显示
|
||||
let imageSize: CGFloat = (availableWidth - spacing) / 2
|
||||
HStack(spacing: spacing) {
|
||||
SquareImageView(image: images[0], size: imageSize)
|
||||
SquareImageView(image: images[1], size: imageSize)
|
||||
}
|
||||
|
||||
case 3:
|
||||
// 三张图片:水平排列
|
||||
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(images.prefix(3), id: \.id) { image in
|
||||
SquareImageView(image: image, size: imageSize)
|
||||
// 保护:如果availableWidth不合理,直接返回空视图
|
||||
if availableWidth < 10 {
|
||||
Color.clear.frame(height: 1)
|
||||
} else {
|
||||
switch images.count {
|
||||
case 1:
|
||||
// 单张图片:大正方形居中显示
|
||||
let imageSize: CGFloat = min(availableWidth * 0.6, 200)
|
||||
HStack {
|
||||
Spacer()
|
||||
SquareImageView(image: images[0], size: imageSize)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// 四张及以上:九宫格布局(最多9张)
|
||||
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
|
||||
|
||||
LazyVGrid(columns: columns, spacing: spacing) {
|
||||
ForEach(images.prefix(9), id: \.id) { image in
|
||||
SquareImageView(image: image, size: imageSize)
|
||||
case 2:
|
||||
// 两张图片:并排显示
|
||||
let imageSize: CGFloat = (availableWidth - spacing) / 2
|
||||
HStack(spacing: spacing) {
|
||||
SquareImageView(image: images[0], size: imageSize)
|
||||
SquareImageView(image: images[1], size: imageSize)
|
||||
}
|
||||
case 3:
|
||||
// 三张图片:水平排列
|
||||
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(images.prefix(3), id: \.id) { image in
|
||||
SquareImageView(image: image, size: imageSize)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 四张及以上:九宫格布局(最多9张)
|
||||
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
|
||||
LazyVGrid(columns: columns, spacing: spacing) {
|
||||
ForEach(images.prefix(9), id: \.id) { image in
|
||||
SquareImageView(image: image, size: imageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,6 +378,7 @@ struct SquareImageView: View {
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
let safeSize = size.isFinite && size > 0 ? size : 100 // 防止非有限或负数
|
||||
CachedAsyncImage(url: image.resUrl) { imageView in
|
||||
imageView
|
||||
.resizable()
|
||||
@@ -337,7 +392,7 @@ struct SquareImageView: View {
|
||||
.scaleEffect(0.8)
|
||||
)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.frame(width: safeSize, height: safeSize)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
}
|
||||
@@ -559,10 +614,10 @@ struct DynamicCardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
FeedView(
|
||||
store: Store(initialState: FeedFeature.State()) {
|
||||
FeedFeature()
|
||||
}
|
||||
)
|
||||
}
|
||||
//#Preview {
|
||||
// FeedView(
|
||||
// store: Store(initialState: FeedFeature.State()) {
|
||||
// FeedFeature()
|
||||
// }
|
||||
// )
|
||||
//}
|
||||
|
@@ -3,11 +3,12 @@ import ComposableArchitecture
|
||||
|
||||
struct HomeView: View {
|
||||
let store: StoreOf<HomeFeature>
|
||||
let onLogout: () -> Void
|
||||
@ObservedObject private var localizationManager = LocalizationManager.shared
|
||||
@State private var selectedTab: Tab = .feed
|
||||
|
||||
var body: some View {
|
||||
WithPerceptionTracking {
|
||||
NavigationStack {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// 使用 "bg" 图片作为背景 - 全屏显示
|
||||
@@ -23,13 +24,17 @@ struct HomeView: View {
|
||||
switch selectedTab {
|
||||
case .feed:
|
||||
FeedView(
|
||||
store: Store(initialState: FeedFeature.State()) {
|
||||
FeedFeature()
|
||||
store: store.scope(
|
||||
state: \.feedState,
|
||||
action: \.feed
|
||||
),
|
||||
onShowCreateFeed: {
|
||||
store.send(.showCreateFeed)
|
||||
}
|
||||
)
|
||||
.transition(.opacity)
|
||||
case .me:
|
||||
MeView()
|
||||
MeView(onLogout: onLogout)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
@@ -47,21 +52,36 @@ struct HomeView: View {
|
||||
store.send(.onAppear)
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { store.isSettingPresented },
|
||||
get: { store.withState(\.isSettingPresented) },
|
||||
set: { _ in store.send(.settingDismissed) }
|
||||
)) {
|
||||
SettingView(store: store.scope(state: \.settingState, action: \.setting))
|
||||
}
|
||||
.navigationDestination(isPresented: Binding(
|
||||
get: { store.withState(\.route) == .createFeed },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
store.send(.createFeedDismissed)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
CreateFeedView(
|
||||
store: store.scope(
|
||||
state: \.feedState.createFeedState,
|
||||
action: \.feed.createFeed
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HomeView(
|
||||
store: Store(
|
||||
initialState: HomeFeature.State()
|
||||
) {
|
||||
HomeFeature()
|
||||
}
|
||||
)
|
||||
}
|
||||
//#Preview {
|
||||
// HomeView(
|
||||
// store: Store(
|
||||
// initialState: HomeFeature.State()
|
||||
// ) {
|
||||
// HomeFeature()
|
||||
// }, onLogout: {}
|
||||
// )
|
||||
//}
|
||||
|
@@ -1,14 +1,18 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
import Perception
|
||||
|
||||
struct IDLoginView: View {
|
||||
let store: StoreOf<IDLoginFeature>
|
||||
let onBack: () -> Void
|
||||
@Binding var showIDLogin: Bool // 新增:绑定父视图的显示状态
|
||||
|
||||
// 使用本地@State管理UI状态
|
||||
@State private var userID: String = ""
|
||||
@State private var password: String = ""
|
||||
@State private var isPasswordVisible: Bool = false
|
||||
|
||||
// 导航状态管理 - 与 LoginView 保持一致
|
||||
@State private var showRecoverPassword: Bool = false
|
||||
|
||||
// 计算登录按钮是否可用
|
||||
@@ -17,171 +21,177 @@ struct IDLoginView: View {
|
||||
}
|
||||
|
||||
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)
|
||||
WithViewStore(store, observe: { $0.loginStep }) { viewStore in
|
||||
GeometryReader { geometry in
|
||||
WithPerceptionTracking {
|
||||
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()
|
||||
}
|
||||
.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))
|
||||
}
|
||||
.frame(height: 60)
|
||||
|
||||
// 标题
|
||||
Text(NSLocalizedString("id_login.title", comment: ""))
|
||||
.font(.system(size: 28, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
.padding(.horizontal, 24)
|
||||
.keyboardType(.numberPad)
|
||||
.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(NSLocalizedString("placeholder.enter_id", comment: ""))
|
||||
.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(NSLocalizedString("placeholder.enter_password", comment: ""))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
} else {
|
||||
SecureField("", text: $password) // 使用SwiftUI的绑定
|
||||
.placeholder(when: password.isEmpty) {
|
||||
Text(NSLocalizedString("placeholder.enter_password", comment: ""))
|
||||
.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(NSLocalizedString("id_login.forgot_password", comment: ""))
|
||||
.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 ? NSLocalizedString("id_login.logging_in", comment: "") : NSLocalizedString("id_login.login_button", comment: ""))
|
||||
.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)
|
||||
}
|
||||
|
||||
// 密码输入框
|
||||
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(
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
// 使用与 LoginView 一致的 navigationDestination 方式
|
||||
.navigationDestination(isPresented: $showRecoverPassword) {
|
||||
WithPerceptionTracking {
|
||||
RecoverPasswordView(
|
||||
store: Store(
|
||||
initialState: RecoverPasswordFeature.State()
|
||||
) {
|
||||
@@ -191,35 +201,42 @@ struct IDLoginView: View {
|
||||
showRecoverPassword = false
|
||||
}
|
||||
)
|
||||
.navigationBarHidden(true),
|
||||
isActive: $showRecoverPassword
|
||||
) {
|
||||
EmptyView()
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
let _ = WithPerceptionTracking {
|
||||
// 初始化时同步TCA状态到本地状态
|
||||
userID = store.userID
|
||||
password = store.password
|
||||
isPasswordVisible = store.isPasswordVisible
|
||||
|
||||
#if DEBUG
|
||||
// 移除测试用的硬编码凭据
|
||||
debugInfoSync("🐛 Debug模式: 已移除硬编码测试凭据")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
// 新增:监听登录状态,成功后自动关闭自身
|
||||
.onChange(of: viewStore.state) { newStep in
|
||||
debugInfoSync("🔄 IDLoginView: loginStep 变化为 \(newStep)")
|
||||
if newStep == .completed {
|
||||
debugInfoSync("✅ IDLoginView: 登录成功,准备关闭自身")
|
||||
showIDLogin = false
|
||||
}
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// 初始化时同步TCA状态到本地状态
|
||||
userID = store.userID
|
||||
password = store.password
|
||||
isPasswordVisible = store.isPasswordVisible
|
||||
|
||||
#if DEBUG
|
||||
// 移除测试用的硬编码凭据
|
||||
debugInfo("🐛 Debug模式: 已移除硬编码测试凭据")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
IDLoginView(
|
||||
store: Store(
|
||||
initialState: IDLoginFeature.State()
|
||||
) {
|
||||
IDLoginFeature()
|
||||
},
|
||||
onBack: {}
|
||||
)
|
||||
}
|
||||
//#Preview {
|
||||
// IDLoginView(
|
||||
// store: Store(
|
||||
// initialState: IDLoginFeature.State()
|
||||
// ) {
|
||||
// IDLoginFeature()
|
||||
// },
|
||||
// onBack: {},
|
||||
// showIDLogin: .constant(true)
|
||||
// )
|
||||
//}
|
||||
|
@@ -3,14 +3,18 @@ import ComposableArchitecture
|
||||
|
||||
struct LanguageSettingsView: View {
|
||||
@ObservedObject private var localizationManager = LocalizationManager.shared
|
||||
@StateObject private var cosManager = COSManager.shared
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
// 使用 TCA 的依赖注入获取 API 服务
|
||||
@Dependency(\.apiService) private var apiService
|
||||
|
||||
init(isPresented: Binding<Bool> = .constant(true)) {
|
||||
self._isPresented = isPresented
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(LocalizationManager.SupportedLanguage.allCases, id: \.rawValue) { language in
|
||||
@@ -43,19 +47,68 @@ struct LanguageSettingsView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
Section("调试功能") {
|
||||
Button("测试腾讯云 COS Token") {
|
||||
Task {
|
||||
await testCOToken()
|
||||
}
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
|
||||
if let tokenData = cosManager.token {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("✅ Token 获取成功")
|
||||
.font(.headline)
|
||||
.foregroundColor(.green)
|
||||
|
||||
Group {
|
||||
Text("存储桶: \(tokenData.bucket)")
|
||||
Text("地域: \(tokenData.region)")
|
||||
Text("应用ID: \(tokenData.appId)")
|
||||
Text("自定义域名: \(tokenData.customDomain)")
|
||||
Text("加速: \(tokenData.accelerate ? "启用" : "禁用")")
|
||||
Text("过期时间: \(tokenData.expirationDate, style: .date)")
|
||||
Text("剩余时间: \(tokenData.remainingTime)秒")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.navigationTitle("语言设置 / Language")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("返回 / Back") {
|
||||
isPresented = false
|
||||
}
|
||||
.onAppear {
|
||||
#if DEBUG
|
||||
// 调试环境下,页面显示时自动调用 tcToken API
|
||||
Task {
|
||||
await cosManager.testTokenRetrieval(apiService: apiService)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func testCOToken() async {
|
||||
do {
|
||||
let token = await cosManager.getToken(apiService: apiService)
|
||||
if let token = token {
|
||||
print("✅ Token 测试成功")
|
||||
print(" - 存储桶: \(token.bucket)")
|
||||
print(" - 地域: \(token.region)")
|
||||
print(" - 剩余时间: \(token.remainingTime)秒")
|
||||
} else {
|
||||
print("❌ Token 测试失败: 未能获取 Token")
|
||||
}
|
||||
} catch {
|
||||
print("❌ Token 测试异常: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LanguageRow: View {
|
||||
@@ -91,6 +144,6 @@ struct LanguageRow: View {
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
LanguageSettingsView(isPresented: .constant(true))
|
||||
}
|
||||
//#Preview {
|
||||
// LanguageSettingsView(isPresented: .constant(true))
|
||||
//}
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
import Perception
|
||||
|
||||
// PreferenceKey 用于传递图片高度
|
||||
struct ImageHeightPreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static let defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
@@ -11,8 +12,9 @@ struct ImageHeightPreferenceKey: PreferenceKey {
|
||||
|
||||
struct LoginView: View {
|
||||
let store: StoreOf<LoginFeature>
|
||||
let onLoginSuccess: () -> Void // 新增:登录成功回调
|
||||
@State private var topImageHeight: CGFloat = 120 // 默认值
|
||||
@ObservedObject private var localizationManager = LocalizationManager.shared
|
||||
// @ObservedObject private var localizationManager = LocalizationManager.shared
|
||||
@State private var showLanguageSettings = false
|
||||
@State private var isAgreedToTerms = true
|
||||
@State private var showUserAgreement = false
|
||||
@@ -21,159 +23,181 @@ struct LoginView: View {
|
||||
@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 {
|
||||
WithViewStore(self.store, observe: { $0.isAnyLoginCompleted }) { viewStore in
|
||||
NavigationStack {
|
||||
GeometryReader { geometry in
|
||||
WithPerceptionTracking {
|
||||
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(NSLocalizedString("login.app_title", comment: ""))
|
||||
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
|
||||
.foregroundColor(.white)
|
||||
.padding(.leading, 20)
|
||||
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(.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)
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
Spacer()
|
||||
}
|
||||
Spacer()
|
||||
#endif
|
||||
|
||||
VStack(spacing: 24) {
|
||||
// ID Login 按钮
|
||||
LoginButton(
|
||||
iconName: "person.circle.fill",
|
||||
iconColor: .green,
|
||||
title: NSLocalizedString("login.id_login", comment: "")
|
||||
) {
|
||||
showIDLogin = true // 直接设置SwiftUI状态
|
||||
}
|
||||
// Email Login 按钮
|
||||
LoginButton(
|
||||
iconName: "envelope.fill",
|
||||
iconColor: .blue,
|
||||
title: NSLocalizedString("login.email_login", comment: "")
|
||||
) {
|
||||
showEmailLogin = true // 显示邮箱登录界面
|
||||
}
|
||||
}.padding(.top, max(0, topImageHeight+140))
|
||||
}
|
||||
.onPreferenceChange(ImageHeightPreferenceKey.self) { imageHeight in
|
||||
topImageHeight = imageHeight
|
||||
}
|
||||
#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)
|
||||
// 间距,使登录按钮区域顶部距离"top"图片底部40pt
|
||||
Spacer()
|
||||
.frame(height: 120)
|
||||
|
||||
// 用户协议组件
|
||||
UserAgreementView(
|
||||
isAgreed: $isAgreedToTerms,
|
||||
onUserServiceTapped: {
|
||||
showUserAgreement = true
|
||||
},
|
||||
onPrivacyPolicyTapped: {
|
||||
showPrivacyPolicy = true
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 140)
|
||||
}
|
||||
|
||||
// 用户协议组件
|
||||
UserAgreementView(
|
||||
isAgreed: $isAgreedToTerms,
|
||||
onUserServiceTapped: {
|
||||
showUserAgreement = true
|
||||
},
|
||||
onPrivacyPolicyTapped: {
|
||||
showPrivacyPolicy = true
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.bottom, 140)
|
||||
// 移除旧的 NavigationLink,改用 navigationDestination
|
||||
}
|
||||
|
||||
// 隐藏的NavigationLink - 使用纯SwiftUI方式
|
||||
NavigationLink(
|
||||
destination: IDLoginView(
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
// 新增:适配 iOS 16 的 navigationDestination
|
||||
.navigationDestination(isPresented: $showIDLogin) {
|
||||
WithPerceptionTracking {
|
||||
IDLoginView(
|
||||
store: store.scope(
|
||||
state: \.idLoginState,
|
||||
action: \.idLogin
|
||||
),
|
||||
onBack: {
|
||||
showIDLogin = false // 直接设置SwiftUI状态
|
||||
}
|
||||
showIDLogin = false
|
||||
},
|
||||
showIDLogin: $showIDLogin // 新增:传递Binding
|
||||
)
|
||||
.navigationBarHidden(true),
|
||||
isActive: $showIDLogin // 使用SwiftUI的绑定
|
||||
) {
|
||||
EmptyView()
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.hidden()
|
||||
|
||||
// 新增:邮箱登录的NavigationLink
|
||||
NavigationLink(
|
||||
destination: EMailLoginView(
|
||||
}
|
||||
.navigationDestination(isPresented: $showEmailLogin) {
|
||||
WithPerceptionTracking {
|
||||
EMailLoginView(
|
||||
store: store.scope(
|
||||
state: \.emailLoginState,
|
||||
action: \.emailLogin
|
||||
),
|
||||
onBack: {
|
||||
showEmailLogin = false // 直接设置SwiftUI状态
|
||||
}
|
||||
showEmailLogin = false
|
||||
},
|
||||
showEmailLogin: $showEmailLogin // 新增:传递Binding
|
||||
)
|
||||
.navigationBarHidden(true),
|
||||
isActive: $showEmailLogin // 使用SwiftUI的绑定
|
||||
) {
|
||||
EmptyView()
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.hidden()
|
||||
}
|
||||
// 移除:HomeView 的 navigationDestination
|
||||
}
|
||||
.sheet(isPresented: $showLanguageSettings) {
|
||||
WithPerceptionTracking {
|
||||
LanguageSettingsView(isPresented: $showLanguageSettings)
|
||||
}
|
||||
}
|
||||
.webView(
|
||||
isPresented: $showUserAgreement,
|
||||
url: APIConfiguration.webURL(for: .userAgreement)
|
||||
)
|
||||
.webView(
|
||||
isPresented: $showPrivacyPolicy,
|
||||
url: APIConfiguration.webURL(for: .privacyPolicy)
|
||||
)
|
||||
// 新增:监听登录成功,调用回调
|
||||
.onChange(of: viewStore.state) { completed in
|
||||
if completed {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
// 新增:监听showIDLogin关闭时,若已登录则跳转首页
|
||||
.onChange(of: showIDLogin) { newValue in
|
||||
if newValue == false && viewStore.state {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
// 新增:监听showEmailLogin关闭时,若已登录则跳转首页
|
||||
.onChange(of: showEmailLogin) { newValue in
|
||||
if newValue == false && viewStore.state {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
.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()
|
||||
}
|
||||
)
|
||||
}
|
||||
//#Preview {
|
||||
// LoginView(
|
||||
// store: Store(
|
||||
// initialState: LoginFeature.State()
|
||||
// ) {
|
||||
// LoginFeature()
|
||||
// },
|
||||
// onLoginSuccess: {}
|
||||
// )
|
||||
//}
|
||||
|
49
yana/Views/MainView.swift
Normal file
49
yana/Views/MainView.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
//import Components // 如果 BottomTabView 在 Components 命名空间,否则移除
|
||||
|
||||
struct MainView: View {
|
||||
let store: StoreOf<MainFeature>
|
||||
|
||||
var body: some View {
|
||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||
NavigationStack {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
// 背景图片
|
||||
Image("bg")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
.ignoresSafeArea(.all)
|
||||
// 主内容
|
||||
ZStack {
|
||||
switch viewStore.selectedTab {
|
||||
case .feed:
|
||||
FeedListView(store: store.scope(
|
||||
state: \.feedList,
|
||||
action: \.feedList
|
||||
))
|
||||
.transition(.opacity)
|
||||
case .other:
|
||||
MeView(onLogout: {}) // 这里可根据需要传递实际登出回调
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// 底部导航栏
|
||||
VStack {
|
||||
Spacer()
|
||||
BottomTabView(selectedTab: viewStore.binding(
|
||||
get: { Tab(rawValue: $0.selectedTab.rawValue) ?? .feed },
|
||||
send: { MainFeature.Action.selectTab(MainFeature.Tab(rawValue: $0.rawValue) ?? .feed) }
|
||||
))
|
||||
}
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom + 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -2,6 +2,8 @@ import SwiftUI
|
||||
|
||||
struct MeView: View {
|
||||
@State private var showLogoutConfirmation = false
|
||||
let onLogout: () -> Void // 新增:登出回调
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
@@ -78,7 +80,7 @@ struct MeView: View {
|
||||
.alert("确认退出", isPresented: $showLogoutConfirmation) {
|
||||
Button("取消", role: .cancel) { }
|
||||
Button("退出", role: .destructive) {
|
||||
performLogout()
|
||||
Task { await performLogout() }
|
||||
}
|
||||
} message: {
|
||||
Text("确定要退出登录吗?")
|
||||
@@ -86,16 +88,13 @@ struct MeView: View {
|
||||
}
|
||||
|
||||
// MARK: - 退出登录方法
|
||||
private func performLogout() {
|
||||
debugInfo("🔓 开始执行退出登录...")
|
||||
|
||||
private func performLogout() async {
|
||||
debugInfoSync("🔓 开始执行退出登录...")
|
||||
// 清除所有认证数据(包括 keychain 中的内容)
|
||||
UserInfoManager.clearAllAuthenticationData()
|
||||
|
||||
// 发送通知重置 window root 为 login view
|
||||
NotificationCenter.default.post(name: .homeLogout, object: nil)
|
||||
|
||||
debugInfo("✅ 退出登录完成")
|
||||
await UserInfoManager.clearAllAuthenticationData()
|
||||
// 调用登出回调,通知父级切换视图
|
||||
onLogout()
|
||||
debugInfoSync("✅ 退出登录完成")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,5 +133,5 @@ struct MenuItemView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MeView()
|
||||
}
|
||||
MeView(onLogout: {})
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
import Combine
|
||||
|
||||
struct RecoverPasswordView: View {
|
||||
let store: StoreOf<RecoverPasswordFeature>
|
||||
@@ -13,16 +14,41 @@ struct RecoverPasswordView: View {
|
||||
|
||||
// 验证码倒计时状态
|
||||
@State private var countdown: Int = 0
|
||||
@State private var countdownTimer: Timer?
|
||||
@State private var timerCancellable: AnyCancellable?
|
||||
|
||||
// 简化的计算属性
|
||||
private var isEmailValid: Bool {
|
||||
!email.isEmpty
|
||||
}
|
||||
|
||||
private var isVerificationCodeValid: Bool {
|
||||
!verificationCode.isEmpty
|
||||
}
|
||||
|
||||
private var isNewPasswordValid: Bool {
|
||||
!newPassword.isEmpty
|
||||
}
|
||||
|
||||
private var isStoreNotLoading: Bool {
|
||||
!store.isResetLoading
|
||||
}
|
||||
|
||||
private var isCodeNotLoading: Bool {
|
||||
!store.isCodeLoading
|
||||
}
|
||||
|
||||
private var isCountdownFinished: Bool {
|
||||
countdown == 0
|
||||
}
|
||||
|
||||
// 计算确认按钮是否可用
|
||||
private var isConfirmButtonEnabled: Bool {
|
||||
return !store.isResetLoading && !email.isEmpty && !verificationCode.isEmpty && !newPassword.isEmpty
|
||||
isStoreNotLoading && isEmailValid && isVerificationCodeValid && isNewPasswordValid
|
||||
}
|
||||
|
||||
// 计算获取验证码按钮是否可用
|
||||
private var isGetCodeButtonEnabled: Bool {
|
||||
return !store.isCodeLoading && !email.isEmpty && countdown == 0
|
||||
isCodeNotLoading && isEmailValid && isCountdownFinished
|
||||
}
|
||||
|
||||
// 计算获取验证码按钮文本
|
||||
@@ -32,7 +58,7 @@ struct RecoverPasswordView: View {
|
||||
} else if countdown > 0 {
|
||||
return "\(countdown)s"
|
||||
} else {
|
||||
return "recover_password.get_code".localized
|
||||
return NSLocalizedString("recover_password.get_code", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +92,7 @@ struct RecoverPasswordView: View {
|
||||
.frame(height: 60)
|
||||
|
||||
// 标题
|
||||
Text("recover_password.title".localized)
|
||||
Text(NSLocalizedString("recover_password.title", comment: ""))
|
||||
.font(.system(size: 28, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.bottom, 80)
|
||||
@@ -74,115 +100,13 @@ struct RecoverPasswordView: View {
|
||||
// 输入框区域
|
||||
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)
|
||||
}
|
||||
emailInputField
|
||||
|
||||
// 验证码输入框(带获取按钮)
|
||||
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)
|
||||
}
|
||||
verificationCodeInputField
|
||||
|
||||
// 新密码输入框
|
||||
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)
|
||||
}
|
||||
newPasswordInputField
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
@@ -190,37 +114,7 @@ struct RecoverPasswordView: View {
|
||||
.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)
|
||||
confirmButton
|
||||
|
||||
// 错误信息
|
||||
if let errorMessage = store.errorMessage {
|
||||
@@ -236,20 +130,7 @@ struct RecoverPasswordView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// 每次进入页面都重置状态
|
||||
store.send(.resetState)
|
||||
|
||||
email = ""
|
||||
verificationCode = ""
|
||||
newPassword = ""
|
||||
isNewPasswordVisible = false
|
||||
countdown = 0
|
||||
stopCountdown()
|
||||
|
||||
#if DEBUG
|
||||
email = "exzero@126.com"
|
||||
store.send(.emailChanged(email))
|
||||
#endif
|
||||
resetState()
|
||||
}
|
||||
.onDisappear {
|
||||
stopCountdown()
|
||||
@@ -263,47 +144,207 @@ struct RecoverPasswordView: View {
|
||||
.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
|
||||
// MARK: - UI Components
|
||||
|
||||
private func startCountdown() {
|
||||
countdown = 60
|
||||
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||||
if countdown > 0 {
|
||||
countdown -= 1
|
||||
} else {
|
||||
stopCountdown()
|
||||
}
|
||||
private var emailInputField: some View {
|
||||
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(NSLocalizedString("recover_password.placeholder_email", comment: ""))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
.padding(.horizontal, 24)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
}
|
||||
|
||||
private var verificationCodeInputField: some View {
|
||||
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(NSLocalizedString("recover_password.placeholder_verification_code", comment: ""))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
// 获取验证码按钮
|
||||
Button(action: {
|
||||
startCountdown()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private var newPasswordInputField: some View {
|
||||
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(NSLocalizedString("recover_password.placeholder_new_password", comment: ""))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16))
|
||||
} else {
|
||||
SecureField("", text: $newPassword)
|
||||
.placeholder(when: newPassword.isEmpty) {
|
||||
Text(NSLocalizedString("recover_password.placeholder_new_password", comment: ""))
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
private var confirmButton: some View {
|
||||
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 ? NSLocalizedString("recover_password.resetting", comment: "") : NSLocalizedString("recover_password.confirm_button", comment: ""))
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
.frame(height: 56)
|
||||
}
|
||||
.disabled(!isConfirmButtonEnabled)
|
||||
.opacity(isConfirmButtonEnabled ? 1.0 : 0.5)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func resetState() {
|
||||
store.send(.resetState)
|
||||
|
||||
email = ""
|
||||
verificationCode = ""
|
||||
newPassword = ""
|
||||
isNewPasswordVisible = false
|
||||
countdown = 0
|
||||
|
||||
#if DEBUG
|
||||
email = "exzero@126.com"
|
||||
store.send(.emailChanged(email))
|
||||
#endif
|
||||
}
|
||||
|
||||
private func startCountdown() {
|
||||
stopCountdown()
|
||||
countdown = 60
|
||||
|
||||
timerCancellable = Timer.publish(every: 1.0, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.sink { _ in
|
||||
if countdown > 0 {
|
||||
countdown -= 1
|
||||
} else {
|
||||
stopCountdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCountdown() {
|
||||
countdownTimer?.invalidate()
|
||||
countdownTimer = nil
|
||||
timerCancellable?.cancel()
|
||||
timerCancellable = nil
|
||||
countdown = 0
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
RecoverPasswordView(
|
||||
store: Store(
|
||||
initialState: RecoverPasswordFeature.State()
|
||||
) {
|
||||
RecoverPasswordFeature()
|
||||
},
|
||||
onBack: {}
|
||||
)
|
||||
}
|
||||
//#Preview {
|
||||
// RecoverPasswordView(
|
||||
// store: Store(
|
||||
// initialState: RecoverPasswordFeature.State()
|
||||
// ) {
|
||||
// RecoverPasswordFeature()
|
||||
// },
|
||||
// onBack: {}
|
||||
// )
|
||||
//}
|
||||
|
@@ -6,29 +6,36 @@ struct SplashView: View {
|
||||
|
||||
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()
|
||||
Group {
|
||||
// 根据导航目标显示不同页面
|
||||
if let navigationDestination = store.navigationDestination {
|
||||
switch navigationDestination {
|
||||
case .login:
|
||||
// 显示登录页面
|
||||
LoginView(
|
||||
store: Store(
|
||||
initialState: LoginFeature.State()
|
||||
) {
|
||||
LoginFeature()
|
||||
},
|
||||
onLoginSuccess: {
|
||||
// 登录成功后导航到主页面
|
||||
store.send(.navigateToMain)
|
||||
}
|
||||
)
|
||||
case .main:
|
||||
// 显示主应用页面
|
||||
MainView(
|
||||
store: Store(
|
||||
initialState: MainFeature.State()
|
||||
) {
|
||||
MainFeature()
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// 显示启动画面
|
||||
splashContent
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -36,14 +43,43 @@ struct SplashView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动画面内容
|
||||
private var splashContent: some View {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SplashView(
|
||||
store: Store(
|
||||
initialState: SplashFeature.State()
|
||||
) {
|
||||
SplashFeature()
|
||||
}
|
||||
)
|
||||
}
|
||||
//#Preview {
|
||||
// SplashView(
|
||||
// store: Store(
|
||||
// initialState: SplashFeature.State()
|
||||
// ) {
|
||||
// SplashFeature()
|
||||
// }
|
||||
// )
|
||||
//}
|
||||
|
@@ -20,13 +20,17 @@ struct yanaApp: App {
|
||||
// 不是在Previews环境中运行
|
||||
}
|
||||
#endif
|
||||
|
||||
debugInfo("🛠 原生URLSession测试开始")
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
AppRootView()
|
||||
SplashView(
|
||||
store: Store(
|
||||
initialState: SplashFeature.State()
|
||||
) {
|
||||
SplashFeature()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -163,10 +163,10 @@ final class yanaAPITests: XCTestCase {
|
||||
XCTAssertEqual(accountModel?.uid, "3184", "真实API数据的UID应该正确")
|
||||
XCTAssertTrue(accountModel?.hasValidAuthentication ?? false, "真实API数据应该有有效认证")
|
||||
|
||||
debugInfo("✅ 真实API数据测试通过")
|
||||
debugInfo(" UID: \(accountModel?.uid ?? "nil")")
|
||||
debugInfo(" Access Token存在: \(accountModel?.accessToken != nil)")
|
||||
debugInfo(" Token类型: \(accountModel?.tokenType ?? "nil")")
|
||||
debugInfoSync("✅ 真实API数据测试通过")
|
||||
debugInfoSync(" UID: \(accountModel?.uid ?? "nil")")
|
||||
debugInfoSync(" Access Token存在: \(accountModel?.accessToken != nil)")
|
||||
debugInfoSync(" Token类型: \(accountModel?.tokenType ?? "nil")")
|
||||
|
||||
} catch {
|
||||
XCTFail("解析真实API数据失败: \(error)")
|
||||
|
Reference in New Issue
Block a user