9 Commits

Author SHA1 Message Date
edwinQQQ
d4bef537d9 feat: 更新FeedListView以使用ViewStore管理状态
- 将FeedListView中的状态管理从store转换为viewStore,提升代码可读性和一致性。
- 移除不必要的本地状态isEditFeedSheetPresented,简化视图逻辑。
- 更新sheet呈现逻辑,确保与viewStore的状态绑定,增强用户体验。
2025-07-21 19:14:40 +08:00
edwinQQQ
ba991598be feat: 更新CreateFeed功能及相关视图组件
- 在CreateFeedFeature中新增isPresented依赖,确保在适当的上下文中执行视图关闭操作。
- 在FeedFeature中优化状态管理,简化CreateFeedView的呈现逻辑。
- 新增FeedListFeature和MainFeature,整合FeedListView和底部导航功能,提升用户体验。
- 更新HomeView和SplashView以集成MainView,确保应用结构一致性。
- 在多个视图中调整状态管理和导航逻辑,增强可维护性和用户体验。
2025-07-21 19:10:31 +08:00
edwinQQQ
5f65df0e7f 指定 swift & tca version 2025-07-21 16:59:23 +08:00
edwinQQQ
9a49d591c3 feat: 添加腾讯云COS Token管理功能及相关视图更新
- 在APIEndpoints.swift中新增tcToken端点以支持腾讯云COS Token获取。
- 在APIModels.swift中新增TcTokenRequest和TcTokenResponse模型,处理Token请求和响应。
- 在COSManager.swift中实现Token的获取、缓存和过期管理逻辑,提升API请求的安全性。
- 在LanguageSettingsView中添加调试功能,允许测试COS Token获取。
- 在多个视图中更新状态管理和导航逻辑,确保用户体验一致性。
- 在FeedFeature和HomeFeature中优化状态管理,简化视图逻辑。
2025-07-18 20:50:25 +08:00
edwinQQQ
fb7ae9e0ad feat: 更新.gitignore,删除需求文档,优化API调试信息
- 在.gitignore中添加忽略项以排除不必要的文件。
- 删除架构分析需求文档以简化项目文档。
- 在APIEndpoints.swift和LoginModels.swift中移除调试信息的异步调用,提升代码简洁性。
- 在EMailLoginFeature.swift和HomeFeature.swift中新增登录流程状态管理,优化用户体验。
- 在多个视图中调整状态管理和导航逻辑,确保一致性和可维护性。
- 更新Xcode项目配置以增强调试信息的输出格式。
2025-07-18 15:57:54 +08:00
edwinQQQ
128bf36c88 feat: 更新依赖和项目配置,优化代码结构
- 在Package.swift中注释掉旧的swift-composable-architecture依赖,并添加swift-case-paths依赖。
- 在Podfile中将iOS平台版本更新至16.0,并移除QCloudCOSXML/Transfer依赖,改为使用QCloudCOSXML。
- 更新Podfile.lock以反映依赖变更,确保项目依赖的准确性。
- 新增架构分析需求文档,明确项目架构评估和改进建议。
- 在多个文件中实现async/await语法,提升异步操作的可读性和性能。
- 更新日志输出方法,确保在调试模式下提供一致的调试信息。
- 优化多个视图组件,提升用户体验和代码可维护性。
2025-07-17 18:47:09 +08:00
edwinQQQ
4bbb4f8434 feat: 添加CreateFeed功能及相关视图组件
- 新增CreateFeedView和CreateFeedFeature,支持用户发布图文动态。
- 在FeedView中集成CreateFeedView,允许用户通过加号按钮访问发布界面。
- 实现图片选择和文本输入功能,支持最多9张图片的上传。
- 添加发布API请求模型,处理动态发布逻辑。
- 更新FeedFeature以管理CreateFeedView的显示状态,确保用户体验流畅。
- 完善UI结构分析与执行计划文档,明确开发步骤和技术要点。
2025-07-16 15:53:32 +08:00
edwinQQQ
33a558ae7b temp commit 2025-07-16 12:06:53 +08:00
edwinQQQ
1f98ed534d feat: 更新Swift助手样式和动态视图组件
- 在swift-assistant-style.mdc中更新上下文信息,简化描述并保留关键信息。
- 在swift-swiftui-dev-rules.mdc中将alwaysApply设置为false,调整开发规则。
- 在Info.plist中移除冗余的CFBundleDisplayName和CFBundleName键,保持文件整洁。
- 在FeedFeature.swift中添加调试日志,增强API响应的可追踪性。
- 在HomeFeature.swift中新增Feed状态和相关actions,优化状态管理。
- 在FeedView.swift中直接使用store状态,提升组件性能和可读性。
- 在HomeView.swift中更新FeedView的store传递方式,确保状态一致性。
- 更新Xcode项目配置,调整代码签名和Swift版本,确保兼容性。
2025-07-14 14:50:15 +08:00
66 changed files with 3388 additions and 2096 deletions

View File

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

View File

@@ -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
View File

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

View 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。

View File

@@ -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"
}
},
{

View File

@@ -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"]
),
]
)
)

View File

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

View File

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

View File

@@ -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 */;

View File

@@ -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"
}
},
{

View 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>

View File

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

View File

@@ -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"
}
}
],

View File

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

View File

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

View File

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

View File

@@ -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("❌ 无法更新 ticketAccountModel 不存在")
static func updateAccountModelTicket(_ ticket: String) async {
guard var accountModel = await getAccountModel() else {
debugErrorSync("❌ 无法更新 ticketAccountModel 不存在")
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)
}
}

View File

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

View File

@@ -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: APInil
static func createIDLoginRequest(userID: String, password: String) -> IDLoginAPIRequest? {
static func createIDLoginRequest(userID: String, password: String) async -> IDLoginAPIRequest? {
// 使DESID
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: APInil
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)
}

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

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

View File

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

View File

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

View 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 contextdismiss
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
}

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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:
// IDLoginfeature
@@ -216,7 +241,14 @@ struct LoginFeature {
case .emailLogin:
// EmailLoginfeature
return .none
case .home(_):
return .none
}
}
}
}
}
// 使
// extension Notification.Name {
// static let ticketSuccess = Notification.Name("ticketSuccess")
// }

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,11 +67,11 @@ struct FontManager {
///
static func printAllAvailableFonts() {
debugInfo("=== 所有可用字体 ===")
debugInfoSync("=== 所有可用字体 ===")
for font in getAllAvailableFonts() {
debugInfo(font)
debugInfoSync(font)
}
debugInfo("==================")
debugInfoSync("==================")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()
// }
// )
//}

View File

@@ -1,17 +1,16 @@
import SwiftUI
import ComposableArchitecture
import Combine
struct EMailLoginView: View {
let store: StoreOf<EMailLoginFeature>
let onBack: () -> Void
@Binding var showEmailLogin: Bool
// 使@StateUI
@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)
// )
//}

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

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

View File

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

View File

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

View File

@@ -1,14 +1,18 @@
import SwiftUI
import ComposableArchitecture
import Perception
struct IDLoginView: View {
let store: StoreOf<IDLoginFeature>
let onBack: () -> Void
@Binding var showIDLogin: Bool //
// 使@StateUI
@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)
// )
//}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,13 +20,17 @@ struct yanaApp: App {
// Previews
}
#endif
debugInfo("🛠 原生URLSession测试开始")
}
var body: some Scene {
WindowGroup {
AppRootView()
SplashView(
store: Store(
initialState: SplashFeature.State()
) {
SplashFeature()
}
)
}
}
}

View File

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