38 Commits

Author SHA1 Message Date
edwinQQQ
a37d7c6eb8 feat: 更新AppSettingView和ImagePicker组件以增强图片选择与预览体验
- 在AppSettingView中修复selectedImages的绑定,确保预览组件正确接收图片。
- 在ImagePreviewView中将images参数改为@Binding类型,提升数据流动性。
- 在ImagePickerWithPreviewView中使用.constant修饰符,优化预览逻辑。
- 在CameraPicker中添加相机控制显示和视图变换设置,提升用户体验。
- 在ImagePreviewView中添加加载状态提示,改善用户反馈。
2025-07-26 09:55:23 +08:00
edwinQQQ
bc96cc47ff feat: 优化AppSettingView和ImagePicker组件以增强图片选择体验
- 在AppSettingView中添加详细的日志记录,便于调试和用户反馈。
- 改进图片加载逻辑,确保在加载失败时提供用户友好的错误提示。
- 在ImagePickerWithPreview组件中使用主线程更新UI,提升响应速度。
- 更新错误弹窗逻辑,确保在用户确认后关闭所有相关弹窗,优化用户体验。
2025-07-26 09:43:10 +08:00
edwinQQQ
ac0d622c97 feat: 更新Info.plist和AppSettingView以支持相机和图片选择功能
- 在Info.plist中新增相机使用说明,确保应用能够访问相机功能。
- 在AppSettingFeature中新增showImagePicker状态和setShowImagePicker动作,支持图片选择弹窗的显示。
- 在AppSettingView中整合图片选择与预览功能,优化用户体验。
- 更新MainView以简化导航逻辑,提升代码可读性与维护性。
- 在ImagePickerWithPreview组件中实现相机和相册选择功能,增强交互性。
2025-07-25 18:47:11 +08:00
edwinQQQ
2f3ef22ce5 feat: 更新AppSettingView以集成图片选择与预览功能
- 将图片选择功能整合到AppSettingView中,使用ImagePickerWithPreviewView提升用户体验。
- 移除冗余的照片选择处理逻辑,简化代码结构。
- 更新昵称编辑功能的实现,确保用户输入限制在15个字符内。
- 优化导航栏和用户协议、隐私政策的展示,增强界面交互性。
2025-07-25 17:20:31 +08:00
edwinQQQ
2cfdf110af feat: 新增图片选择与预览功能
- 在ImagePickerWithPreview组件中实现相机和相册选择功能,提升用户体验。
- 新增ImagePreviewView以支持图片预览,增强交互性。
- 更新MainView以移除冗余调试日志,优化代码整洁性。
- 在swift-assistant-style.mdc中添加项目基础信息,确保开发环境一致性。
2025-07-25 17:08:05 +08:00
edwinQQQ
79fc03b52a feat: 优化视图组件与数据迁移逻辑
- 移除DataMigrationManager类,简化数据迁移逻辑。
- 在FeedListView和MeView中新增图片预览功能,提升用户体验。
- 更新OptimizedDynamicCardView以支持图片点击回调,增强交互性。
- 新增PreviewItem结构体以管理图片预览状态,提升代码可读性与维护性。
- 清理AppDelegate中的冗余代码,优化启动流程。
2025-07-25 16:22:38 +08:00
edwinQQQ
815091a2ff feat: 新增返回按钮功能以优化设置页面导航
- 在AppSettingFeature中新增dismissTapped事件,处理返回操作。
- 更新MainFeature以监听dismissTapped事件,支持导航栈的pop操作。
- 在AppSettingView中实现返回按钮,提升用户体验与界面交互性。
- 隐藏导航栏以优化设置页面的视觉效果。
2025-07-25 14:43:27 +08:00
edwinQQQ
fb09ddb956 feat: 增强应用功能与用户体验
- 在Package.swift中更新依赖路径,确保项目结构清晰。
- 在AppSettingFeature中新增初始化方法,支持用户信息、头像和昵称的设置。
- 更新FeedListFeature和MainFeature,新增测试按钮和导航功能,提升用户交互体验。
- 在MeFeature中优化用户信息加载逻辑,增强错误处理能力。
- 新增TestView以支持测试功能,验证导航跳转的有效性。
- 更新多个视图以整合新功能,提升代码可读性与维护性。
2025-07-25 14:10:56 +08:00
edwinQQQ
343fd9e2df feat: 新增用户信息更新功能
- 在APIEndpoints中新增用户信息更新端点。
- 实现UpdateUserRequest和UpdateUserResponse结构体,支持用户信息更新请求和响应。
- 在APIService中添加updateUser方法,处理用户信息更新请求。
- 更新AppSettingFeature以支持头像和昵称的修改,整合用户信息更新逻辑。
- 在AppSettingView中实现头像选择和昵称编辑功能,提升用户体验。
2025-07-24 16:38:27 +08:00
edwinQQQ
c072a7e73d feat: 移除设置功能并优化主功能状态管理
- 从HomeFeature和MainFeature中移除设置相关状态和逻辑,简化状态管理。
- 更新视图以去除设置页面的展示,提升用户体验。
- 删除SettingFeature及其相关视图,减少冗余代码,增强代码可维护性。
2025-07-24 15:04:39 +08:00
edwinQQQ
6cc4b11e93 feat: 更新AppSettingFeature和MainFeature以支持登出功能
- 在AppSettingFeature中实现登出逻辑,清理认证信息并发送登出事件。
- 在MainFeature中新增登出标志和相应的状态管理,确保用户登出后状态更新。
- 更新MainView以响应登出事件,重命名内部视图为InternalMainView以提高可读性。
- 在SplashView中集成登出处理,确保用户能够顺利返回登录页面。
2025-07-24 14:46:39 +08:00
edwinQQQ
cb325724dc feat: 更新AppSettingFeature以改进用户信息加载逻辑
- 重构用户信息加载逻辑,采用Result类型处理成功与失败的响应,提升错误处理能力。
- 更新状态管理,确保用户信息加载状态与错误信息的准确反映。
- 移除冗余代码,简化用户信息获取流程,增强代码可读性。
2025-07-24 14:03:51 +08:00
edwinQQQ
71c40e465d feat: 更新AppSettingFeature以增强用户设置功能
- 重构AppSettingFeature,采用@Reducer和@ObservableState以优化状态管理。
- 新增用户信息加载逻辑,支持从服务器获取用户信息并更新界面。
- 更新AppSettingView,整合头像、昵称及其他设置项的展示,提升用户体验。
- 增加多语言支持,更新Localizable.strings文件以适应新的设置项。
2025-07-24 11:24:04 +08:00
edwinQQQ
f30026821a feat: 更新MainFeature以优化用户状态管理
- 在MainFeature中增强用户状态管理逻辑,确保仅在用户切换时重置首次加载状态。
- 更新MeFeature的uid处理逻辑,提升用户体验与状态一致性。
2025-07-24 10:20:29 +08:00
edwinQQQ
25fec8a2e6 feat: 增强FeedListFeature和MeFeature的首次加载逻辑
- 在FeedListFeature和MeFeature中新增isFirstLoad状态,确保仅在首次加载时请求数据。
- 更新MainView以简化视图切换逻辑,使用isHidden修饰符控制视图显示。
- 新增View+isHidden扩展,提供视图隐藏功能,提升代码可读性和复用性。
2025-07-24 10:20:12 +08:00
edwinQQQ
3a74547684 feat: 新增应用设置功能及相关视图
- 在MainFeature中集成AppSettingFeature,支持应用设置页面的导航与状态管理。
- 新增AppSettingView以展示用户头像和昵称编辑功能,提升用户体验。
- 更新MainView以支持应用设置页面的展示,增强导航功能。
2025-07-23 20:15:14 +08:00
edwinQQQ
bb49b00a59 feat: 新增导航功能与设置页面支持
- 在MainFeature中新增导航路径和设置页面状态管理,支持页面导航。
- 更新MainView以集成导航功能,添加测试按钮以触发导航。
- 在MeFeature中新增设置按钮点击事件,交由MainFeature处理。
- 增强MeView以支持设置按钮,提升用户体验。
2025-07-23 20:10:37 +08:00
edwinQQQ
772543243f feat: 重构用户动态功能,整合MeFeature并更新MainFeature
- 将MeDynamicFeature重命名为MeFeature,并在MainFeature中进行相应更新。
- 更新MainFeature的状态管理,整合用户动态相关逻辑。
- 新增MeFeature以管理用户信息和动态加载,提升代码结构清晰度。
- 更新MainView和MeView以适应新的MeFeature,优化用户体验。
- 删除冗余的MeDynamicView,简化视图结构,提升代码可维护性。
2025-07-23 19:31:17 +08:00
edwinQQQ
8b09653c4c feat: 更新动态功能,新增我的动态视图及相关状态管理
- 在HomeFeature中添加MeDynamicFeature以管理用户动态状态。
- 在MainFeature中集成MeDynamicFeature,支持动态内容的加载与展示。
- 新增MeDynamicView以展示用户的动态列表,支持下拉刷新和上拉加载更多功能。
- 更新MeView以集成用户动态视图,提升用户体验。
- 在APIEndpoints中新增getMyDynamic端点以支持获取用户动态信息。
- 更新DynamicsModels以适应新的动态数据结构,确保数据解析的准确性。
- 在OptimizedDynamicCardView中优化图片处理逻辑,提升动态展示效果。
- 更新相关视图组件以支持动态内容的展示与交互,增强用户体验。
2025-07-23 19:17:49 +08:00
edwinQQQ
3a68270ca9 feat: 更新README.md以反映项目架构和功能增强
- 在项目简介中添加The Composable Architecture (TCA)架构设计信息。
- 更新技术栈部分,包含支持的开发语言、最低支持版本及主要框架。
- 增加用户认证、云存储集成及自定义UI组件的描述。
- 修改环境要求以支持iOS 16及以上版本。
- 更新开发规范,采用TCA架构模式并支持多语言。
- 添加API测试目标和开发团队信息,提升文档完整性。
2025-07-23 17:57:02 +08:00
edwinQQQ
0fe3b6cb7a feat: 新增用户信息获取功能及相关模型
- 在APIEndpoints.swift中新增getUserInfo端点以支持获取用户信息。
- 在APIModels.swift中实现获取用户信息请求和响应模型,处理用户信息的请求与解析。
- 在UserInfoManager中新增方法以从服务器获取用户信息,并在登录成功后自动获取用户信息。
- 在SettingFeature中新增用户信息刷新状态管理,支持用户信息的刷新操作。
- 在SettingView中集成用户信息刷新按钮,提升用户体验。
- 在SplashFeature中实现自动获取用户信息的逻辑,优化用户登录流程。
- 在yanaAPITests中添加用户信息相关的单元测试,确保功能的正确性。
2025-07-23 11:46:46 +08:00
edwinQQQ
8362142c49 feat: 更新图片预览功能,支持本地与远程图片展示
- 在ImagePreviewPager中引入ImagePreviewSource枚举,支持本地和远程图片的统一处理。
- 优化OptimizedDynamicCardView,新增图片预览状态管理,集成全屏图片预览功能。
- 更新OptimizedImageGrid以支持图片点击事件,触发预览弹窗,提升用户体验。
2025-07-22 19:49:42 +08:00
edwinQQQ
ed3e7100c3 feat: 增强发布动态功能,支持图片上传与进度显示
- 在PublishFeedRequest中新增resList属性,支持上传图片资源信息。
- 在EditFeedFeature中实现图片上传逻辑,处理图片选择与上传进度。
- 更新EditFeedView以显示图片上传进度,提升用户体验。
- 在COSManager中新增UIImage上传方法,优化图片上传流程。
- 在FeedListView中添加通知以刷新动态列表,确保数据同步。
2025-07-22 19:02:48 +08:00
edwinQQQ
fd6e44c6f9 feat: 新增图片预览功能与现代图片选择组件
- 在EditFeedView中集成ImagePreviewPager,支持全屏图片预览。
- 更新ModernImageSelectionGrid,添加图片点击事件以触发预览。
- 移除冗余的PhotosUI导入,优化代码结构。
2025-07-22 18:20:21 +08:00
edwinQQQ
2a02553015 feat: 增强编辑动态功能,支持图片选择与处理
- 在EditFeedFeature中新增图片选择相关状态和动作,支持用户选择和处理最多9张图片。
- 在EditFeedView中集成ModernImageSelectionGrid组件,优化图片选择界面。
- 更新CreateFeedView以支持图片选择功能,提升用户体验。
- 实现图片处理逻辑,确保用户能够方便地添加和删除图片。
2025-07-22 18:06:10 +08:00
edwinQQQ
4eb01bde7c feat: 实现动态内容的分页加载与刷新功能
- 在FeedListFeature中新增分页相关状态管理,支持上拉加载更多和下拉刷新功能,提升用户体验。
- 在FeedListView中实现上拉加载更多的触发逻辑和加载指示器,优化动态内容展示。
2025-07-22 17:43:24 +08:00
edwinQQQ
60b3f824be feat: 增加首次加载标志以优化数据请求逻辑
- 在FeedListFeature中新增isLoaded属性,确保仅在首次加载时请求feed数据,提升性能和用户体验。
2025-07-22 17:26:29 +08:00
edwinQQQ
c8ff40cac1 feat: 更新动态相关数据模型及视图组件
- 在DynamicsModels.swift中为动态响应结构和列表数据添加Sendable协议,提升并发安全性。
- 在FeedListFeature.swift中实现动态内容的加载逻辑,集成API请求以获取最新动态。
- 在FeedListView.swift中新增动态内容列表展示,优化用户交互体验。
- 在MeView.swift中添加设置按钮,支持弹出设置视图。
- 在SettingView.swift中新增COS上传测试功能,允许用户测试图片上传至腾讯云COS。
- 在OptimizedDynamicCardView.swift中实现优化的动态卡片组件,提升动态展示效果。
2025-07-22 17:17:21 +08:00
edwinQQQ
6c363ea884 feat: 添加发布动态功能及相关视图组件
- 在APIEndpoints.swift中新增publishFeed端点以支持发布动态。
- 新增PublishFeedRequest和PublishFeedResponse模型,处理发布请求和响应。
- 在EditFeedFeature中实现动态编辑功能,支持用户输入和发布内容。
- 更新CreateFeedView和EditFeedView以集成新的发布功能,提升用户体验。
- 在Localizable.strings中添加相关文本的本地化支持,确保多语言兼容性。
- 优化FeedListView和FeedView以展示最新动态,增强用户交互体验。
2025-07-22 15:13:32 +08:00
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
88 changed files with 7796 additions and 3393 deletions

View File

@@ -3,67 +3,37 @@ description:
globs:
alwaysApply: true
---
# CONTEXT
# 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.
---
# 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:
This project based on iOS 16.0+ & SwiftUI & TCA 1.20.2
I wish to receive advice using the latest tools and seek step-by-step guidance to fully understand the implementation process.
## OBJECTIVE
As an expert AI programming assistant, your task is to provide me with clear, readable, and effective code. You should:
- Utilize the latest versions of SwiftUI, Swift(6) and TCA(1.20.2), 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.
## RESPONSE FORMAT
- **Utilize the Chain-of-Thought (CoT) method to reason and respond, explaining your thought process step by step.**
- 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.
- Fully implement all requested features, leaving no to-dos, placeholders, or omissions.
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.
- 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.

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

@@ -9,13 +9,22 @@
"version" : "1.0.3"
}
},
{
"identity" : "liquidglass",
"kind" : "remoteSourceControl",
"location" : "https://github.com/BarredEwe/LiquidGlass.git",
"state" : {
"revision" : "d5bf927a08a97c2d94db7ef71f1e15f8532d1005",
"version" : "0.7.0"
}
},
{
"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,18 +15,23 @@ 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"),
.package(url: "https://github.com/BarredEwe/LiquidGlass.git", from: "0.7.0")
],
targets: [
.target(
name: "yana",
dependencies: [
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
]
"LiquidGlass"
],
path: "yana",
),
.testTarget(
name: "yanaTests",
dependencies: ["yana"]
dependencies: ["yana"],
path: "yanaAPITests",
),
]
)
)

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

@@ -2,37 +2,50 @@
## 项目简介
Yana 是一个基于 iOS 平台的即时通讯应用,使用 Swift 语言开发,集成了网易云信 SDK 实现即时通讯功能。
Yana 是一个基于 iOS 平台的即时通讯应用,使用 Swift 语言开发,集成了网易云信 SDK 实现即时通讯功能,并采用 The Composable Architecture (TCA) 架构设计
## 技术栈
- 开发语言Swift
- 最低支持版本iOS 15.6
- 主要框架:
- NIMSDK_LITE网易云信即时通讯 SDK
- **开发语言**Swift (主要)Objective-C (部分组件)
- **最低支持版本**iOS 16
- **架构模式**The Composable Architecture (TCA) - 1.20.2
- **UI 框架**SwiftUI
- **依赖管理**
- CocoaPods
- Swift Package Manager
- **主要框架**
- NIMSDK_LITE网易云信即时通讯 SDK (10.6.1)
- NEChatKit聊天核心组件
- NEChatUIKit会话聊天UI 组件
- NEContactUIKit通讯录 UI 组件
- NELocalConversationUIKit本地会话列表 UI 组件
- Alamofire网络请求框架
- ComposableArchitecture状态管理 (v1.20.2+)
- CasePaths枚举模式匹配
## 项目结构
```
yana/
├── AppDelegate.swift # 应用程序代理
├── yanaApp.swift # SwiftUI 应用入口
├── ContentView.swift # 主视图
├── Managers/ # 管理器类
├── Models/ # 数据模型
├── Configs/ # 配置文件
└── Assets.xcassets/ # 资源文件
├── yana/ # 应用源代码
│ ├── Info.plist
│ ├── yana-Bridging-Header.h # Objective-C 集成桥接头文件
│ ├── AppDelegate.swift # 应用程序代理
│ ├── yanaApp.swift # SwiftUI 应用入口
├── ContentView.swift # 主视图
│ ├── Managers/ # 管理器类
│ ├── Models/ # 数据模型
│ ├── Configs/ # 配置文件
│ ├── APIs/ # API 相关文件
│ └── Assets.xcassets/ # 资源文件
├── yanaAPITests/ # API 测试目标
└── Pods/ # CocoaPods 依赖
```
## 环境要求
- Xcode 13.0 或更高版本
- iOS 15.6 或更高版本
- iOS 16 或更高版本
- CocoaPods 包管理器
## 安装步骤
@@ -49,10 +62,24 @@ yana/
## 主要功能
- 即时通讯
- 会话管理
- 通讯录管理
- 本地会话列表
- **用户认证**
- 邮箱登录流程(带验证码)
- 多种认证方式
- **即时通讯**
- **会话管理**
- **通讯录管理**
- **本地会话列表**
- **云存储集成**
## UI 组件
项目包含多种自定义 UI 组件:
- 自定义登录按钮
- 底部标签导航
- API 调用加载效果
- Web 视图集成
- 图片预览功能
- 屏幕适配工具
## API 使用
@@ -75,21 +102,27 @@ let response = try await apiService.request(request)
- 项目使用 CocoaPods 管理依赖
- 需要配置网易云信相关密钥
- 最低支持 iOS 15.6 版本
- 最低支持 iOS 16 版本
- 仅支持 iPhone 设备(不支持 iPad、Mac Catalyst 或 Vision Pro
## 开发规范
- 遵循 Swift 官方编码规范
- 使用 SwiftUI 构建用户界面
- 采用 MVVM 架构模式
- 采用 TCA 架构模式
- 支持多语言(包含中文本地化)
## 依赖版本
## 测试
- NIMSDK 相关组件版本10.6.1
- Alamofire最新版本
项目包含专门的 API 测试目标 "yanaAPITests",用于对主应用的 API 功能进行单元测试。
## 开发团队
项目由团队 "EKM7RAGNA6" 开发,测试目标的包标识符为 "com.stupidmonkey.yanaAPITests"。
## 构建配置
- 项目使用动态框架
- 支持 iOS 13.0 及以上版本
- 已配置框架冲突处理脚本
- 支持 iOS 16 及以上版本
- Swift 版本6.0
- 已配置框架冲突处理脚本

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 = minimal;
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 = minimal;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
@@ -545,20 +583,22 @@
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKM7RAGNA6;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yanaAPITests;
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 = NO;
SWIFT_VERSION = 5.0;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/yana.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/yana";
};
@@ -572,7 +612,7 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKM7RAGNA6;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yanaAPITests;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -581,7 +621,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 = 6.0;
TARGETED_DEVICE_FAMILY = 1;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/yana.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/yana";
};
@@ -628,6 +668,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 +684,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"
}
},
{
@@ -33,8 +33,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0",
"version" : "1.2.0"
"revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341",
"version" : "1.2.1"
}
},
{
@@ -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.2"
}
},
{
@@ -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,10 +20,17 @@ enum APIEndpoint: String, CaseIterable {
case ticket = "/oauth/ticket"
case emailGetCode = "/email/getCode" //
case latestDynamics = "/dynamic/square/latestDynamics" //
case tcToken = "/tencent/cos/getToken" // COS Token
case publishFeed = "/dynamic/square/publish" //
case getUserInfo = "/user/get" //
case getMyDynamic = "/dynamic/getMyDynamic"
case updateUser = "/user/v2/update" //
// Web
case userAgreement = "/modules/rule/protocol.html"
case privacyPolicy = "/modules/rule/privacy-wap.html"
var path: String {
return self.rawValue
}
@@ -85,40 +92,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,214 @@ 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)
}
}
// MARK: - User Info API Management
extension UserInfoManager {
///
/// - Parameters:
/// - uid: IDnil使ID
/// - apiService: API
/// - Returns: nil
static func fetchUserInfoFromServer(
uid: String? = nil,
apiService: APIServiceProtocol
) async -> UserInfo? {
// ID
let targetUid: String
if let uid = uid {
targetUid = uid
} else {
// 使ID
guard let currentUid = await getCurrentUserId() else {
debugErrorSync("❌ 无法获取用户信息:当前用户未登录")
return nil
}
targetUid = currentUid
}
debugInfoSync("👤 开始获取用户信息")
debugInfoSync(" 目标UID: \(targetUid)")
do {
let request = UserInfoHelper.createGetUserInfoRequest(uid: targetUid)
let response = try await apiService.request(request)
if response.isSuccess {
debugInfoSync("✅ 用户信息获取成功")
if let userInfo = response.data {
//
await saveUserInfo(userInfo)
debugInfoSync("💾 用户信息已保存到本地")
return userInfo
} else {
debugErrorSync("❌ 用户信息为空")
return nil
}
} else {
debugErrorSync("❌ 获取用户信息失败: \(response.errorMessage)")
return nil
}
} catch {
debugErrorSync("❌ 获取用户信息异常: \(error.localizedDescription)")
return nil
}
}
///
/// - Parameter apiService: API
/// - Returns:
static func refreshCurrentUserInfo(apiService: APIServiceProtocol) async -> Bool {
guard let currentUid = await getCurrentUserId() else {
debugErrorSync("❌ 无法刷新用户信息:当前用户未登录")
return false
}
debugInfoSync("🔄 开始刷新当前用户信息")
debugInfoSync(" 当前UID: \(currentUid)")
if let _ = await fetchUserInfoFromServer(uid: currentUid, apiService: apiService) {
debugInfoSync("✅ 用户信息刷新成功")
return true
} else {
debugErrorSync("❌ 用户信息刷新失败")
return false
}
}
///
/// - Parameters:
/// - uid: ID
/// - apiService: API
/// - forceRefresh: false
/// - Returns: nil
static func getUserInfoWithCache(
uid: String,
apiService: APIServiceProtocol,
forceRefresh: Bool = false
) async -> UserInfo? {
//
if !forceRefresh {
if let cachedUserInfo = await getUserInfo() {
debugInfoSync("📱 使用本地缓存的用户信息")
return cachedUserInfo
}
}
//
debugInfoSync("🌐 从服务器获取用户信息")
return await fetchUserInfoFromServer(uid: uid, apiService: apiService)
}
/// APP
/// - Parameter apiService: API
/// - Returns:
static func autoFetchUserInfoOnAppLaunch(apiService: APIServiceProtocol) async -> Bool {
//
let authStatus = await checkAuthenticationStatus()
guard authStatus.canAutoLogin else {
debugInfoSync("🔍 APP启动用户未登录跳过用户信息获取")
return false
}
//
if let _ = await getUserInfo() {
debugInfoSync("📱 APP启动使用现有用户信息缓存")
return true
}
//
debugInfoSync("🔄 APP启动自动获取用户信息")
return await refreshCurrentUserInfo(apiService: apiService)
}
}
// MARK: -
struct UpdateUserRequest: APIRequestProtocol {
typealias Response = UpdateUserResponse
let avatar: String?
let nick: String?
let uid: Int
let ticket: String
var endpoint: String { APIEndpoint.updateUser.path }
var method: HTTPMethod { .POST }
// queryParameters
var queryParameters: [String: String]? {
var params: [String: String] = [
"uid": String(uid),
"ticket": ticket
]
if let avatar = avatar { params["avatar"] = avatar }
if let nick = nick { params["nick"] = nick }
return params
}
var bodyParameters: [String: Any]? { nil }
}
struct UpdateUserResponse: Codable, Equatable {
let code: Int
let message: String
let data: UserInfo?
}

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,32 +196,37 @@ 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
}
}
// MARK: -
func updateUser(request: UpdateUserRequest) async throws -> UpdateUserResponse {
try await self.request(request)
}
// MARK: - Private Helper Methods
/// URL
@@ -228,7 +238,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 +262,9 @@ struct LiveAPIService: APIServiceProtocol {
queryItems.append(URLQueryItem(name: key, value: "\(value)"))
}
debugInfo("🔐 GET请求签名生成完成 - 基于所有参数统一生成: \(baseParams.pubSign)")
debugInfoSync("🔐 GET请求签名生成完成 - 基于所有参数统一生成: \(baseParams.pubSign)")
} catch {
debugWarn("警告:无法添加基础参数到查询字符串")
debugWarnSync("警告:无法添加基础参数到查询字符串")
}
}
@@ -322,46 +332,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

@@ -4,7 +4,7 @@ import ComposableArchitecture
// MARK: -
///
struct MomentsLatestResponse: Codable, Equatable {
struct MomentsLatestResponse: Codable, Equatable, Sendable {
let code: Int
let message: String
let data: MomentsListData?
@@ -12,18 +12,17 @@ struct MomentsLatestResponse: Codable, Equatable {
}
///
struct MomentsListData: Codable, Equatable {
struct MomentsListData: Codable, Equatable, Sendable {
let dynamicList: [MomentsInfo]
let nextDynamicId: Int
}
///
struct MomentsInfo: Codable, Equatable {
public struct MomentsInfo: Codable, Equatable, Sendable {
let dynamicId: Int
let uid: Int
let nick: String
let avatar: String
let gender: Int
let type: Int
let content: String
let likeCount: Int
@@ -31,52 +30,47 @@ struct MomentsInfo: Codable, Equatable {
let commentCount: Int
let publishTime: Int
let worldId: Int
let squareTop: Int
let topicTop: Int
let newUser: Bool
let defUser: Int
let status: Int
let scene: String
// data.md
let playCount: Int?
let dynamicResList: [MomentsPicture]?
//
let gender: Int?
let squareTop: Int?
let topicTop: Int?
let newUser: Bool?
let defUser: Int?
let scene: String?
let userVipInfoVO: UserVipInfo?
// -
let headwearPic: String?
let headwearEffect: String?
let headwearType: Int?
let headwearName: String?
let headwearId: Int?
// -
let experLevelPic: String?
let charmLevelPic: String?
//
let isCustomWord: Bool?
let labelList: [String]?
// IntBool
var isSquareTop: Bool { squareTop != 0 }
var isTopicTop: Bool { topicTop != 0 }
//
//
var isSquareTop: Bool { (squareTop ?? 0) != 0 }
var isTopicTop: Bool { (topicTop ?? 0) != 0 }
var formattedPublishTime: Date {
Date(timeIntervalSince1970: TimeInterval(publishTime) / 1000.0)
}
}
///
struct MomentsPicture: Codable, Equatable {
let id: Int
let resUrl: String
let format: String
let width: Int
let height: Int
struct MomentsPicture: Codable, Equatable, Sendable {
let id: Int?
let resUrl: String?
let format: String?
let width: Int?
let height: Int?
let resDuration: Int? //
}
/// VIP -
struct UserVipInfo: Codable, Equatable {
struct UserVipInfo: Codable, Equatable, Sendable {
let vipLevel: Int?
let vipName: String?
let vipIcon: String?
@@ -157,4 +151,133 @@ struct LatestDynamicsRequest: APIRequestProtocol {
// Loading
var shouldShowLoading: Bool { true }
var shouldShowError: Bool { true }
}
}
// MARK: - API
///
struct ResListItem: Codable, Equatable {
let resUrl: String
let width: Int
let height: Int
let format: String
}
///
struct PublishFeedRequest: APIRequestProtocol {
typealias Response = PublishFeedResponse
let endpoint: String = APIEndpoint.publishFeed.path
let method: HTTPMethod = .POST
let content: String
let uid: String
let type: String
var pub_sign: String
let resList: [ResListItem]?
var queryParameters: [String: String]? { nil }
var bodyParameters: [String: Any]? {
var params: [String: Any] = [
"content": content,
"uid": uid,
"type": type,
"pub_sign": pub_sign
]
if let resList = resList, !resList.isEmpty {
params["resList"] = resList.map { [
"resUrl": $0.resUrl,
"width": $0.width,
"height": $0.height,
"format": $0.format
] }
}
return params
}
var includeBaseParameters: Bool { true }
var shouldShowLoading: Bool { true }
var shouldShowError: Bool { true }
/// async 线 pub_sign
static func make(content: String, uid: String, type: String = "0", resList: [ResListItem]? = nil) async -> PublishFeedRequest {
let base = await MainActor.run { BaseRequest() }
var mutableBase = base
mutableBase.generateSignature(with: [
"content": content,
"uid": uid,
"type": type
])
return PublishFeedRequest(
content: content,
uid: uid,
type: type,
pub_sign: mutableBase.pubSign,
resList: resList
)
}
///
private init(content: String, uid: String, type: String, pub_sign: String, resList: [ResListItem]?) {
self.content = content
self.uid = uid
self.type = type
self.pub_sign = pub_sign
self.resList = resList
}
}
///
struct PublishFeedResponse: Codable, Equatable {
let code: Int
let message: String
let data: PublishFeedData?
let timestamp: Int?
}
///
struct PublishFeedData: Codable, Equatable {
let dynamicId: Int?
}
// MARK: - API
///
struct MyMomentsResponse: Codable, Equatable, Sendable {
let code: Int
let message: String
let data: [MomentsInfo]?
let timestamp: Int?
}
struct GetMyDynamicRequest: APIRequestProtocol {
typealias Response = MyMomentsResponse
let endpoint: String = APIEndpoint.getMyDynamic.path
let method: HTTPMethod = .POST
let fromUid: Int
let uid: Int
let page: Int
let pageSize: Int
init(fromUid: Int, uid: Int, page: Int = 1, pageSize: Int = 20) {
self.fromUid = fromUid
self.uid = uid
self.page = page
self.pageSize = pageSize
}
var queryParameters: [String: String]? {
[
"fromUid": String(fromUid),
"uid": String(uid),
"page": String(page),
"pageSize": String(pageSize)
]
}
var bodyParameters: [String: Any]? { nil }
var includeBaseParameters: Bool { true }
var shouldShowLoading: Bool { true }
var shouldShowError: Bool { true }
}

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
// ];
}
}
@@ -154,29 +146,209 @@ struct IDLoginData: Codable, Equatable {
// MARK: - User Info Model
struct UserInfo: Codable, Equatable {
let userId: String?
let username: String?
let nickname: String?
let uid: Int?
let userId: String? //
let nick: String?
let nickname: String? //
let avatar: String?
let email: String?
let phone: String?
let status: String?
let createTime: String?
let updateTime: String?
let region: String?
let regionDesc: String?
let gender: Int?
let birth: Int64?
let userDesc: String?
let userLevelVo: UserLevelVo?
let userVipInfoVO: UserVipInfoVO?
let medalsPic: [MedalsPic]?
let userHeadwear: UserHeadwear?
let privatePhoto: [PrivatePhoto]?
let createTime: Int64?
let phoneAreaCode: String?
let erbanNo: Int?
let isCertified: Bool?
let isBindPhone: Bool?
let isBindApple: Bool?
let isBindPasswd: Bool?
let isBindPaymentPwd: Bool?
let banAccount: Bool?
let visitNum: Int?
let fansNum: Int?
let followNum: Int?
let visitHide: Bool?
let visitListView: Bool?
let newUser: Bool?
let defUser: Int?
let platformRole: Int?
let bindType: Int?
let showLimitCharge: Bool?
let uploadGifAvatarPrice: Int?
let hasRegPacket: Bool?
let hasPrettyErbanNo: Bool?
let hasSuperRole: Bool?
let isRechargeUser: Bool?
let isFirstCharge: Bool?
let fromSayHelloChannel: Bool?
let partitionId: Int?
let useStatus: Int?
let micNickColor: String?
let micCircle: String?
let audioCard: AudioCard?
let userInfoSkillVo: UserInfoSkillVo?
let userInfoCardPic: String?
let iosBubbleUrl: String?
let androidBubbleUrl: String?
let status: String? //
let username: String? //
let email: String? //
let phone: String? //
let updateTime: String? //
enum CodingKeys: String, CodingKey {
case uid
case userId = "user_id"
case username
case nick
case nickname
case avatar
case region
case regionDesc
case gender
case birth
case userDesc
case userLevelVo
case userVipInfoVO
case medalsPic
case userHeadwear
case privatePhoto
case createTime
case phoneAreaCode
case erbanNo
case isCertified
case isBindPhone
case isBindApple
case isBindPasswd
case isBindPaymentPwd
case banAccount
case visitNum
case fansNum
case followNum
case visitHide
case visitListView
case newUser
case defUser
case platformRole
case bindType
case showLimitCharge
case uploadGifAvatarPrice
case hasRegPacket
case hasPrettyErbanNo
case hasSuperRole
case isRechargeUser
case isFirstCharge
case fromSayHelloChannel
case partitionId
case useStatus
case micNickColor
case micCircle
case audioCard
case userInfoSkillVo
case userInfoCardPic
case iosBubbleUrl
case androidBubbleUrl
case status
case username
case email
case phone
case status
case createTime = "create_time"
case updateTime = "update_time"
case updateTime
}
}
// MARK: -
struct UserLevelVo: Codable, Equatable {
let experUrl: String?
let charmLevelSeq: Int?
let experLevelName: String?
let charmLevelName: String?
let charmAmount: Int?
let experLevelGrp: String?
let charmUrl: String?
let experLevelSeq: Int?
let experAmount: Int?
let charmLevelGrp: String?
}
struct UserVipInfoVO: Codable, Equatable {
let vipIcon: String?
let nameplateId: Int?
let vipLogo: String?
let userCardBG: String?
let preventKick: Bool?
let preventTrace: Bool?
let preventFollow: Bool?
let micNickColour: String?
let micCircle: String?
let enterRoomEffects: String?
let medalSeat: Int?
let friendNickColour: String?
let visitHide: Bool?
let visitListView: Bool?
let privateChatLimit: Bool?
let nameplateUrl: String?
let roomPicScreen: Bool?
let uploadGifAvatar: Bool?
let expireTime: Int64?
let enterHide: Bool?
let vipLevel: Int?
let vipName: String?
}
struct MedalsPic: Codable, Equatable {
let picUrl: String?
let mp4Url: String?
}
struct UserHeadwear: Codable, Equatable {
let expireTime: Int64?
let renewPrice: Int?
let uid: Int?
let comeFrom: Int?
let labelType: Int?
let limitDesc: String?
let redirectLink: String?
let headwearId: Int?
let buyTime: Int64?
let pic: String?
let used: Bool?
let price: Int?
let originalPrice: Int?
let type: Int?
let days: Int?
let headwearName: String?
let effect: String?
let expireDays: Int?
let status: Int?
}
struct PrivatePhoto: Codable, Equatable {
let seqNo: Int?
let photoUrl: String?
let createTime: Int64?
let review: Bool?
let pid: Int?
}
struct AudioCard: Codable, Equatable {
let uid: Int?
let status: Int?
}
struct UserInfoSkillVo: Codable, Equatable {
let liveTag: Bool?
let liveSkillVoList: [LiveSkillVo]?
}
struct LiveSkillVo: Codable, Equatable {
// API
}
// MARK: - Login Helper
struct LoginHelper {
@@ -186,21 +358,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 +391,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 +464,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 +487,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 +528,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 +561,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,19 +577,82 @@ 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加密失败")
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)
}
}
// MARK: - User Info API Models
///
struct GetUserInfoRequest: APIRequestProtocol {
typealias Response = GetUserInfoResponse
let endpoint = APIEndpoint.getUserInfo.path
let method: HTTPMethod = .GET
let includeBaseParameters = true
let queryParameters: [String: String]?
var bodyParameters: [String: Any]? { nil }
let timeout: TimeInterval = 30.0
let shouldShowLoading: Bool = false // loading
let shouldShowError: Bool = false //
///
/// - Parameter uid: ID
init(uid: String) {
self.queryParameters = [
"uid": uid
]
}
}
///
struct GetUserInfoResponse: Codable, Equatable {
let code: Int?
let message: String?
let timestamp: Int64?
let data: UserInfo?
///
var isSuccess: Bool {
return code == 200
}
///
var errorMessage: String {
return message ?? "获取用户信息失败,请重试"
}
}
// MARK: - User Info Helper
struct UserInfoHelper {
///
/// - Parameter uid: ID
/// - Returns: API
static func createGetUserInfoRequest(uid: String) -> GetUserInfoRequest {
return GetUserInfoRequest(uid: uid)
}
///
/// - Parameter uid: ID
static func debugGetUserInfoRequest(uid: String) {
debugInfoSync("👤 获取用户信息请求调试")
debugInfoSync(" UID: \(uid)")
debugInfoSync(" Endpoint: /user/get")
debugInfoSync(" Method: GET")
debugInfoSync(" Parameters: uid=\(uid)")
}
}

View File

@@ -1,92 +1,330 @@
## 📝 给继任者的详细工作交接说明
亲爱的继任者,我刚刚为这个 **yana iOS 项目**完成了一个完整的图片缓存优化工作。以下是关键信息:
### 🎯 已完成的核心工作
1. **解决了重大性能问题**
- **问题**FeedView 中图片每次滚动都重新加载,用户体验极差
- **原因**AsyncImage 缓存不足没有预加载机制cell 重用时图片丢失
2. **创建了企业级图片缓存系统**
- **文件**`yana/Utils/ImageCacheManager.swift`
- **功能**:三层缓存(内存+磁盘+网络)+ 智能预加载 + 任务去重
3. **优化了 FeedView 架构**
- **文件**`yana/Views/FeedView.swift`
- **改进**:使用 `CachedAsyncImage` 替代 `AsyncImage`,添加预加载机制
### ✅ 技术架构详情
#### **ImageCacheManager 核心特性**
- **内存缓存**NSCache50MB 限制100张图片
- **磁盘缓存**Documents/ImageCache100MB 限制SHA256 文件名
- **预加载**当前位置前后2个动态的所有图片
- **任务去重**:同一图片多次请求共享下载任务
#### **CachedAsyncImage 组件**
- **缓存优先级**:内存 → 磁盘 → 网络
- **异步加载**:不阻塞主线程
- **SwiftUI 兼容**:完全兼容现有 AsyncImage 语法
#### **FeedView 优化**
- **OptimizedDynamicCardView**:使用缓存图片组件
- **OptimizedImageGrid**:优化的图片网格
- **智能预加载**onAppear 时触发相邻内容预加载
### 🔧 重要的技术细节
1. **哈希冲突解决**
- 项目中已有 `String+MD5.swift` 文件
- 使用现有的 `sha256()``md5()` 方法,避免重复声明
2. **兼容性处理**
- iOS 13+:使用 CryptoKit 的 SHA256
- iOS 13以下使用 CommonCrypto 的 MD5
3. **Bridging Header 配置**
- 已添加 `#import <CommonCrypto/CommonCrypto.h>`
### 🚀 性能提升效果
| 优化前 | 优化后 |
|--------|--------|
| ❌ 每次滚动重新加载图片 | ✅ 缓存图片瞬间显示 |
| ❌ 频繁网络请求 | ✅ 大幅减少网络请求 |
| ❌ 用户体验差 | ✅ 流畅滚动体验 |
### 📋 项目上下文回顾
1. **API 功能已完成**
- 动态内容 API 集成完毕DynamicsModels.swift + FeedFeature.swift
- 数据解析问题已解决(类型匹配修复)
- TCA 架构状态管理正常工作
2. **当前状态**
- ✅ 编译成功
- ✅ API 数据正常显示
- ✅ 图片缓存系统就绪
- ✅ 性能优化完成
### 🔍 可能的后续工作
用户可能需要:
1. **功能扩展**:点赞、评论、分享等交互功能
2. **UI 优化**:更丰富的动画效果、主题切换
3. **性能监控**:添加缓存命中率统计、内存使用监控
4. **错误处理**:网络异常时的重试机制优化
### 💡 重要提醒
- **用户是中文开发者**:需要中文回复,使用 Chain-of-Thought 思考过程
- **项目基于 iOS 15.6**:注意兼容性要求
- **TCA 架构**:遵循项目现有的 TCA 模式
- **图片缓存系统**:已经是生产就绪的企业级方案,无需重构
### 🎉 工作成果
这次优化彻底解决了图片重复加载的性能问题,用户现在可以享受流畅的滚动体验。缓存系统设计完善,支持大规模图片内容,为后续功能扩展奠定了坚实基础。
**继任者,你接手的是一个功能完整、性能优秀的动态内容展示系统!** 🚀
祝你工作顺利!
📦 Response Data:
{
"code" : 200,
"message" : "success",
"data" : [
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 267,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753182147000,
"status" : 0,
"content" : "我"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"dynamicResList" : [
{
"height" : 3024,
"id" : 443,
"width" : 4032,
"resUrl" : "https:\/\/image.molistar.xyz\/images\/C32EB0F8-CBF5-4F4B-8114-C3C7E1AF192F.jpg",
"format" : "jpeg"
}
],
"worldId" : -1,
"likeCount" : 0,
"type" : 2,
"dynamicId" : 266,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753181890000,
"status" : 0,
"content" : ""
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"dynamicResList" : [
{
"height" : 828,
"id" : 442,
"width" : 828,
"resUrl" : "https:\/\/image.molistar.xyz\/images\/1E8FE811-1989-4337-BDEE-63554F92A686.jpg",
"format" : "jpeg"
}
],
"worldId" : -1,
"likeCount" : 0,
"type" : 2,
"dynamicId" : 265,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753181143000,
"status" : 0,
"content" : "大"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"dynamicResList" : [
{
"height" : 3024,
"id" : 440,
"width" : 4032,
"resUrl" : "https:\/\/https:\/\/image.molistar.xyz\/images\/DF8E655B-2F63-4B34-90B3-13C8A812245C.jpg",
"format" : "jpeg"
},
{
"height" : 1792,
"id" : 441,
"width" : 828,
"resUrl" : "https:\/\/https:\/\/image.molistar.xyz\/images\/D869C761-59CC-4E6B-BB2A-74F87D4A4979.jpg",
"format" : "jpeg"
}
],
"worldId" : -1,
"likeCount" : 0,
"type" : 2,
"dynamicId" : 264,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753180835000,
"status" : 0,
"content" : "好"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"dynamicResList" : [
{
"height" : 1792,
"id" : 438,
"width" : 828,
"resUrl" : "https:\/\/image.molistar.xyz\/image\/9d5c8e10eb0d228a26ec4e8d58b41c38.jpeg",
"format" : "jpeg"
},
{
"height" : 1792,
"id" : 439,
"width" : 828,
"resUrl" : "https:\/\/image.molistar.xyz\/image\/9ab8dff9f5ffbb4d65998822dd126794.jpeg",
"format" : "jpeg"
}
],
"worldId" : -1,
"likeCount" : 0,
"type" : 2,
"dynamicId" : 263,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753180130000,
"status" : 0,
"content" : "猜猜猜"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 262,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753168392000,
"status" : 0,
"content" : "他哥哥哥哥哥哥"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 261,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753168329000,
"status" : 0,
"content" : "一直以为自己是真的"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 260,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753167661000,
"status" : 0,
"content" : "在意那些是自己一"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 259,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753166596000,
"status" : 0,
"content" : "哈哈我觉得这个世界"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 258,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753166592000,
"status" : 0,
"content" : "哈哈我觉得这个世界"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 257,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753166298000,
"status" : 0,
"content" : "哈哈哈哈更"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 256,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753165531000,
"status" : 0,
"content" : "不不不不不"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 255,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1753156105000,
"status" : 0,
"content" : "你有什么"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 254,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1752650142000,
"status" : 0,
"content" : "igvigciycoyvcoyvyovoy突袭陶瓷陶瓷陶瓷陶瓷陶瓷陶瓷陶瓷"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 247,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1742801936000,
"status" : 0,
"content" : "vicigiigohvhveerr让你表弟姐姐接你吧多半都不\n\n\n\n代表脯肉吧多半日品牌狠批人品很频频频频噢……在一起的时候就是一次又来了就可以😌我们一起加油呀我要好好学习📑你好开心🥳、在一起的时候就像一只手握在一起\n"
},
{
"isLike" : false,
"uid" : 3184,
"playCount" : 0,
"worldId" : -1,
"likeCount" : 0,
"type" : 0,
"dynamicId" : 206,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1726834519000,
"status" : 1,
"content" : "爸爸不会后悔就"
},
{
"isLike" : true,
"uid" : 3184,
"playCount" : 0,
"dynamicResList" : [
{
"height" : 500,
"id" : 355,
"width" : 500,
"resUrl" : "https:\/\/image.pekolive.com\/image\/0c091078d01305f3144ab3352a9fe21a.jpeg",
"format" : "jpeg"
},
{
"height" : 328,
"id" : 356,
"width" : 440,
"resUrl" : "https:\/\/image.pekolive.com\/image\/8cdbbab3a0e6df7389f2d2671ee48bc3.jpeg",
"format" : "jpeg"
}
],
"worldId" : -1,
"likeCount" : 1,
"type" : 2,
"dynamicId" : 205,
"nick" : "hansome",
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
"commentCount" : 0,
"publishTime" : 1726834050000,
"status" : 1,
"content" : "寄件人、一直以为自己是什么地方玩不"
}
],
"timestamp" : 1753256129947
}
=====================================

View File

@@ -2,82 +2,10 @@ import UIKit
//import NIMSDK
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// UserDefaults Keychain
DataMigrationManager.performStartupMigration()
private func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
//
UserInfoManager.preloadCache()
//
// NetworkManager.shared.networkStatusChanged = { status in
// print("🌍 \(status)")
// }
#if DEBUG
// 🔍 DESOC
// print("🔐 使OCDES")
// DESEncryptOCTest.runInAppDelegate()
// - 使
// let testURL = URL(string: "http://192.168.10.211:8080/oauth/token")!
// var request = URLRequest(url: testURL)
// request.httpMethod = "POST"
// request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// request.setValue("application/json", forHTTPHeaderField: "Accept")
// request.setValue("zh-Hant", forHTTPHeaderField: "Accept-Language")
//
// //
// let testParameters: [String: Any] = [
// "ispType": "65535",
// "phone": "3+TbIQYiwIk=",
// "netType": 2,
// "channel": "molistar_enterprise",
// "version": "20.20.61",
// "pub_sign": "2E7C50AA17A20B32A0023F20B7ECE108",
// "osVersion": "16.4",
// "deviceId": "b715b75715e3417c9c70e72bbe502c6c",
// "grant_type": "password",
// "os": "iOS",
// "app": "youmi",
// "password": "nTW/lEgupIQ=",
// "client_id": "erban-client",
// "lang": "zh-Hant-CN",
// "client_secret": "uyzjdhds",
// "Accept-Language": "zh-Hant",
// "model": "iPhone XR",
// "appVersion": "1.0.0"
// ]
//
// do {
// let jsonData = try JSONSerialization.data(withJSONObject: testParameters, options: .prettyPrinted)
// request.httpBody = jsonData
//
// print("🛠 URLSession")
// print("📍 : \(testURL.absoluteString)")
// print("📦 : \(String(data: jsonData, encoding: .utf8) ?? "")")
//
// URLSession.shared.dataTask(with: request) { data, response, error in
// DispatchQueue.main.async {
// let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
// let responseString = data != nil ? String(data: data!, encoding: .utf8) ?? "" : ""
//
// print("""
// === ===
// 🔗 URL: \(testURL.absoluteString)
// 📊 : \(statusCode)
// : \(error?.localizedDescription ?? "")
// 📦 : \(data?.count ?? 0) bytes
// 📄 : \(responseString)
// ==================
// """)
// }
// }.resume()
// } catch {
// print(" JSON: \(error.localizedDescription)")
// }
#endif
await UserInfoManager.preloadCache()
// NIMConfigurationManager.setupNimSDK()

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,243 @@
import Foundation
import ComposableArchitecture
@Reducer
struct AppSettingFeature {
@ObservableState
struct State: Equatable {
var nickname: String = ""
var avatarURL: String? = nil
var userInfo: UserInfo? = nil
var isLoadingUserInfo: Bool = false
var userInfoError: String? = nil
// WebView
var showUserAgreement: Bool = false
var showPrivacyPolicy: Bool = false
// /
var isUploadingAvatar: Bool = false
var avatarUploadError: String? = nil
var isEditingNickname: Bool = false
var nicknameInput: String = ""
var isUpdatingUser: Bool = false
var updateUserError: String? = nil
// userInfoavatarURLnicknameinit
init(nickname: String = "", avatarURL: String? = nil, userInfo: UserInfo? = nil) {
self.nickname = nickname
self.avatarURL = avatarURL
self.userInfo = userInfo
}
// TCA
var showImagePicker: Bool = false
}
enum Action: Equatable {
case onAppear
case editNicknameTapped
case logoutTapped
case dismissTapped
//
case loadUserInfo
case userInfoResponse(Result<UserInfo, APIError>)
// WebView
case personalInfoPermissionsTapped
case helpTapped
case clearCacheTapped
case checkUpdatesTapped
case aboutUsTapped
// WebView
case userAgreementDismissed
case privacyPolicyDismissed
// /
case avatarTapped
case avatarSelected(Data)
case avatarUploadResult(Result<String, APIError>)
case nicknameEditConfirmed(String)
case updateUser(Result<UpdateUserResponse, APIError>)
case nicknameInputChanged(String)
case nicknameEditAlert(Bool)
case testPushTapped
// TCA
case setShowImagePicker(Bool)
}
@Dependency(\.apiService) var apiService
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .onAppear:
return .send(.loadUserInfo)
case .editNicknameTapped:
//
return .none
case .logoutTapped:
//
return .run { send in
await UserInfoManager.clearAllAuthenticationData()
// FeatureMainFeature
// .noneMainFeatureAppSettingFeature.Action.logoutTapped
}
case .dismissTapped:
// MainFeature navigationPath pop
return .none
case .loadUserInfo:
state.isLoadingUserInfo = true
state.userInfoError = nil
return .run { send in
// do {
if let userInfo = await UserInfoManager.getUserInfo() {
await send(.userInfoResponse(.success(userInfo)))
} else {
await send(.userInfoResponse(.failure(APIError.custom("用户信息不存在"))))
}
// } catch {
// let apiError: APIError
// if let error = error as? APIError {
// apiError = error
// } else {
// apiError = APIError.custom(error.localizedDescription)
// }
// await send(.userInfoResponse(.failure(apiError)))
// }
}
case let .userInfoResponse(.success(userInfo)):
state.userInfo = userInfo
state.nickname = userInfo.nick ?? ""
state.avatarURL = userInfo.avatar //
state.isLoadingUserInfo = false
return .none
case let .userInfoResponse(.failure(error)):
state.userInfoError = error.localizedDescription
state.isLoadingUserInfo = false
return .none
case .personalInfoPermissionsTapped:
state.showPrivacyPolicy = true
return .none
case .helpTapped:
state.showUserAgreement = true
return .none
case .clearCacheTapped:
//
return .none
case .checkUpdatesTapped:
//
return .none
case .aboutUsTapped:
//
return .none
case .userAgreementDismissed:
state.showUserAgreement = false
return .none
case .privacyPolicyDismissed:
state.showPrivacyPolicy = false
return .none
case .avatarTapped:
//
return .none
case let .avatarSelected(imageData):
state.isUploadingAvatar = true
state.avatarUploadError = nil
return .run { [avatarData = imageData] send in
guard let uiImage = UIImage(data: avatarData) else {
await send(.avatarUploadResult(.failure(APIError.custom("图片格式错误"))))
return
}
//
if let url = await COSManager.shared.uploadUIImage(uiImage, apiService: apiService) {
await send(.avatarUploadResult(.success(url)))
} else {
await send(.avatarUploadResult(.failure(APIError.custom("头像上传失败"))))
}
}
case let .avatarUploadResult(.success(url)):
state.isUploadingAvatar = false
// updateUser API avatar
state.isUpdatingUser = true
state.updateUserError = nil
guard let userInfo = state.userInfo else { return .none }
// avatarURLUI
state.avatarURL = url
return .run { send in
let ticket = await UserInfoManager.getCurrentUserTicket() ?? ""
let req = UpdateUserRequest(avatar: url, nick: nil, uid: userInfo.uid ?? 0, ticket: ticket)
do {
let resp: UpdateUserResponse = try await apiService.request(req)
await send(.updateUser(.success(resp)))
} catch {
let apiError = error as? APIError ?? APIError.custom(error.localizedDescription)
await send(.updateUser(.failure(apiError)))
}
}
case let .avatarUploadResult(.failure(error)):
state.isUploadingAvatar = false
state.avatarUploadError = error.localizedDescription
return .none
case .nicknameEditAlert(let show):
state.isEditingNickname = show
state.nicknameInput = state.nickname
return .none
case .nicknameInputChanged(let text):
state.nicknameInput = String(text.prefix(15))
return .none
case .nicknameEditConfirmed(let newNick):
guard let userInfo = state.userInfo else { return .none }
state.isUpdatingUser = true
state.updateUserError = nil
return .run { send in
let ticket = await UserInfoManager.getCurrentUserTicket() ?? ""
let req = UpdateUserRequest(avatar: nil, nick: newNick, uid: userInfo.uid ?? 0, ticket: ticket)
do {
let resp: UpdateUserResponse = try await apiService.request(req)
await send(.updateUser(.success(resp)))
} catch {
let apiError = error as? APIError ?? APIError.custom(error.localizedDescription)
await send(.updateUser(.failure(apiError)))
}
}
case .updateUser(.success(_)):
state.isUpdatingUser = false
// resp.data userinfo1
if let uid = state.userInfo?.uid {
return .run { send in
try? await Task.sleep(nanoseconds: 1_000_000_000)
if let newUser = await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
await send(.userInfoResponse(.success(newUser)))
} else {
await send(.userInfoResponse(.failure(APIError.custom("获取最新用户信息失败"))))
}
}
}
state.isEditingNickname = false
return .none
case let .updateUser(.failure(error)):
state.isUpdatingUser = false
state.updateUserError = error.localizedDescription
return .none
case .testPushTapped:
return .none
case .setShowImagePicker(let show):
state.showImagePicker = show
return .none
}
}
}

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 = APIEndpoint.publishFeed.path
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,26 @@ 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)
//
debugInfoSync("🔄 邮箱登录成功,开始获取用户信息")
if let _ = await UserInfoManager.fetchUserInfoFromServer(
uid: accountModel.uid,
apiService: apiService
) {
debugInfoSync("✅ 用户信息获取成功")
} else {
debugErrorSync("❌ 用户信息获取失败,但不影响登录流程")
}
}
case .loginResponse(.failure(let error)):
state.isLoading = false
state.loginStep = .failed
if let apiError = error as? APIError {
state.errorMessage = apiError.localizedDescription
} else {
@@ -177,6 +196,7 @@ struct EMailLoginFeature {
state.isCodeLoading = false
state.errorMessage = nil
state.isCodeSent = false
state.loginStep = .initial
return .none
}
}

View File

@@ -0,0 +1,214 @@
import Foundation
import ComposableArchitecture
import SwiftUI
import PhotosUI // PhotosUI
@Reducer
struct EditFeedFeature {
@ObservableState
struct State: Equatable {
var content: String = ""
var isLoading: Bool = false
var errorMessage: String? = nil
var shouldDismiss: Bool = false
var selectedImages: [PhotosPickerItem] = []
var processedImages: [UIImage] = []
var canAddMoreImages: Bool {
processedImages.count < 9
}
var canPublish: Bool {
(!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !processedImages.isEmpty) && !isLoading && !isUploadingImages
}
//
var isUploadingImages: Bool = false
var imageUploadProgress: Double = 0.0 // 0.0~1.0
var uploadedResList: [ResListItem] = []
// EquatableselectedImagesPhotosPickerItemEquatable
static func == (lhs: State, rhs: State) -> Bool {
lhs.content == rhs.content &&
lhs.isLoading == rhs.isLoading &&
lhs.errorMessage == rhs.errorMessage &&
lhs.shouldDismiss == rhs.shouldDismiss &&
lhs.processedImages == rhs.processedImages &&
lhs.selectedImages.count == rhs.selectedImages.count &&
lhs.isUploadingImages == rhs.isUploadingImages &&
lhs.imageUploadProgress == rhs.imageUploadProgress &&
lhs.uploadedResList == rhs.uploadedResList
}
}
enum Action {
case contentChanged(String)
case publishButtonTapped
case publishResponse(Result<PublishFeedResponse, Error>)
case clearError
case dismissView
case clearDismissFlag
// Action
case photosPickerItemsChanged([PhotosPickerItem])
case processPhotosPickerItems([PhotosPickerItem])
case updateProcessedImages([UIImage])
case removeImage(Int)
// Action
case uploadImages
case uploadImagesResponse(Result<[ResListItem], Error>)
//
case updateImageUploadProgress(Double)
}
@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
return .none
case .publishButtonTapped:
guard state.canPublish else {
state.errorMessage = "请输入内容"
return .none
}
//
if !state.processedImages.isEmpty {
state.isUploadingImages = true
state.imageUploadProgress = 0.0
state.errorMessage = nil
return .send(.uploadImages)
} else {
state.isLoading = true
state.errorMessage = nil
return .run { [content = state.content] send in
let userId = await UserInfoManager.getCurrentUserId() ?? ""
let type = content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "2" : "0"
let request = await PublishFeedRequest.make(
content: content.trimmingCharacters(in: .whitespacesAndNewlines),
uid: userId,
type: type,
resList: nil
)
do {
let response = try await apiService.request(request)
await send(.publishResponse(.success(response)))
} catch {
await send(.publishResponse(.failure(error)))
}
}
}
case .uploadImages:
let images = state.processedImages
return .run { send in
var resList: [ResListItem] = []
for (idx, image) in images.enumerated() {
guard let data = image.jpegData(compressionQuality: 0.9) else { continue }
if let url = await COSManager.shared.uploadImage(data, apiService: apiService),
let cgImage = image.cgImage {
let width = cgImage.width
let height = cgImage.height
let format = "jpeg"
let item = ResListItem(resUrl: url, width: width, height: height, format: format)
resList.append(item)
}
//
await MainActor.run {
send(.updateImageUploadProgress(Double(idx + 1) / Double(images.count)))
}
}
if resList.count == images.count {
await send(.uploadImagesResponse(.success(resList)))
} else {
await send(.uploadImagesResponse(.failure(NSError(domain: "COSUpload", code: -1, userInfo: [NSLocalizedDescriptionKey: "部分图片上传失败"])) ))
}
}
case .uploadImagesResponse(let result):
state.isUploadingImages = false
state.imageUploadProgress = 1.0
switch result {
case .success(let resList):
state.uploadedResList = resList
state.isLoading = true
return .run { [content = state.content, resList] send in
let userId = await UserInfoManager.getCurrentUserId() ?? ""
// type: 2 /
let type = resList.isEmpty ? "0" : (content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "2" : "2")
let request = await PublishFeedRequest.make(
content: content.trimmingCharacters(in: .whitespacesAndNewlines),
uid: userId,
type: type,
resList: resList
)
do {
let response = try await apiService.request(request)
await send(.publishResponse(.success(response)))
} catch {
await send(.publishResponse(.failure(error)))
}
}
case .failure(let error):
state.errorMessage = error.localizedDescription
return .none
}
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:
state.shouldDismiss = true
return .none
case .clearDismissFlag:
state.shouldDismiss = false
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 .updateImageUploadProgress(let progress):
state.imageUploadProgress = progress
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,144 @@
import Foundation
import ComposableArchitecture
@Reducer
struct FeedListFeature {
@Dependency(\.apiService) var apiService
struct State: Equatable {
var isFirstLoad: Bool = true
var feeds: [Feed] = [] // feed
var isLoading: Bool = false
var error: String? = nil
var isEditFeedPresented: Bool = false // EditFeedView
//
var moments: [MomentsInfo] = []
//
var isLoaded: Bool = false
//
var currentPage: Int = 1
var hasMore: Bool = true
var isLoadingMore: Bool = false
}
enum Action: Equatable {
case onAppear
case reload
case loadMore
case loadMoreResponse(TaskResult<MomentsLatestResponse>)
case editFeedButtonTapped // add
case editFeedDismissed //
case testButtonTapped //
//
case fetchFeeds
case fetchFeedsResponse(TaskResult<MomentsLatestResponse>)
// Action
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .onAppear:
guard state.isFirstLoad else { return .none }
state.isFirstLoad = false
return .send(.fetchFeeds)
case .reload:
//
state.isLoading = true
state.error = nil
state.currentPage = 1
state.hasMore = true
state.isLoaded = true
return .run { [apiService] send in
await send(.fetchFeedsResponse(TaskResult {
let request = LatestDynamicsRequest(dynamicId: "", pageSize: 20, types: [.text, .picture])
return try await apiService.request(request)
}))
}
case .loadMore:
//
guard state.hasMore, !state.isLoadingMore, !state.isLoading else { return .none }
state.isLoadingMore = true
let lastDynamicId: String = {
if let last = state.moments.last {
return String(last.dynamicId)
} else {
return ""
}
}()
return .run { [apiService] send in
await send(.loadMoreResponse(TaskResult {
let request = LatestDynamicsRequest(dynamicId: lastDynamicId, pageSize: 20, types: [.text, .picture])
return try await apiService.request(request)
}))
}
case let .loadMoreResponse(.success(response)):
state.isLoadingMore = false
if let list = response.data?.dynamicList {
if list.isEmpty {
state.hasMore = false
} else {
state.moments.append(contentsOf: list)
state.currentPage += 1
state.hasMore = (list.count >= 20)
}
state.error = nil
} else {
state.hasMore = false
state.error = response.message
}
return .none
case let .loadMoreResponse(.failure(error)):
state.isLoadingMore = false
state.hasMore = false
state.error = error.localizedDescription
return .none
case .fetchFeeds:
state.isLoading = true
state.error = nil
// API
return .run { [apiService] send in
await send(.fetchFeedsResponse(TaskResult {
let request = LatestDynamicsRequest(dynamicId: "", pageSize: 20, types: [.text, .picture])
return try await apiService.request(request)
}))
}
case let .fetchFeedsResponse(.success(response)):
state.isLoading = false
if let list = response.data?.dynamicList {
state.moments = list
state.error = nil
state.currentPage = 1
state.hasMore = (list.count >= 20)
} else {
state.moments = []
state.error = response.message
state.hasMore = false
}
return .none
case let .fetchFeedsResponse(.failure(error)):
state.isLoading = false
state.moments = []
state.error = error.localizedDescription
state.hasMore = false
return .none
case .editFeedButtonTapped:
state.isEditFeedPresented = true
return .none
case .editFeedDismissed:
state.isEditFeedPresented = false
return .none
case .testButtonTapped:
debugInfoSync("[LOG] FeedListFeature testButtonTapped")
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

@@ -1,20 +1,23 @@
import Foundation
import ComposableArchitecture
@Reducer
struct HomeFeature {
@ObservableState
struct HomeFeature: Reducer {
enum Route: Equatable {
case createFeed
}
struct State: Equatable {
var isInitialized = false
var userInfo: UserInfo?
var accountModel: AccountModel?
var error: String?
//
var isSettingPresented = false
var settingState = SettingFeature.State()
var feedState = FeedFeature.State()
var meDynamic = MeDynamicFeature.State(uid: 0)
var isLoggedOut = false
var route: Route? = nil
}
@CasePathable
enum Action: Equatable {
case onAppear
case loadUserInfo
@@ -23,68 +26,67 @@ struct HomeFeature {
case accountModelLoaded(AccountModel?)
case logoutTapped
case logout
// actions
case settingDismissed
case setting(SettingFeature.Action)
case feed(FeedFeature.Action)
case meDynamic(MeDynamicFeature.Action)
case logoutCompleted
case showCreateFeed
case createFeedDismissed
}
var body: some ReducerOf<Self> {
Scope(state: \.settingState, action: \.setting) {
SettingFeature()
Scope(state: \.feedState, action: \.feed) {
FeedFeature()
}
Scope(state: \.meDynamic, action: \.meDynamic) {
MeDynamicFeature()
}
Reduce { state, action in
switch action {
case .onAppear:
guard !state.isInitialized else { return .none }
state.isInitialized = true
return .concatenate(
.send(.loadUserInfo),
.send(.loadAccountModel)
)
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
state.meDynamic.uid = userInfo?.uid ?? 0
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
return .none
case .logoutTapped:
return .send(.logout)
case .logout:
//
UserInfoManager.clearAllAuthenticationData()
//
NotificationCenter.default.post(name: .homeLogout, object: nil)
return .run { send in
await UserInfoManager.clearAllAuthenticationData()
await send(.logoutCompleted)
}
case .logoutCompleted:
state.isLoggedOut = true
return .none
case .settingDismissed:
state.isSettingPresented = false
case .feed:
return .none
case .setting:
// reducer
case .meDynamic:
return .none
case .showCreateFeed:
state.route = .createFeed
return .none
case .createFeedDismissed:
state.route = nil
return .none
}
}
}
}
// MARK: - Notification Extension
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,86 @@ 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
debugInfoSync("✅ ID 登录完整流程成功")
debugInfoSync("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
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
if let ticket = response.ticket, let oldAccountModel = state.accountModel {
let newAccountModel = oldAccountModel.withTicket(ticket)
state.accountModel = newAccountModel
return .run { _ in
await UserInfoManager.saveAccountModel(newAccountModel)
// AccountModel
UserInfoManager.saveAccountModel(accountModel)
// Ticket
NotificationCenter.default.post(name: .ticketSuccess, object: nil)
} else {
debugError("❌ AccountModel 不存在,无法保存 ticket")
state.ticketError = "内部错误:账户信息丢失"
state.loginStep = .failed
//
debugInfoSync("🔄 登录成功,开始获取用户信息")
if let _ = await UserInfoManager.fetchUserInfoFromServer(
uid: newAccountModel.uid,
apiService: apiService
) {
debugInfoSync("✅ 用户信息获取成功")
} else {
debugErrorSync("❌ 用户信息获取失败,但不影响登录流程")
}
}
} 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,124 @@
import Foundation
import ComposableArchitecture
import CasePaths
@Reducer
struct MainFeature {
enum Tab: Int, Equatable, CaseIterable {
case feed, other
}
@ObservableState
struct State: Equatable {
var selectedTab: Tab = .feed
var feedList: FeedListFeature.State = .init()
var me: MeFeature.State = .init()
var accountModel: AccountModel? = nil
// State
var navigationPath: [Destination] = []
var appSettingState: AppSettingFeature.State? = nil
//
var isLoggedOut: Bool = false
}
//
enum Destination: Hashable, Codable, CaseIterable {
case appSetting
case testView
}
@CasePathable
enum Action: Equatable {
case onAppear
case selectTab(Tab)
case feedList(FeedListFeature.Action)
case me(MeFeature.Action)
case accountModelLoaded(AccountModel?)
//
case navigationPathChanged([Destination])
case appSettingButtonTapped
case appSettingAction(AppSettingFeature.Action)
//
case logout
}
var body: some ReducerOf<Self> {
Scope(state: \ .feedList, action: \ .feedList) {
FeedListFeature()
}
Scope(state: \ .me, action: \ .me) {
MeFeature()
}
Reduce { state, action in
switch action {
case .onAppear:
return .run { send in
let accountModel = await UserInfoManager.getAccountModel()
await send(.accountModelLoaded(accountModel))
}
case .selectTab(let tab):
state.selectedTab = tab
state.navigationPath = []
if tab == .other, let uidStr = state.accountModel?.uid, let uid = Int(uidStr), uid > 0 {
if state.me.uid != uid {
state.me.uid = uid
state.me.isFirstLoad = true //
}
return .send(.me(.onAppear))
}
return .none
case .feedList(.testButtonTapped):
state.navigationPath.append(.testView)
return .none
case .feedList:
return .none
case let .accountModelLoaded(accountModel):
state.accountModel = accountModel
return .none
case .me(.settingButtonTapped):
// push
let userInfo = state.me.userInfo
let avatarURL = userInfo?.avatar
let nickname = userInfo?.nick ?? ""
state.appSettingState = AppSettingFeature.State(nickname: nickname, avatarURL: avatarURL, userInfo: userInfo)
state.navigationPath.append(.appSetting)
return .none
case .me:
return .none
case .navigationPathChanged(let newPath):
// pop settingState
state.navigationPath = newPath
return .none
case .appSettingButtonTapped:
let userInfo = state.me.userInfo
let avatarURL = userInfo?.avatar
let nickname = userInfo?.nick ?? ""
state.appSettingState = AppSettingFeature.State(nickname: nickname, avatarURL: avatarURL, userInfo: userInfo)
state.navigationPath.append(.appSetting)
return .none
case .appSettingAction(.logoutTapped):
//
state.isLoggedOut = true
return .none
case .appSettingAction(.dismissTapped):
// pop
if !state.navigationPath.isEmpty {
state.navigationPath.removeLast()
}
return .none
case .appSettingAction(.updateUser(.success)):
// Me
return .send(.me(.refresh))
case .appSettingAction:
return .none
case .logout:
// SplashView/SplashFeature
return .none
}
}
//
.ifLet(\ .appSettingState, action: \ .appSettingAction) {
AppSettingFeature()
}
}
}

View File

@@ -0,0 +1,91 @@
import Foundation
import ComposableArchitecture
@Reducer
struct MeDynamicFeature: Reducer {
struct State: Equatable {
var uid: Int
var dynamics: [MomentsInfo] = []
var page: Int = 1
var pageSize: Int = 20
var isLoading: Bool = false
var isRefreshing: Bool = false
var isLoadingMore: Bool = false
var hasMore: Bool = true
var error: String?
var isInitialized: Bool = false //
}
enum Action: Equatable {
case onAppear
case refresh
case loadMore
case fetchResponse(Result<MyMomentsResponse, APIError>)
}
@Dependency(\.apiService) var apiService
func reduce(into state: inout State, action: Action) async -> Effect<Action> {
switch action {
case .onAppear:
guard !state.isInitialized else { return .none }
state.isInitialized = true
state.page = 1
state.dynamics = []
state.hasMore = true
state.isLoading = true
state.error = nil
return fetchDynamics(uid: state.uid, page: 1, pageSize: state.pageSize)
case .refresh:
state.page = 1
state.hasMore = true
state.isRefreshing = true
state.error = nil
state.isInitialized = false //
return fetchDynamics(
uid: state.uid,
page: 1,
pageSize: state.pageSize
)
case .loadMore:
guard !state.isLoadingMore, state.hasMore else { return .none }
state.isLoadingMore = true
return fetchDynamics(uid: state.uid, page: state.page + 1, pageSize: state.pageSize)
case let .fetchResponse(result):
state.isLoading = false
state.isRefreshing = false
state.isLoadingMore = false
switch result {
case let .success(resp):
let newDynamics = resp.data ?? []
if state.page == 1 {
state.dynamics = newDynamics
} else {
state.dynamics += newDynamics
}
state.hasMore = newDynamics.count == state.pageSize
if state.hasMore { state.page += 1 }
state.error = nil
case let .failure(error):
state.error = error.localizedDescription
}
return .none
}
}
private func fetchDynamics(uid: Int, page: Int, pageSize: Int) -> Effect<Action> {
let apiService = self.apiService
return .run { send in
do {
let req = GetMyDynamicRequest(fromUid: uid, uid: uid, page: page, pageSize: pageSize)
let resp = try await apiService.request(req)
await send(.fetchResponse(.success(resp)))
} catch {
await send(.fetchResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
}
}
}
}

View File

@@ -0,0 +1,113 @@
import Foundation
import ComposableArchitecture
@Reducer
struct MeFeature {
@Dependency(\.apiService) var apiService
struct State: Equatable {
var isFirstLoad: Bool = true
var userInfo: UserInfo?
var isLoadingUserInfo: Bool = false
var userInfoError: String?
var moments: [MomentsInfo] = []
var isLoadingMoments: Bool = false
var momentsError: String?
var hasMore: Bool = true
var isLoadingMore: Bool = false
var isRefreshing: Bool = false
var page: Int = 1
var pageSize: Int = 20
var uid: Int = 0
}
enum Action: Equatable {
case onAppear
case refresh
case loadMore
case userInfoResponse(Result<UserInfo, APIError>)
case momentsResponse(Result<MyMomentsResponse, APIError>)
//
case settingButtonTapped
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .onAppear:
guard state.isFirstLoad else { return .none }
state.isFirstLoad = false
return .send(.refresh)
case .refresh:
guard state.uid > 0 else { return .none }
state.isRefreshing = true
state.page = 1
state.hasMore = true
return .merge(
fetchUserInfo(uid: state.uid),
fetchMoments(uid: state.uid, page: 1, pageSize: state.pageSize)
)
case .loadMore:
guard state.uid > 0, state.hasMore, !state.isLoadingMore else { return .none }
state.isLoadingMore = true
return fetchMoments(uid: state.uid, page: state.page + 1, pageSize: state.pageSize)
case let .userInfoResponse(result):
state.isLoadingUserInfo = false
state.isRefreshing = false
switch result {
case let .success(userInfo):
state.userInfo = userInfo
state.userInfoError = nil
case let .failure(error):
state.userInfoError = error.localizedDescription
}
return .none
case let .momentsResponse(result):
state.isLoadingMoments = false
state.isLoadingMore = false
state.isRefreshing = false
switch result {
case let .success(resp):
let newMoments = resp.data ?? []
if state.page == 1 {
state.moments = newMoments
} else {
state.moments += newMoments
}
state.hasMore = newMoments.count == state.pageSize
if state.hasMore { state.page += 1 }
state.momentsError = nil
case let .failure(error):
state.momentsError = error.localizedDescription
}
return .none
case .settingButtonTapped:
// MainFeature
return .none
}
}
private func fetchUserInfo(uid: Int) -> Effect<Action> {
.run { send in
// do {
if let userInfo = await UserInfoManager.fetchUserInfoFromServer(uid: String(uid), apiService: apiService) {
await send(.userInfoResponse(.success(userInfo)))
} else {
await send(.userInfoResponse(.failure(.noData)))
}
// } catch {
// await send(.userInfoResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
// }
}
}
private func fetchMoments(uid: Int, page: Int, pageSize: Int) -> Effect<Action> {
.run { send in
do {
let req = GetMyDynamicRequest(fromUid: uid, uid: uid, page: page, pageSize: pageSize)
let resp = try await apiService.request(req)
await send(.momentsResponse(.success(resp)))
} catch {
await send(.momentsResponse(.failure(error as? APIError ?? .unknown(error.localizedDescription))))
}
}
}
}

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

@@ -1,75 +0,0 @@
import Foundation
import ComposableArchitecture
@Reducer
struct SettingFeature {
@ObservableState
struct State: Equatable {
var userInfo: UserInfo?
var accountModel: AccountModel?
var isLoading = false
var error: String?
}
enum Action: Equatable {
case onAppear
case loadUserInfo
case userInfoLoaded(UserInfo?)
case loadAccountModel
case accountModelLoaded(AccountModel?)
case logoutTapped
case logout
case dismissTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
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:
state.isLoading = true
//
UserInfoManager.clearAllAuthenticationData()
//
NotificationCenter.default.post(name: .homeLogout, object: nil)
return .none
case .dismissTapped:
//
NotificationCenter.default.post(name: .settingsDismiss, object: nil)
return .none
}
}
}
}
// MARK: - Notification Extension
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,26 +25,35 @@ struct SplashFeature {
case splashFinished
case checkAuthentication
case authenticationChecked(UserInfoManager.AuthenticationStatus)
// actions
case fetchUserInfo
case userInfoFetched(Bool)
// actions
case navigateToLogin
case navigateToMain
}
@Dependency(\.apiService) var apiService // API
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
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 +63,48 @@ 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(.fetchUserInfo)
} else {
debugInfo("🔑 需要手动登录")
NotificationCenter.default.post(name: .autoLoginFailed, object: nil)
debugInfoSync("🔑 需要手动登录")
return .send(.navigateToLogin)
}
case .fetchUserInfo:
//
return .run { send in
let success = await UserInfoManager.autoFetchUserInfoOnAppLaunch(apiService: apiService)
await send(.userInfoFetched(success))
}
case let .userInfoFetched(success):
if success {
debugInfoSync("✅ 用户信息获取成功,进入主页")
} else {
debugInfoSync("⚠️ 用户信息获取失败,但仍进入主页")
}
return .send(.navigateToMain)
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>
@@ -13,6 +9,8 @@
</dict>
<key>NSWiFiUsageDescription</key>
<string>应用需要访问 Wi-Fi 信息以提供网络相关功能</string>
<key>NSCameraUsageDescription</key>
<string>需要使用相机拍照上传图片</string>
<key>UIAppFonts</key>
<array>
<string>Bayon-Regular.ttf</string>

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

@@ -2,11 +2,10 @@
Localizable.strings
yana
Created on 2024.
英文本地化文件
English localization file (auto-aligned)
*/
// MARK: - 登录界面
// MARK: - Login Screen
"login.id_login" = "ID Login";
"login.email_login" = "Email Login";
"login.app_title" = "E-PARTI";
@@ -14,32 +13,32 @@
"login.agreement" = "User Service Agreement";
"login.policy" = "Privacy Policy";
// MARK: - 通用按钮
// MARK: - Common Buttons
"common.login" = "Login";
"common.register" = "Register";
"common.cancel" = "Cancel";
"common.confirm" = "Confirm";
"common.ok" = "OK";
// MARK: - 错误信息
// MARK: - Error Messages
"error.network" = "Network Error";
"error.invalid_input" = "Invalid Input";
"error.login_failed" = "Login Failed";
// MARK: - 占位符文本
// MARK: - Placeholders
"placeholder.email" = "Enter your email";
"placeholder.password" = "Enter your password";
"placeholder.username" = "Enter your username";
"placeholder.enter_id" = "Please enter ID";
"placeholder.enter_password" = "Please enter password";
// MARK: - ID登录页面
// MARK: - ID Login Page
"id_login.title" = "ID Login";
"id_login.forgot_password" = "Forgot Password?";
"id_login.login_button" = "Login";
"id_login.logging_in" = "Logging in...";
// MARK: - 邮箱登录页面
// MARK: - Email Login Page
"email_login.title" = "Email Login";
"email_login.email_required" = "Please enter email";
"email_login.invalid_email" = "Please enter a valid email address";
@@ -52,13 +51,13 @@
"placeholder.enter_email" = "Please enter email";
"placeholder.enter_verification_code" = "Please enter verification code";
// MARK: - 验证和错误信息
// MARK: - Validation and Error Messages
"validation.id_required" = "Please enter your ID";
"validation.password_required" = "Please enter your password";
"error.encryption_failed" = "Encryption failed, please try again";
"error.login_failed" = "Login failed, please check your credentials";
// MARK: - 密码恢复页面
// MARK: - Password Recovery Page
"recover_password.title" = "Recover Password";
"recover_password.placeholder_email" = "Please enter email";
"recover_password.placeholder_verification_code" = "Please enter verification code";
@@ -74,5 +73,60 @@
"recover_password.reset_success" = "Password reset successfully";
"recover_password.resetting" = "Resetting...";
// MARK: - 主页
"home.title" = "Enjoy your Life Time";
// MARK: - Home
"home.title" = "Enjoy your Life Time";
// MARK: - Create Feed
"createFeed.enterContent" = "Enter Content";
"createFeed.processingImages" = "Processing images...";
"createFeed.publishing" = "Publishing...";
"createFeed.publish" = "Publish";
"createFeed.title" = "Image & Text Publish";
// MARK: - Edit Feed
"editFeed.title" = "Image & Text Edit";
"editFeed.publish" = "Publish";
"editFeed.enterContent" = "Enter Content";
// MARK: - Feed List
"feedList.title" = "Enjoy your Life Time";
"feedList.slogan" = "The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.";
// MARK: - Feed
"feed.title" = "Enjoy your Life Time";
"feed.empty" = "No moments yet";
"feed.error" = "Error: %@";
"feed.retry" = "Retry";
"feed.loadingMore" = "Loading more...";
"me.title" = "Me";
"me.nickname" = "Nickname";
"me.id" = "ID: %@";
"language.select" = "Select Language";
"language.current" = "Current Language";
"language.info" = "Language Info";
"feed.user" = "User %d";
"feed.2hoursago" = "2 hours ago";
"feed.demoContent" = "Today is a beautiful day, sharing some little happiness in life. Hope everyone cherishes every moment.";
"feed.vip" = "VIP%d";
// MARK: - Splash
"splash.title" = "E-Parti";
// MARK: - Setting
"setting.title" = "Settings";
"setting.user" = "User";
"setting.language" = "Language Settings";
"setting.about" = "About Us";
"setting.version" = "Version Info";
"setting.logout" = "Logout";
// MARK: - App Setting
"appSetting.title" = "Edit";
"appSetting.nickname" = "Nickname";
"appSetting.personalInfoPermissions" = "Personal Information and Permissions";
"appSetting.help" = "Help";
"appSetting.clearCache" = "Clear Cache";
"appSetting.checkUpdates" = "Check for Updates";
"appSetting.logout" = "Log Out";
"appSetting.aboutUs" = "About Us";
"appSetting.logoutAccount" = "Log out of account";

View File

@@ -76,3 +76,53 @@
// MARK: - 主页
"home.title" = "享受您的生活时光";
"createFeed.enterContent" = "输入内容";
"createFeed.processingImages" = "处理图片中...";
"createFeed.publishing" = "发布中...";
"createFeed.publish" = "发布";
"createFeed.title" = "图文发布";
"editFeed.title" = "图文发布";
"editFeed.publish" = "发布";
"editFeed.enterContent" = "输入内容";
"feedList.title" = "享受您的生活时光";
"feedList.slogan" = "疾病如同残酷的统治者,\n而时间是我们最宝贵的财富。\n我们活着的每一刻都是对不可避免命运的胜利。";
"feed.title" = "享受您的生活时光";
"feed.empty" = "暂无动态内容";
"feed.error" = "错误: %@";
"feed.retry" = "重试";
"feed.loadingMore" = "加载更多...";
"splash.title" = "E-Parti";
"setting.title" = "设置";
"setting.user" = "用户";
"setting.language" = "语言设置";
"setting.about" = "关于我们";
"setting.version" = "版本信息";
"setting.logout" = "退出登录";
"me.title" = "我的";
"me.nickname" = "用户昵称";
"me.id" = "ID: %@";
"language.select" = "选择语言";
"language.current" = "当前语言";
"language.info" = "语言信息";
"feed.user" = "用户%d";
"feed.2hoursago" = "2小时前";
"feed.demoContent" = "今天是美好的一天,分享一些生活中的小确幸。希望大家都能珍惜每一个当下的时刻。";
"feed.vip" = "VIP%d";
// MARK: - App Setting
"appSetting.title" = "编辑";
"appSetting.nickname" = "昵称";
"appSetting.personalInfoPermissions" = "个人信息与权限";
"appSetting.help" = "帮助";
"appSetting.clearCache" = "清除缓存";
"appSetting.checkUpdates" = "检查更新";
"appSetting.logout" = "退出登录";
"appSetting.aboutUs" = "关于我们";
"appSetting.logoutAccount" = "退出账户";

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)

241
yana/Utils/COSManager.swift Normal file
View File

@@ -0,0 +1,241 @@
import Foundation
import QCloudCOSXML
// MARK: - COS
/// COS
///
/// COS
/// - Token
/// -
/// -
@MainActor
class COSManager: ObservableObject {
static let shared = COSManager()
private init() {}
//
private static var isCOSInitialized = false
//
private func ensureCOSInitialized(tokenData: TcTokenData) {
guard !Self.isCOSInitialized else { return }
let configuration = QCloudServiceConfiguration()
let endpoint = QCloudCOSXMLEndPoint()
endpoint.regionName = tokenData.region
endpoint.useHTTPS = true
if tokenData.accelerate {
endpoint.suffix = "cos.accelerate.myqcloud.com"
}
configuration.endpoint = endpoint
QCloudCOSXMLService.registerDefaultCOSXML(with: configuration)
QCloudCOSTransferMangerService.registerDefaultCOSTransferManger(with: configuration)
Self.isCOSInitialized = true
debugInfoSync("✅ COS服务已初始化region: \(tokenData.region)")
}
// 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 _ = cachedToken, let expiration = tokenExpirationDate {
let isExpired = Date() >= expiration
return "Token 状态: \(isExpired ? "已过期" : "有效"), 过期时间: \(expiration)"
} else {
return "Token 状态: 未缓存"
}
}
// MARK: -
/// COS
/// - Parameters:
/// - imageData:
/// - apiService: API
/// - Returns: nil
func uploadImage(_ imageData: Data, apiService: any APIServiceProtocol & Sendable) async -> String? {
guard let tokenData = await getToken(apiService: apiService) else {
debugInfoSync("❌ 无法获取 COS Token")
return nil
}
// COS
ensureCOSInitialized(tokenData: tokenData)
// COS
let credential = QCloudCredential()
credential.secretID = tokenData.secretId
// secretKey
let rawSecretKey = tokenData.secretKey.trimmingCharacters(in: .whitespacesAndNewlines)
debugInfoSync("secretKey原始内容: [\(rawSecretKey)]")
credential.secretKey = rawSecretKey
credential.token = tokenData.sessionToken
credential.startDate = tokenData.startDate
credential.expirationDate = tokenData.expirationDate
let request = QCloudCOSXMLUploadObjectRequest<AnyObject>()
request.bucket = tokenData.bucket
request.regionName = tokenData.region
request.credential = credential
// key
let fileExtension = "jpg" // JPG
let key = "images/\(UUID().uuidString).\(fileExtension)"
request.object = key
request.body = imageData as AnyObject
//
request.sendProcessBlock = { (bytesSent, totalBytesSent,
totalBytesExpectedToSend) in
debugInfoSync("\(bytesSent), \(totalBytesSent), \(totalBytesExpectedToSend)")
// bytesSent
// totalBytesSent
// totalBytesExpectedToSend
};
//
if tokenData.accelerate {
request.enableQuic = true
// endpoint "cos.accelerate.myqcloud.com"
}
// 使 async/await
return await withCheckedContinuation { continuation in
request.setFinish { result, error in
if let error = error {
debugInfoSync("❌ 图片上传失败: \(error.localizedDescription)")
continuation.resume(returning: " ?????????? ")
} else {
//
let domain = tokenData.customDomain.isEmpty ? "\(tokenData.bucket).cos.\(tokenData.region).myqcloud.com" : tokenData.customDomain
let prefix = domain.hasPrefix("http") ? "" : "https://"
let cloudURL = "\(prefix)\(domain)/\(key)"
debugInfoSync("✅ 图片上传成功: \(cloudURL)")
continuation.resume(returning: cloudURL)
}
}
QCloudCOSTransferMangerService.defaultCOSTransferManager().uploadObject(request)
}
}
/// UIImage COS JPEG(0.8)
/// - Parameters:
/// - image: UIImage
/// - apiService: API
/// - Returns: nil
func uploadUIImage(_ image: UIImage, apiService: any APIServiceProtocol & Sendable) async -> String? {
guard let data = image.jpegData(compressionQuality: 0.8) else {
debugInfoSync("❌ 图片压缩失败,无法生成 JPEG 数据")
return nil
}
return await uploadImage(data, apiService: apiService)
}
}
// 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

@@ -23,4 +23,28 @@ extension Color {
let blue = Double(hex & 0xFF) / 255.0
self.init(red: red, green: green, blue: blue, opacity: alpha)
}
}
init(hexString: String) {
let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

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

@@ -1,356 +0,0 @@
import Foundation
///
///
/// UserDefaults Keychain
///
///
///
/// 1.
/// 2. Keychain
/// 3.
/// 4.
final class DataMigrationManager {
// MARK: -
static let shared = DataMigrationManager()
private init() {}
// MARK: -
private let migrationCompleteKey = "keychain_migration_completed_v1"
// MARK: -
private enum LegacyStorageKeys {
static let userId = "user_id"
static let accessToken = "access_token"
static let userInfo = "user_info"
static let accountModel = "account_model"
static let appLanguage = "AppLanguage"
}
// MARK: -
enum MigrationResult {
case completed //
case alreadyMigrated //
case noDataToMigrate //
case failed(Error) //
var description: String {
switch self {
case .completed:
return "数据迁移完成"
case .alreadyMigrated:
return "数据已经迁移过"
case .noDataToMigrate:
return "没有需要迁移的数据"
case .failed(let error):
return "迁移失败: \(error.localizedDescription)"
}
}
}
// MARK: -
///
/// - Returns:
func performMigration() -> MigrationResult {
debugInfo("🔄 开始检查数据迁移...")
//
if isMigrationCompleted() {
debugInfo("✅ 数据已经迁移过,跳过迁移")
return .alreadyMigrated
}
//
let legacyData = collectLegacyData()
if legacyData.isEmpty {
debugInfo(" 没有发现需要迁移的数据")
markMigrationCompleted()
return .noDataToMigrate
}
debugInfo("📦 发现需要迁移的数据: \(legacyData.keys.joined(separator: ", "))")
do {
//
try migrateToKeychain(legacyData)
//
try verifyMigration(legacyData)
//
cleanupLegacyData(legacyData.keys)
//
markMigrationCompleted()
debugInfo("✅ 数据迁移完成")
return .completed
} catch {
debugError("❌ 数据迁移失败: \(error)")
return .failed(error)
}
}
///
func forceMigration() -> MigrationResult {
resetMigrationStatus()
return performMigration()
}
// MARK: -
///
private func isMigrationCompleted() -> Bool {
return UserDefaults.standard.bool(forKey: migrationCompleteKey)
}
///
private func markMigrationCompleted() {
UserDefaults.standard.set(true, forKey: migrationCompleteKey)
UserDefaults.standard.synchronize()
}
///
private func resetMigrationStatus() {
UserDefaults.standard.removeObject(forKey: migrationCompleteKey)
UserDefaults.standard.synchronize()
}
///
private func collectLegacyData() -> [String: Any] {
let userDefaults = UserDefaults.standard
var legacyData: [String: Any] = [:]
//
if let userId = userDefaults.string(forKey: LegacyStorageKeys.userId) {
legacyData[LegacyStorageKeys.userId] = userId
}
if let accessToken = userDefaults.string(forKey: LegacyStorageKeys.accessToken) {
legacyData[LegacyStorageKeys.accessToken] = accessToken
}
if let userInfoData = userDefaults.data(forKey: LegacyStorageKeys.userInfo) {
legacyData[LegacyStorageKeys.userInfo] = userInfoData
}
if let accountModelData = userDefaults.data(forKey: LegacyStorageKeys.accountModel) {
legacyData[LegacyStorageKeys.accountModel] = accountModelData
}
if let appLanguage = userDefaults.string(forKey: LegacyStorageKeys.appLanguage) {
legacyData[LegacyStorageKeys.appLanguage] = appLanguage
}
return legacyData
}
/// Keychain
private func migrateToKeychain(_ legacyData: [String: Any]) throws {
let keychain = KeychainManager.shared
// AccountModel
if let accountModelData = legacyData[LegacyStorageKeys.accountModel] as? Data {
do {
let accountModel = try JSONDecoder().decode(AccountModel.self, from: accountModelData)
try keychain.store(accountModel, forKey: "account_model")
debugInfo("✅ AccountModel 迁移成功")
} catch {
debugError("❌ AccountModel 迁移失败: \(error)")
// AccountModel
try migrateAccountModelFromIndependentFields(legacyData)
}
} else {
// AccountModel
try migrateAccountModelFromIndependentFields(legacyData)
}
// UserInfo
if let userInfoData = legacyData[LegacyStorageKeys.userInfo] as? Data {
do {
let userInfo = try JSONDecoder().decode(UserInfo.self, from: userInfoData)
try keychain.store(userInfo, forKey: "user_info")
debugInfo("✅ UserInfo 迁移成功")
} catch {
debugError("❌ UserInfo 迁移失败: \(error)")
throw error
}
}
//
if let appLanguage = legacyData[LegacyStorageKeys.appLanguage] as? String {
try keychain.storeString(appLanguage, forKey: "AppLanguage")
debugInfo("✅ 语言设置迁移成功")
}
}
/// AccountModel
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")
return
}
let accountModel = AccountModel(
uid: userId,
jti: nil,
tokenType: "bearer",
refreshToken: nil,
netEaseToken: nil,
accessToken: accessToken,
expiresIn: nil,
scope: nil,
ticket: nil
)
try KeychainManager.shared.store(accountModel, forKey: "account_model")
debugInfo("✅ 从独立字段重建 AccountModel 成功")
}
///
private func verifyMigration(_ legacyData: [String: Any]) throws {
let keychain = KeychainManager.shared
// AccountModel
if legacyData[LegacyStorageKeys.accountModel] != nil ||
(legacyData[LegacyStorageKeys.userId] != nil && legacyData[LegacyStorageKeys.accessToken] != nil) {
let accountModel: AccountModel? = try keychain.retrieve(AccountModel.self, forKey: "account_model")
guard accountModel != nil else {
throw MigrationError.verificationFailed("AccountModel 验证失败")
}
}
// UserInfo
if legacyData[LegacyStorageKeys.userInfo] != nil {
let userInfo: UserInfo? = try keychain.retrieve(UserInfo.self, forKey: "user_info")
guard userInfo != nil else {
throw MigrationError.verificationFailed("UserInfo 验证失败")
}
}
//
if legacyData[LegacyStorageKeys.appLanguage] != nil {
let appLanguage = try keychain.retrieveString(forKey: "AppLanguage")
guard appLanguage != nil else {
throw MigrationError.verificationFailed("语言设置验证失败")
}
}
debugInfo("✅ 迁移数据验证成功")
}
///
private func cleanupLegacyData(_ keys: Dictionary<String, Any>.Keys) {
let userDefaults = UserDefaults.standard
for key in keys {
userDefaults.removeObject(forKey: key)
debugInfo("🗑️ 清理旧数据: \(key)")
}
userDefaults.synchronize()
debugInfo("✅ 旧数据清理完成")
}
}
// MARK: -
enum MigrationError: Error, LocalizedError {
case verificationFailed(String)
case dataCorrupted(String)
case keychainError(Error)
var errorDescription: String? {
switch self {
case .verificationFailed(let message):
return "验证失败: \(message)"
case .dataCorrupted(let message):
return "数据损坏: \(message)"
case .keychainError(let error):
return "Keychain 错误: \(error.localizedDescription)"
}
}
}
// MARK: -
extension DataMigrationManager {
///
/// AppDelegate App
static func performStartupMigration() {
let migrationResult = DataMigrationManager.shared.performMigration()
switch migrationResult {
case .completed:
debugInfo("🎉 应用启动时数据迁移完成")
case .alreadyMigrated:
break //
case .noDataToMigrate:
break //
case .failed(let error):
debugError("⚠️ 应用启动时数据迁移失败: \(error)")
//
}
}
}
// MARK: -
#if DEBUG
extension DataMigrationManager {
///
func debugPrintLegacyData() {
let legacyData = collectLegacyData()
debugInfo("🔍 旧版本数据:")
for (key, value) in legacyData {
debugInfo(" - \(key): \(type(of: value))")
}
}
///
func debugCreateLegacyData() {
let userDefaults = UserDefaults.standard
userDefaults.set("test_user_123", forKey: LegacyStorageKeys.userId)
userDefaults.set("test_access_token", forKey: LegacyStorageKeys.accessToken)
userDefaults.set("zh-Hans", forKey: LegacyStorageKeys.appLanguage)
userDefaults.synchronize()
debugInfo("🧪 已创建测试用的旧版本数据")
}
///
func debugClearAllData() {
// Keychain
do {
try KeychainManager.shared.clearAll()
} catch {
debugError("❌ 清除 Keychain 数据失败: \(error)")
}
// UserDefaults
let userDefaults = UserDefaults.standard
let allKeys = [
LegacyStorageKeys.userId,
LegacyStorageKeys.accessToken,
LegacyStorageKeys.userInfo,
LegacyStorageKeys.accountModel,
LegacyStorageKeys.appLanguage,
migrationCompleteKey
]
for key in allKeys {
userDefaults.removeObject(forKey: key)
}
userDefaults.synchronize()
debugInfo("🧪 已清除所有迁移相关数据")
}
}
#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()
}
//
//#Preview {
// AppRootView()
//}

View File

@@ -0,0 +1,474 @@
//
// AppSettingView.swift
// yana
//
// Created by Edwin on 2024/11/20.
//
import SwiftUI
import ComposableArchitecture
import PhotosUI
struct AppSettingView: View {
let store: StoreOf<AppSettingFeature>
// letpickerStore
let pickerStore = Store(
initialState: ImagePickerWithPreviewReducer.State(inner: ImagePickerWithPreviewState(selectionMode: .single)),
reducer: { ImagePickerWithPreviewReducer() }
)
@State private var showNicknameAlert = false
@State private var nicknameInput = ""
@State private var showImagePickerSheet = false
@State private var showActionSheet = false
@State private var showPhotoPicker = false
@State private var showCamera = false
@State private var selectedPhotoItems: [PhotosPickerItem] = []
@State private var selectedImages: [UIImage] = []
@State private var cameraImage: UIImage? = nil
@State private var previewIndex: Int = 0
@State private var showPreview = false
@State private var isLoading = false
@State private var errorMessage: String? = nil
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
ZStack {
mainContent(viewStore: viewStore)
}
.confirmationDialog(
"请选择图片来源",
isPresented: $showActionSheet,
titleVisibility: .visible
) {
Button("拍照") { showCamera = true }
Button("从相册选择") { showPhotoPicker = true }
Button("取消", role: .cancel) {}
}
.photosPicker(
isPresented: $showPhotoPicker,
selection: $selectedPhotoItems,
maxSelectionCount: 1,
matching: .images
)
.sheet(isPresented: $showCamera) {
CameraPicker { image in
print("[LOG] CameraPicker回调image: \(image != nil)")
if let image = image {
print("[LOG] CameraPicker获得图片直接上传头像")
if let data = image.jpegData(compressionQuality: 0.8) {
viewStore.send(AppSettingFeature.Action.avatarSelected(data))
}
} else {
errorMessage = "拍照失败,请重试"
//
showPreview = false
showCamera = false
showPhotoPicker = false
showActionSheet = false
print("[LOG] CameraPicker无图片弹出错误提示")
}
showCamera = false
}
}
.fullScreenCover(isPresented: $showPreview) {
ImagePreviewView(
images: $selectedImages,
currentIndex: .constant(0),
onConfirm: {
print("[LOG] 预览确认,准备上传头像")
if let image = selectedImages.first, let data = image.jpegData(compressionQuality: 0.8) {
viewStore.send(AppSettingFeature.Action.avatarSelected(data))
}
showPreview = false
},
onCancel: {
print("[LOG] 预览取消")
showPreview = false
}
)
}
.onChange(of: selectedPhotoItems) { items in
print("[LOG] PhotosPicker选中items: \(items.count)")
guard !items.isEmpty else { return }
isLoading = true
selectedImages = []
let group = DispatchGroup()
var tempImages: [UIImage] = []
for item in items {
group.enter()
item.loadTransferable(type: Data.self) { result in
defer { group.leave() }
if let data = try? result.get(), let uiImage = UIImage(data: data) {
DispatchQueue.main.async {
tempImages.append(uiImage)
print("[LOG] 成功加载图片当前tempImages数量: \(tempImages.count)")
}
} else {
print("[LOG] 图片加载失败")
}
}
}
DispatchQueue.global().async {
group.wait()
DispatchQueue.main.async {
isLoading = false
print("[LOG] 所有图片加载完成tempImages数量: \(tempImages.count)")
if tempImages.isEmpty {
errorMessage = "图片加载失败,请重试"
//
showPreview = false
showCamera = false
showPhotoPicker = false
showActionSheet = false
print("[LOG] PhotosPicker图片加载失败弹出错误提示")
} else {
// selectedImages
selectedImages = tempImages
print("[LOG] selectedImages已设置数量: \(selectedImages.count)")
// 线showPreview
DispatchQueue.main.async {
showPreview = true
print("[LOG] showPreview已设置为true")
}
}
}
}
}
.alert(isPresented: Binding<Bool>(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
print("[LOG] 错误弹窗弹出: \(errorMessage ?? "")")
return Alert(title: Text("错误"), message: Text(errorMessage ?? ""), dismissButton: .default(Text("确定"), action: {
// actionset
showPreview = false
showCamera = false
showPhotoPicker = false
showActionSheet = false
}))
}
.navigationBarHidden(true)
.alert("修改昵称", isPresented: $showNicknameAlert) {
nicknameAlertContent(viewStore: viewStore)
} message: {
Text("昵称最长15个字符")
}
.sheet(isPresented: userAgreementBinding(viewStore: viewStore)) {
WebView(url: URL(string: "https://www.yana.com/user-agreement")!)
}
.sheet(isPresented: privacyPolicyBinding(viewStore: viewStore)) {
WebView(url: URL(string: "https://www.yana.com/privacy-policy")!)
}
}
}
}
// MARK: -
private func mainContent(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
ZStack {
Color(red: 22/255, green: 17/255, blue: 44/255).ignoresSafeArea()
VStack(spacing: 0) {
topBar
ScrollView {
WithPerceptionTracking {
VStack(spacing: 32) {
//
avatarSection(viewStore: viewStore)
//
nicknameSection(viewStore: viewStore)
//
settingsSection(viewStore: viewStore)
// 退
logoutButton(viewStore: viewStore)
}
}
}
}
}
}
// MARK: -
private func avatarSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
ZStack(alignment: .bottomTrailing) {
avatarImageView(viewStore: viewStore)
.onTapGesture {
showActionSheet = true
}
cameraButton(viewStore: viewStore)
}
.padding(.top, 24)
}
// MARK: -
@ViewBuilder
private func avatarImageView(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
if viewStore.isUploadingAvatar || viewStore.isLoadingUserInfo {
loadingAvatarView
} else if let avatarURLString = viewStore.avatarURL, !avatarURLString.isEmpty, let avatarURL = URL(string: avatarURLString) {
networkAvatarView(url: avatarURL)
} else {
defaultAvatarView
}
}
// MARK: -
private var loadingAvatarView: some View {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 120, height: 120)
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.2)
)
}
// MARK: -
private func networkAvatarView(url: URL) -> some View {
CachedAsyncImage(url: url.absoluteString) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
defaultAvatarView
}
.frame(width: 120, height: 120)
.clipShape(Circle())
}
// MARK: -
private var defaultAvatarView: some View {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 120, height: 120)
.overlay(
Image(systemName: "person.fill")
.font(.system(size: 40))
.foregroundColor(.white)
)
}
// MARK: -
private func cameraButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
Button(action: {
showActionSheet = true
}) {
ZStack {
Circle().fill(Color.purple).frame(width: 36, height: 36)
Image(systemName: "camera.fill")
.foregroundColor(.white)
}
}
.offset(x: 8, y: 8)
}
// MARK: -
private func nicknameSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
VStack(spacing: 0) {
HStack {
Text(NSLocalizedString("appSetting.nickname", comment: "Nickname"))
.foregroundColor(.white)
Spacer()
Text(viewStore.nickname)
.foregroundColor(.gray)
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
.padding(.horizontal, 32)
.padding(.vertical, 18)
.onTapGesture {
nicknameInput = viewStore.nickname
showNicknameAlert = true
}
Divider().background(Color.gray.opacity(0.3))
.padding(.horizontal, 32)
}
}
// MARK: -
private func settingsSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
VStack(spacing: 0) {
personalInfoPermissionsRow(viewStore: viewStore)
helpRow(viewStore: viewStore)
clearCacheRow(viewStore: viewStore)
checkUpdatesRow(viewStore: viewStore)
aboutUsRow(viewStore: viewStore)
}
.background(Color.clear)
.padding(.horizontal, 0)
}
// MARK: -
private func personalInfoPermissionsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
settingRow(
title: NSLocalizedString("appSetting.personalInfoPermissions", comment: "Personal Information and Permissions"),
action: { viewStore.send(.personalInfoPermissionsTapped) }
)
}
// MARK: -
private func helpRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
settingRow(
title: NSLocalizedString("appSetting.help", comment: "Help"),
action: { viewStore.send(.helpTapped) }
)
}
// MARK: -
private func clearCacheRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
settingRow(
title: NSLocalizedString("appSetting.clearCache", comment: "Clear Cache"),
action: { viewStore.send(.clearCacheTapped) }
)
}
// MARK: -
private func checkUpdatesRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
settingRow(
title: NSLocalizedString("appSetting.checkUpdates", comment: "Check for Updates"),
action: { viewStore.send(.checkUpdatesTapped) }
)
}
// MARK: -
private func aboutUsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
settingRow(
title: NSLocalizedString("appSetting.aboutUs", comment: "About Us"),
action: { viewStore.send(.aboutUsTapped) }
)
}
// MARK: -
private func settingRow(title: String, action: @escaping () -> Void) -> some View {
VStack(spacing: 0) {
HStack {
Text(title)
.foregroundColor(.white)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
.padding(.horizontal, 32)
.padding(.vertical, 18)
.onTapGesture {
action()
}
Divider().background(Color.gray.opacity(0.3))
.padding(.horizontal, 32)
}
}
// MARK: - 退
private func logoutButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
Button(action: {
viewStore.send(.logoutTapped)
}) {
Text(NSLocalizedString("appSetting.logoutAccount", comment: "Log out of account"))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(Color.white.opacity(0.08))
.cornerRadius(28)
.padding(.horizontal, 32)
}
.padding(.bottom, 32)
}
// MARK: -
private func userAgreementBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
viewStore.binding(
get: \.showUserAgreement,
send: AppSettingFeature.Action.userAgreementDismissed
)
}
// MARK: -
private func privacyPolicyBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
viewStore.binding(
get: \.showPrivacyPolicy,
send: AppSettingFeature.Action.privacyPolicyDismissed
)
}
// MARK: - Alert
@ViewBuilder
private func nicknameAlertContent(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
TextField("请输入昵称", text: $nicknameInput)
.onChange(of: nicknameInput) { newValue in
if newValue.count > 15 {
nicknameInput = String(newValue.prefix(15))
}
}
Button("确定") {
let trimmed = nicknameInput.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && trimmed != viewStore.nickname {
viewStore.send(.nicknameEditConfirmed(trimmed))
}
}
Button("取消", role: .cancel) {}
}
// MARK: -
private var topBar: some View {
HStack {
WithViewStore(store, observe: { $0 }) { viewStore in
Button(action: {
viewStore.send(.dismissTapped)
}) {
Image(systemName: "chevron.left")
.foregroundColor(.white)
.font(.system(size: 20, weight: .medium))
}
}
Spacer()
Text(NSLocalizedString("appSetting.title", comment: "Settings"))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
Spacer()
//
Color.clear
.frame(width: 20, height: 20)
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 16)
}
// MARK: -
private func loadAndProcessImage(item: PhotosPickerItem, completion: @escaping @Sendable (Data?) -> Void) {
item.loadTransferable(type: Data.self) { result in
guard let data = try? result.get(), let uiImage = UIImage(data: data) else {
completion(nil)
return
}
let square = cropToSquare(image: uiImage)
let resized = resizeImage(image: square, targetSize: CGSize(width: 180, height: 180))
let jpegData = resized.jpegData(compressionQuality: 0.8)
completion(jpegData)
}
}
}
// MARK: -
private func cropToSquare(image: UIImage) -> UIImage {
let size = min(image.size.width, image.size.height)
let x = (image.size.width - size) / 2
let y = (image.size.height - size) / 2
let cropRect = CGRect(x: x, y: y, width: size, height: size)
guard let cgImage = image.cgImage?.cropping(to: cropRect) else { return image }
return UIImage(cgImage: cgImage, scale: image.scale, orientation: image.imageOrientation)
}
private func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
}

View File

@@ -0,0 +1,40 @@
import SwiftUI
import UIKit
import PhotosUI
public struct CameraPicker: UIViewControllerRepresentable {
public var onImagePicked: (UIImage?) -> Void
public init(onImagePicked: @escaping (UIImage?) -> Void) {
self.onImagePicked = onImagePicked
}
public func makeCoordinator() -> Coordinator {
Coordinator(onImagePicked: onImagePicked)
}
public func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
picker.allowsEditing = false
picker.cameraViewTransform = .identity
picker.showsCameraControls = true
return picker
}
public func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
public class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let onImagePicked: (UIImage?) -> Void
init(onImagePicked: @escaping (UIImage?) -> Void) {
self.onImagePicked = onImagePicked
}
public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
let image = info[.originalImage] as? UIImage
onImagePicked(image)
picker.dismiss(animated: true)
}
public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
onImagePicked(nil)
picker.dismiss(animated: true)
}
}
}
//

View File

@@ -0,0 +1,140 @@
import Foundation
import ComposableArchitecture
import SwiftUI
import PhotosUI
public enum ImagePickerSelectionMode: Equatable {
case single
case multiple(max: Int)
}
public struct ImagePickerWithPreviewState: Equatable {
public var selectionMode: ImagePickerSelectionMode = .single
public var showActionSheet: Bool = false
public var showPhotoPicker: Bool = false
public var showCamera: Bool = false
public var showPreview: Bool = false
public var isLoading: Bool = false
public var errorMessage: String? = nil
public var selectedPhotoItems: [PhotosPickerItem] = []
public var selectedImages: [UIImage] = []
public var cameraImage: UIImage? = nil
public var previewIndex: Int = 0 //
public init(selectionMode: ImagePickerSelectionMode = .single) {
self.selectionMode = selectionMode
}
}
public enum ImagePickerWithPreviewAction: Equatable {
case showActionSheet(Bool)
case selectSource(ImageSource)
case photoPickerItemsChanged([PhotosPickerItem])
case cameraImagePicked(UIImage?)
case previewConfirm
case previewCancel
case uploadStart
case uploadSuccess
case uploadFailure(String)
case setLoading(Bool)
case setError(String?)
case setPreviewIndex(Int)
case setShowCamera(Bool)
case setShowPhotoPicker(Bool)
case reset
}
public enum ImageSource: Equatable {
case camera
case photoLibrary
}
public struct ImagePickerWithPreviewReducer: Reducer {
public init() {}
public struct State: Equatable {
public var inner: ImagePickerWithPreviewState
public init(inner: ImagePickerWithPreviewState) { self.inner = inner }
}
public enum Action: Equatable {
case inner(ImagePickerWithPreviewAction)
}
public var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .inner(let action):
switch action {
case .showActionSheet(let show):
state.inner.showActionSheet = show
return .none
case .selectSource(let source):
state.inner.showActionSheet = false
switch source {
case .camera:
state.inner.showCamera = true
state.inner.showPhotoPicker = false
case .photoLibrary:
state.inner.showPhotoPicker = true
state.inner.showCamera = false
}
return .none
case .photoPickerItemsChanged(let items):
state.inner.selectedPhotoItems = items
//
state.inner.showPreview = !items.isEmpty
state.inner.previewIndex = 0
return .none
case .cameraImagePicked(let image):
state.inner.cameraImage = image
state.inner.selectedImages = image.map { [$0] } ?? []
state.inner.showPreview = image != nil
state.inner.previewIndex = 0
return .none
case .previewConfirm:
state.inner.showPreview = false
state.inner.isLoading = true
state.inner.errorMessage = nil
return .none // Effect
case .previewCancel:
state.inner.showPreview = false
state.inner.selectedPhotoItems = []
state.inner.selectedImages = []
state.inner.cameraImage = nil
return .none
case .uploadStart:
state.inner.isLoading = true
state.inner.errorMessage = nil
return .none
case .uploadSuccess:
state.inner.isLoading = false
state.inner.selectedPhotoItems = []
state.inner.selectedImages = []
state.inner.cameraImage = nil
return .none
case .uploadFailure(let msg):
state.inner.isLoading = false
state.inner.errorMessage = msg
return .none
case .setLoading(let loading):
state.inner.isLoading = loading
return .none
case .setError(let msg):
state.inner.errorMessage = msg
return .none
case .setPreviewIndex(let idx):
state.inner.previewIndex = idx
return .none
case .setShowCamera(let show):
state.inner.showCamera = show
return .none
case .setShowPhotoPicker(let show):
state.inner.showPhotoPicker = show
return .none
case .reset:
let mode = state.inner.selectionMode
state.inner = ImagePickerWithPreviewState(selectionMode: mode)
return .none
}
}
}
}
}

View File

@@ -0,0 +1,190 @@
import SwiftUI
import ComposableArchitecture
import PhotosUI
public struct ImagePickerWithPreviewView: View {
let store: StoreOf<ImagePickerWithPreviewReducer>
let onUpload: ([UIImage]) -> Void
let onCancel: () -> Void
@State private var loadedImages: [UIImage] = []
@State private var isLoadingImages: Bool = false
public init(store: StoreOf<ImagePickerWithPreviewReducer>, onUpload: @escaping ([UIImage]) -> Void, onCancel: @escaping () -> Void) {
self.store = store
self.onUpload = onUpload
self.onCancel = onCancel
}
public var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
ZStack {
Color.clear
LoadingView(isLoading: viewStore.inner.isLoading || isLoadingImages)
}
.background(.clear)
.modifier(ActionSheetModifier(viewStore: viewStore, onCancel: onCancel))
.modifier(CameraSheetModifier(viewStore: viewStore))
.modifier(PhotosPickerModifier(viewStore: viewStore, loadedImages: $loadedImages, isLoadingImages: $isLoadingImages))
.modifier(PreviewCoverModifier(viewStore: viewStore, loadedImages: loadedImages, onUpload: onUpload))
.modifier(ErrorToastModifier(viewStore: viewStore))
}
}
}
private struct LoadingView: View {
let isLoading: Bool
var body: some View {
if isLoading {
Color.black.opacity(0.4).ignoresSafeArea()
ProgressView("上传中...")
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.7))
.cornerRadius(16)
}
}
}
private struct ActionSheetModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
let onCancel: () -> Void
func body(content: Content) -> some View {
content.confirmationDialog(
"请选择图片来源",
isPresented: .init(
get: { viewStore.inner.showActionSheet },
set: { viewStore.send(.inner(.showActionSheet($0))) }
),
titleVisibility: .visible
) {
Button("拍照") { viewStore.send(.inner(.selectSource(.camera))) }
Button("从相册选择") { viewStore.send(.inner(.selectSource(.photoLibrary))) }
Button("取消", role: .cancel) { onCancel() }
}
}
}
private struct CameraSheetModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
func body(content: Content) -> some View {
content.sheet(isPresented: .init(
get: { viewStore.inner.showCamera },
set: { viewStore.send(.inner(.setShowCamera($0))) }
)) {
CameraPicker { image in
viewStore.send(.inner(.cameraImagePicked(image)))
}
}
}
}
private struct PhotosPickerModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
@Binding var loadedImages: [UIImage]
@Binding var isLoadingImages: Bool
func body(content: Content) -> some View {
content
.photosPicker(
isPresented: .init(
get: { viewStore.inner.showPhotoPicker },
set: { viewStore.send(.inner(.setShowPhotoPicker($0))) }
),
selection: .init(
get: { viewStore.inner.selectedPhotoItems },
set: { viewStore.send(.inner(.photoPickerItemsChanged($0))) }
),
maxSelectionCount: {
switch viewStore.inner.selectionMode {
case .single: return 1
case .multiple(let max): return max
}
}(),
matching: .images
)
.onChange(of: viewStore.inner.selectedPhotoItems) { items in
guard !items.isEmpty else { return }
isLoadingImages = true
loadedImages = []
let group = DispatchGroup()
var tempImages: [UIImage] = []
for item in items {
group.enter()
item.loadTransferable(type: Data.self) { result in
defer { group.leave() }
if let data = try? result.get(), let uiImage = UIImage(data: data) {
DispatchQueue.main.async {
tempImages.append(uiImage)
}
}
}
}
DispatchQueue.global().async {
group.wait()
DispatchQueue.main.async {
loadedImages = tempImages
isLoadingImages = false
viewStore.send(.inner(.setLoading(false)))
}
}
}
}
}
private struct PreviewCoverModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
let loadedImages: [UIImage]
let onUpload: ([UIImage]) -> Void
func body(content: Content) -> some View {
content.fullScreenCover(isPresented: .init(
get: { viewStore.inner.showPreview },
set: { _ in }
)) {
ImagePreviewView(
images: .constant(previewImages),
currentIndex: .init(
get: { viewStore.inner.previewIndex },
set: { viewStore.send(.inner(.setPreviewIndex($0))) }
),
onConfirm: {
viewStore.send(.inner(.previewConfirm))
onUpload(previewImages)
},
onCancel: {
viewStore.send(.inner(.previewCancel))
}
)
}
}
private var previewImages: [UIImage] {
if let camera = viewStore.inner.cameraImage {
return [camera]
}
if !loadedImages.isEmpty {
return loadedImages
}
return []
}
}
private struct ErrorToastModifier: ViewModifier {
let viewStore: ViewStoreOf<ImagePickerWithPreviewReducer>
func body(content: Content) -> some View {
content.overlay(
Group {
if let error = viewStore.inner.errorMessage {
VStack {
Spacer()
Text(error)
.foregroundColor(.red)
.padding()
.background(Color.white)
.cornerRadius(12)
.padding(.bottom, 40)
}
}
}
)
}
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
public struct ImagePreviewView: View {
@Binding var images: [UIImage]
@Binding var currentIndex: Int
let onConfirm: () -> Void
let onCancel: () -> Void
public init(images: Binding<[UIImage]>, currentIndex: Binding<Int>, onConfirm: @escaping () -> Void, onCancel: @escaping () -> Void) {
self._images = images
self._currentIndex = currentIndex
self.onConfirm = onConfirm
self.onCancel = onCancel
}
public var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack {
Spacer()
if !images.isEmpty {
TabView(selection: $currentIndex) {
ForEach(images.indices, id: \ .self) { idx in
Image(uiImage: images[idx])
.resizable()
.aspectRatio(contentMode: .fit)
.tag(idx)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: images.count > 1 ? .always : .never))
.frame(maxHeight: 400)
} else {
//
VStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.5)
Text("加载图片中...")
.foregroundColor(.white)
.padding(.top, 16)
}
}
Spacer()
HStack(spacing: 24) {
Button(action: onCancel) {
Text("取消")
.foregroundColor(.white)
.padding(.horizontal, 32)
.padding(.vertical, 12)
.background(Color.gray.opacity(0.5))
.cornerRadius(20)
}
Button(action: onConfirm) {
Text("确认")
.foregroundColor(.white)
.padding(.horizontal, 32)
.padding(.vertical, 12)
.background(Color.blue)
.cornerRadius(20)
}
.disabled(images.isEmpty)
.opacity(images.isEmpty ? 0.5 : 1.0)
}
.padding(.bottom, 40)
}
}
}
}

View File

@@ -0,0 +1,291 @@
import SwiftUI
import ComposableArchitecture
import Foundation
// MARK: -
struct OptimizedDynamicCardView: View {
let moment: MomentsInfo
let allMoments: [MomentsInfo]
let currentIndex: Int
//
let onImageTap: (_ images: [String], _ index: Int) -> Void
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int, onImageTap: @escaping (_ images: [String], _ index: Int) -> Void) {
self.moment = moment
self.allMoments = allMoments
self.currentIndex = currentIndex
self.onImageTap = onImageTap
}
public var body: some View {
ZStack {
//
RoundedRectangle(cornerRadius: 12)
.fill(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
//
VStack(alignment: .leading, spacing: 12) {
//
HStack(alignment: .top) {
//
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)
Text("ID: \(moment.uid)")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.6))
}
Spacer()
// VIP
Text(formatDisplayTime(moment.publishTime))
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.white.opacity(0.15))
.cornerRadius(4)
}
//
if !moment.content.isEmpty {
Text(moment.content)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading)
.padding(.leading, 40 + 8) //
}
//
if let images = moment.dynamicResList, !images.isEmpty {
OptimizedImageGrid(images: images) { tappedIndex in
let urls = images.map { $0.resUrl ?? "" }
onImageTap(urls, tappedIndex)
}
.padding(.bottom, images.count == 2 ? 46 : 0) //
}
//
HStack(spacing: 20) {
// Like
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(16)
}
.onAppear {
preloadNearbyImages()
}
}
private func formatTime(_ timestamp: Int) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
let now = Date()
let interval = now.timeIntervalSince(date)
if interval < 60 {
return "刚刚"
} else if interval < 3600 {
return "\(Int(interval / 60))分钟前"
} else if interval < 86400 {
return "\(Int(interval / 3600))小时前"
} else {
formatter.dateFormat = "MM-dd HH:mm"
return formatter.string(from: date)
}
}
//
private func formatDisplayTime(_ timestamp: Int) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
let now = Date()
let interval = now.timeIntervalSince(date)
let calendar = Calendar.current
if calendar.isDateInToday(date) {
if interval < 60 {
return "刚刚"
} else if interval < 3600 {
return "\(Int(interval / 60))分钟前"
} else {
return "\(Int(interval / 3600))小时前"
}
} else {
formatter.dateFormat = "MM/dd"
return formatter.string(from: date)
}
}
private func preloadNearbyImages() {
var urlsToPreload: [String] = []
let preloadRange = max(0, currentIndex - 2)...min(allMoments.count - 1, currentIndex + 2)
for index in preloadRange {
let moment = allMoments[index]
urlsToPreload.append(moment.avatar)
if let images = moment.dynamicResList {
urlsToPreload.append(contentsOf: images.compactMap { $0.resUrl })
}
}
ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
}
// UIImageURL
}
// MARK: -
struct OptimizedImageGrid: View {
let images: [MomentsPicture]
let onImageTap: (Int) -> Void
init(images: [MomentsPicture], onImageTap: @escaping (Int) -> Void) {
self.images = images
self.onImageTap = onImageTap
}
public var body: some View {
GeometryReader { geometry in
let availableWidth = max(geometry.size.width, 1)
let spacing: CGFloat = 8
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) {
onImageTap(0)
}
Spacer()
}
case 2:
let imageSize: CGFloat = (availableWidth - spacing) / 2
HStack(spacing: spacing) {
SquareImageView(image: images[0], size: imageSize) {
onImageTap(0)
}
SquareImageView(image: images[1], size: imageSize) {
onImageTap(1)
}
}
case 3:
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
HStack(spacing: spacing) {
ForEach(Array(images.prefix(3).enumerated()), id: \ .element.id) { idx, image in
SquareImageView(image: image, size: imageSize) {
onImageTap(idx)
}
}
}
default:
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
LazyVGrid(columns: columns, spacing: spacing) {
ForEach(Array(images.prefix(9).enumerated()), id: \ .element.id) { idx, image in
SquareImageView(image: image, size: imageSize) {
onImageTap(idx)
}
}
}
}
}
}
.frame(height: calculateGridHeight())
}
private func calculateGridHeight() -> CGFloat {
switch images.count {
case 1:
return 200
case 2:
return 120
case 3:
return 100
case 4...6:
return 216
default:
return 340
}
}
}
// MARK: -
struct SquareImageView: View {
let image: MomentsPicture
let size: CGFloat
let onTap: (() -> Void)?
init(image: MomentsPicture, size: CGFloat, onTap: (() -> Void)? = nil) {
self.image = image
self.size = size
self.onTap = onTap
}
public var body: some View {
let safeSize = size.isFinite && size > 0 ? size : 100
Group {
if let onTap = onTap {
Button(action: onTap) {
imageContent
}
.buttonStyle(PlainButtonStyle())
} else {
imageContent
}
}
.frame(width: safeSize, height: safeSize)
.clipped()
.cornerRadius(8)
}
private var imageContent: some View {
CachedAsyncImage(url: image.resUrl ?? "") { imageView in
imageView
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.3))
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
.scaleEffect(0.8)
)
}
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
struct PreviewItem: Identifiable, Equatable {
let id = UUID()
let images: [String]
let index: Int
}

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,12 @@
import SwiftUI
extension View {
@ViewBuilder
func isHidden(_ hidden: Bool) -> some View {
if hidden {
self.opacity(0).allowsHitTesting(false)
} else {
self
}
}
}

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(NSLocalizedString("createFeed.enterContent", comment: "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(NSLocalizedString("createFeed.processingImages", comment: "Processing images..."))
.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(NSLocalizedString("createFeed.publishing", comment: "Publishing..."))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
} else {
Text(NSLocalizedString("createFeed.publish", comment: "Publish"))
.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(NSLocalizedString("createFeed.title", comment: "Image & Text Publish"))
.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,299 @@
import SwiftUI
import ComposableArchitecture
import PhotosUI
//import ImagePreviewPager
struct EditFeedView: View {
let onDismiss: () -> Void
let store: StoreOf<EditFeedFeature>
@State private var isKeyboardVisible = false
private let maxCount = 500
private func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
var body: some View {
WithPerceptionTracking {
GeometryReader { geometry in
WithViewStore(store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
ZStack {
backgroundView
mainContent(geometry: geometry, viewStore: viewStore)
if viewStore.isUploadingImages {
uploadingImagesOverlay(progress: viewStore.imageUploadProgress)
} else if viewStore.isLoading {
loadingOverlay
}
}
.contentShape(Rectangle())
.onTapGesture {
if isKeyboardVisible {
hideKeyboard()
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
withAnimation(.easeInOut(duration: 0.3)) {
isKeyboardVisible = true
}
}
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
withAnimation(.easeInOut(duration: 0.3)) {
isKeyboardVisible = false
}
}
.onChange(of: viewStore.errorMessage) { error in
if error != nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
viewStore.send(.clearError)
}
}
}
.onChange(of: viewStore.shouldDismiss) { shouldDismiss in
if shouldDismiss {
onDismiss()
NotificationCenter.default.post(name: Notification.Name("reloadFeedList"), object: nil)
viewStore.send(.clearDismissFlag)
}
}
}
}
}
}
}
private var backgroundView: some View {
Color(hexString: "0C0527")
.ignoresSafeArea()
}
private func mainContent(geometry: GeometryProxy, viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
VStack(spacing: 0) {
headerView(geometry: geometry, viewStore: viewStore)
textInputArea(viewStore: viewStore)
//
ModernImageSelectionGrid(
images: viewStore.processedImages,
selectedItems: viewStore.selectedImages,
canAddMore: viewStore.canAddMoreImages,
onItemsChanged: { items in
viewStore.send(.photosPickerItemsChanged(items))
},
onRemoveImage: { index in
viewStore.send(.removeImage(index))
}
)
.padding(.horizontal, 24)
.padding(.bottom, 32)
Spacer()
if !isKeyboardVisible {
publishButtonBottom(viewStore: viewStore, geometry: geometry)
}
}
}
private func headerView(geometry: GeometryProxy, viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
HStack {
Text(NSLocalizedString("editFeed.title", comment: "Image & Text Edit"))
.font(.system(size: 20, weight: .semibold))
.foregroundColor(.white)
Spacer()
if isKeyboardVisible {
WithPerceptionTracking {
Button(action: {
hideKeyboard()
viewStore.send(.publishButtonTapped)
}) {
Text(NSLocalizedString("editFeed.publish", comment: "Publish"))
.font(.system(size: 16, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
LinearGradient(
colors: [
Color(hexString: "A14AC6"),
Color(hexString: "3B1EEB")
],
startPoint: .leading,
endPoint: .trailing
)
.cornerRadius(16)
)
}
.disabled(!viewStore.canPublish)
}
}
}
.padding(.horizontal, 24)
.padding(.top, geometry.safeAreaInsets.top + 16)
.padding(.bottom, 24)
}
private func textInputArea(viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: 20)
.fill(Color(hexString: "1C143A"))
TextEditor(text: Binding(
get: { viewStore.content },
set: { viewStore.send(.contentChanged($0)) }
))
.scrollContentBackground(.hidden)
.padding(16)
.frame(height: 160)
.foregroundColor(.white)
.background(.clear)
.cornerRadius(20)
.font(.system(size: 16))
if viewStore.content.isEmpty {
Text(NSLocalizedString("editFeed.enterContent", comment: "Enter Content"))
.foregroundColor(Color.white.opacity(0.4))
.padding(20)
.font(.system(size: 16))
}
WithPerceptionTracking {
VStack {
Spacer()
HStack {
Spacer()
Text("\(viewStore.content.count)/\(maxCount)")
.foregroundColor(Color.white.opacity(0.4))
.font(.system(size: 14))
.padding(.trailing, 16)
.padding(.bottom, 10)
}
}
}
}
.frame(height: 160)
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
private func publishButtonBottom(viewStore: ViewStoreOf<EditFeedFeature>, geometry: GeometryProxy) -> some View {
VStack {
Spacer()
Button(action: {
hideKeyboard()
viewStore.send(.publishButtonTapped)
}) {
Text(NSLocalizedString("editFeed.publish", comment: "Publish"))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background(
LinearGradient(
colors: [Color(hexString: "A14AC6"), Color(hexString: "3B1EEB")],
startPoint: .leading,
endPoint: .trailing
)
.cornerRadius(28)
)
}
.padding(.horizontal, 24)
.padding(.bottom, geometry.safeAreaInsets.bottom + 24)
.disabled(!viewStore.canPublish || viewStore.isUploadingImages || viewStore.isLoading)
.opacity(viewStore.canPublish ? 1.0 : 0.5)
}
}
private var loadingOverlay: some View {
Group {
Color.black.opacity(0.3)
.ignoresSafeArea()
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.5)
}
}
//
private func uploadingImagesOverlay(progress: Double) -> some View {
Group {
Color.black.opacity(0.3)
.ignoresSafeArea()
VStack(spacing: 16) {
ProgressView(value: progress)
.progressViewStyle(LinearProgressViewStyle(tint: .white))
.frame(width: 180)
Text("正在上传图片...\(Int(progress * 100))%")
.foregroundColor(.white)
.font(.system(size: 16, weight: .medium))
}
}
}
}
//#Preview {
// EditFeedView()
//}
// MARK: -
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)
@State private var showPreview = false
@State private var previewIndex = 0
var body: some View {
let totalSpacing: CGFloat = 8 * 2
let totalWidth = UIScreen.main.bounds.width - 24 * 2 - totalSpacing
let gridItemSize: CGFloat = totalWidth / 3
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) // aspectFill
.frame(width: gridItemSize, height: gridItemSize)
.clipped()
.cornerRadius(12)
.onTapGesture {
previewIndex = index
showPreview = true
}
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: { items in DispatchQueue.main.async { onItemsChanged(items) } }
),
maxSelectionCount: 9 - images.count,
matching: .images
) {
RoundedRectangle(cornerRadius: 12)
.fill(Color(hexString: "1C143A"))
.frame(width: gridItemSize, height: gridItemSize)
.overlay(
Image("add photo")
.resizable()
.frame(width: 40, height: 40)
.opacity(0.6)
)
}
}
}
.fullScreenCover(isPresented: $showPreview) {
ImagePreviewPager(images: images, currentIndex: $previewIndex, onClose: { showPreview = false })
}
}
}
}

View File

@@ -0,0 +1,147 @@
import SwiftUI
import ComposableArchitecture
struct FeedListView: View {
let store: StoreOf<FeedListFeature>
//
@State private var previewItem: PreviewItem? = nil
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
VStack(alignment: .center, spacing: 0) {
//
ZStack {
HStack {
Spacer(minLength: 0)
Text(NSLocalizedString("feedList.title", comment: "Enjoy your Life Time"))
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .center)
Spacer(minLength: 0)
}
HStack {
Spacer(minLength: 0)
Button(action: {
viewStore.send(.editFeedButtonTapped)
}) {
Image("add icon")
.resizable()
.frame(width: 40, height: 40)
}
}
}
.padding(.horizontal, 20)
//
Image("Volume")
.frame(width: 56, height: 41)
.padding(.top, 16)
Text(NSLocalizedString("feedList.slogan", comment: "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(.leading)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.bottom, 30)
//
if viewStore.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.top, 20)
} else if let error = viewStore.error {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
.padding(.top, 20)
} else if viewStore.moments.isEmpty {
Text(NSLocalizedString("feedList.empty", comment: "暂无动态"))
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.7))
.padding(.top, 20)
} else {
ScrollView {
WithPerceptionTracking {
LazyVStack(spacing: 16) {
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
OptimizedDynamicCardView(
moment: moment,
allMoments: viewStore.moments,
currentIndex: index,
onImageTap: { images, tappedIndex in
previewItem = PreviewItem(images: images, index: tappedIndex)
}
)
//
if index == viewStore.moments.count - 1, viewStore.hasMore, !viewStore.isLoadingMore {
Color.clear
.frame(height: 1)
.onAppear {
viewStore.send(.loadMore)
}
}
}
//
if viewStore.isLoadingMore {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.padding(.vertical, 8)
}
//
Color.clear.frame(height: 120)
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 20)
}
}
.refreshable {
viewStore.send(.reload)
}
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .top)
}
}
.onAppear {
viewStore.send(.onAppear)
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("reloadFeedList"))) { _ in
viewStore.send(.reload)
}
.sheet(isPresented: viewStore.binding(
get: \.isEditFeedPresented,
send: { $0 ? .editFeedButtonTapped : .editFeedDismissed }
)) {
EditFeedView(
onDismiss: {
viewStore.send(.editFeedDismissed)
},
store: Store(
initialState: EditFeedFeature.State()
) {
EditFeedFeature()
}
)
}
//
.fullScreenCover(item: $previewItem) { item in
ImagePreviewPager(images: item.images, currentIndex: .constant(item.index)) {
previewItem = nil
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

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,14 +24,25 @@ 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()
.transition(.opacity)
Spacer()
// MeView(
// meDynamicStore: store.scope(
// state: \.meDynamic,
// action: \.meDynamic
// ),
// onLogout: onLogout
// )
// .transition(.opacity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -46,22 +58,31 @@ struct HomeView: View {
.onAppear {
store.send(.onAppear)
}
.sheet(isPresented: Binding(
get: { store.isSettingPresented },
set: { _ in store.send(.settingDismissed) }
.navigationDestination(isPresented: Binding(
get: { store.withState(\.route) == .createFeed },
set: { isPresented in
if !isPresented {
store.send(.createFeedDismissed)
}
}
)) {
SettingView(store: store.scope(state: \.settingState, action: \.setting))
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

@@ -0,0 +1,87 @@
import SwiftUI
enum ImagePreviewSource: Identifiable, Equatable {
case local(UIImage)
case remote(String)
var id: String {
switch self {
case .local(let img):
return String(describing: img.hashValue)
case .remote(let url):
return url
}
}
static func == (lhs: ImagePreviewSource, rhs: ImagePreviewSource) -> Bool {
switch (lhs, rhs) {
case let (.local(l), .local(r)):
return l.pngData() == r.pngData()
case let (.remote(l), .remote(r)):
return l == r
default:
return false
}
}
}
struct ImagePreviewPager: View {
let images: [ImagePreviewSource]
@Binding var currentIndex: Int
let onClose: () -> Void
//
init(images: [UIImage], currentIndex: Binding<Int>, onClose: @escaping () -> Void) {
self.images = images.map { .local($0) }
self._currentIndex = currentIndex
self.onClose = onClose
}
//
init(images: [String], currentIndex: Binding<Int>, onClose: @escaping () -> Void) {
self.images = images.map { .remote($0) }
self._currentIndex = currentIndex
self.onClose = onClose
}
var body: some View {
ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea()
TabView(selection: $currentIndex) {
ForEach(Array(images.enumerated()), id: \ .element.id) { idx, source in
Group {
switch source {
case .local(let img):
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
case .remote(let urlStr):
CachedAsyncImage(url: urlStr) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.3))
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
.scaleEffect(0.8)
)
}
}
}
.tag(idx)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
Button(action: { onClose() }) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 32))
.foregroundColor(.white)
.padding(24)
}
}
}
}

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

129
yana/Views/MainView.swift Normal file
View File

@@ -0,0 +1,129 @@
import SwiftUI
import ComposableArchitecture
struct MainView: View {
let store: StoreOf<MainFeature>
var onLogout: (() -> Void)? = nil
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
InternalMainView(store: store)
.onChange(of: viewStore.isLoggedOut) { isLoggedOut in
if isLoggedOut {
onLogout?()
}
}
}
}
}
}
struct InternalMainView: View {
let store: StoreOf<MainFeature>
@State private var path: [MainFeature.Destination] = []
init(store: StoreOf<MainFeature>) {
self.store = store
_path = State(initialValue: store.withState { $0.navigationPath })
}
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
WithPerceptionTracking {
NavigationStack(path: $path) {
GeometryReader { geometry in
contentView(geometry: geometry, viewStore: viewStore)
.navigationDestination(for: MainFeature.Destination.self) { destination in
DestinationView(destination: destination, store: self.store)
}
.onChange(of: path) { newPath in
viewStore.send(.navigationPathChanged(newPath))
}
.onChange(of: viewStore.navigationPath) { newPath in
if path != newPath {
path = newPath
}
}
.onAppear {
viewStore.send(.onAppear)
}
}
}
}
}
}
struct DestinationView: View {
let destination: MainFeature.Destination
let store: StoreOf<MainFeature>
var body: some View {
switch destination {
case .appSetting:
IfLetStore(
store.scope(state: \.appSettingState, action: \.appSettingAction),
then: { store in
WithPerceptionTracking {
AppSettingView(store: store)
}
},
else: { Text("appSettingState is nil") }
)
case .testView:
TestView()
}
}
}
private func contentView(geometry: GeometryProxy, viewStore: ViewStoreOf<MainFeature>) -> some View {
WithPerceptionTracking {
ZStack {
//
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
//
MainContentView(
store: store,
selectedTab: viewStore.selectedTab
)
.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)
}
}
}
}
struct MainContentView: View {
let store: StoreOf<MainFeature>
let selectedTab: MainFeature.Tab
var body: some View {
Group {
if selectedTab == .feed {
FeedListView(store: store.scope(
state: \.feedList,
action: \.feedList
))
} else if selectedTab == .other {
MeView(
store: store.scope(
state: \.me,
action: \.me
)
)
} else {
EmptyView()
}
}
}
}

View File

@@ -1,138 +1,151 @@
import SwiftUI
import ComposableArchitecture
struct MeView: View {
@State private var showLogoutConfirmation = false
let store: StoreOf<MeFeature>
//
@State private var previewItem: PreviewItem? = nil
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 20) {
//
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.ignoresSafeArea(.all)
//
VStack {
HStack {
Spacer()
Text("我的")
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
Spacer()
}
.padding(.top, geometry.safeAreaInsets.top + 20)
//
VStack(spacing: 16) {
Circle()
.fill(Color.white.opacity(0.2))
.frame(width: 80, height: 80)
.overlay(
Image(systemName: "person.fill")
.font(.system(size: 40))
WithViewStore(self.store, observe: { $0 }) { viewStore in
Button(action: {
viewStore.send(.settingButtonTapped)
}) {
Image(systemName: "gearshape")
.font(.system(size: 33, weight: .medium))
.foregroundColor(.white)
)
Text("用户昵称")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.white)
Text("ID: 123456789")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
}
.padding(.top, 30)
//
VStack(spacing: 12) {
MenuItemView(icon: "gearshape", title: "设置", action: {})
MenuItemView(icon: "person.circle", title: "个人信息", action: {})
MenuItemView(icon: "heart", title: "我的收藏", action: {})
MenuItemView(icon: "clock", title: "浏览历史", action: {})
MenuItemView(icon: "questionmark.circle", title: "帮助与反馈", action: {})
}
.padding(.horizontal, 20)
.padding(.top, 40)
// 退
Button(action: {
showLogoutConfirmation = true
}) {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.font(.system(size: 16))
Text("退出登录")
.font(.system(size: 16, weight: .medium))
}
.padding(.trailing, 16)
.padding(.top, 8)
}
.foregroundColor(.red)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
Color.white.opacity(0.1)
.cornerRadius(12)
)
}
.padding(.horizontal, 20)
.padding(.top, 30)
// -
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
Spacer()
}
VStack(spacing: 16) {
//
WithViewStore(self.store, observe: { $0 }) { viewStore in
if viewStore.isLoadingUserInfo {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.frame(height: 130)
} else if let error = viewStore.userInfoError {
Text(error)
.font(.system(size: 14))
.foregroundColor(.red)
.frame(height: 130)
} else if let userInfo = viewStore.userInfo {
VStack(spacing: 8) {
if let avatarUrl = userInfo.avatar, !avatarUrl.isEmpty {
AsyncImage(url: URL(string: avatarUrl)) { image in
image.resizable().aspectRatio(contentMode: .fill).clipShape(Circle())
} placeholder: {
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
}
.frame(width: 90, height: 90)
} else {
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
.frame(width: 90, height: 90)
}
Text(userInfo.nick ?? "用户昵称")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.white)
Text("ID: \(userInfo.uid ?? 0)")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
}
.padding(.top, 0)
.frame(height: 130)
} else {
Spacer().frame(height: 130)
}
}
//
WithViewStore(self.store, observe: { $0 }) { viewStore in
if viewStore.isLoadingMoments && viewStore.moments.isEmpty {
ProgressView("加载中...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewStore.momentsError {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 40))
.foregroundColor(.yellow)
Text(error)
.foregroundColor(.red)
Button("重试") {
viewStore.send(.onAppear)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewStore.moments.isEmpty {
VStack(spacing: 16) {
Image(systemName: "tray")
.font(.system(size: 40))
.foregroundColor(.gray)
Text("暂无动态")
.foregroundColor(.white.opacity(0.8))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
WithPerceptionTracking {
LazyVStack(spacing: 12) {
ForEach(viewStore.moments.indices, id: \ .self) { index in
let moment = viewStore.moments[index]
OptimizedDynamicCardView(
moment: moment,
allMoments: viewStore.moments,
currentIndex: index,
onImageTap: { images, tappedIndex in
previewItem = PreviewItem(images: images, index: tappedIndex)
}
)
.padding(.horizontal, 12)
}
if viewStore.hasMore {
ProgressView()
.onAppear {
viewStore.send(.loadMore)
}
}
//
Color.clear.frame(height: 120)
}
.padding(.top, 8)
}
}
.refreshable {
viewStore.send(.refresh)
}
}
}
Spacer()
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.top, 8)
}
}
.onAppear {
ViewStore(self.store, observe: { $0 }).send(.onAppear)
}
//
.fullScreenCover(item: $previewItem) { item in
ImagePreviewPager(images: item.images, currentIndex: .constant(item.index)) {
previewItem = nil
}
}
}
.ignoresSafeArea(.container, edges: .top)
.alert("确认退出", isPresented: $showLogoutConfirmation) {
Button("取消", role: .cancel) { }
Button("退出", role: .destructive) {
performLogout()
}
} message: {
Text("确定要退出登录吗?")
}
}
// MARK: - 退
private func performLogout() {
debugInfo("🔓 开始执行退出登录...")
// keychain
UserInfoManager.clearAllAuthenticationData()
// window root login view
NotificationCenter.default.post(name: .homeLogout, object: nil)
debugInfo("✅ 退出登录完成")
}
}
// MARK: -
struct MenuItemView: View {
let icon: String
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundColor(.white)
.frame(width: 24)
Text(title)
.font(.system(size: 16))
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right")
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.6))
}
.padding(.horizontal, 20)
.frame(height: 56)
.background(
Color.white.opacity(0.1)
.cornerRadius(12)
)
}
.buttonStyle(PlainButtonStyle())
}
}
#Preview {
MeView()
}

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

@@ -1,199 +0,0 @@
import SwiftUI
import ComposableArchitecture
struct SettingView: View {
let store: StoreOf<SettingFeature>
@ObservedObject private var localizationManager = LocalizationManager.shared
var body: some View {
WithPerceptionTracking {
GeometryReader { geometry in
ZStack {
// - 使"bg"
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
.ignoresSafeArea(.all)
VStack(spacing: 0) {
// Navigation Bar
HStack {
//
Button(action: {
store.send(.dismissTapped)
}) {
Image(systemName: "chevron.left")
.font(.title2)
.foregroundColor(.white)
}
.padding(.leading, 16)
Spacer()
//
Text("设置")
.font(.custom("PingFang SC-Semibold", size: 16))
.foregroundColor(.white)
Spacer()
//
Color.clear
.frame(width: 44, height: 44)
}
.padding(.top, 8)
.padding(.horizontal)
//
ScrollView {
VStack(spacing: 24) {
//
VStack(spacing: 16) {
//
Image(systemName: "person.circle.fill")
.font(.system(size: 60))
.foregroundColor(.white.opacity(0.8))
//
VStack(spacing: 8) {
if let userInfo = store.userInfo, let userName = userInfo.username {
Text(userName)
.font(.title2.weight(.semibold))
.foregroundColor(.white)
} else {
Text("用户")
.font(.title2.weight(.semibold))
.foregroundColor(.white)
}
// ID
if let userInfo = store.userInfo, let userId = userInfo.userId {
Text("ID: \(userId)")
.font(.caption)
.foregroundColor(.white.opacity(0.8))
} else if let accountModel = store.accountModel, let uid = accountModel.uid {
Text("UID: \(uid)")
.font(.caption)
.foregroundColor(.white.opacity(0.8))
}
}
}
.padding(.vertical, 24)
.padding(.horizontal, 20)
.background(Color.black.opacity(0.3))
.cornerRadius(16)
.padding(.horizontal, 24)
.padding(.top, 32)
//
VStack(spacing: 12) {
//
SettingRowView(
icon: "globe",
title: "语言设置",
action: {
// TODO:
}
)
//
SettingRowView(
icon: "info.circle",
title: "关于我们",
action: {
// TODO:
}
)
//
HStack {
Image(systemName: "app.badge")
.foregroundColor(.white.opacity(0.8))
.frame(width: 24)
Text("版本信息")
.foregroundColor(.white)
Spacer()
Text("1.0.0")
.foregroundColor(.white.opacity(0.6))
.font(.caption)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.background(Color.black.opacity(0.2))
.cornerRadius(12)
}
.padding(.horizontal, 24)
Spacer(minLength: 50)
// 退
Button(action: {
store.send(.logoutTapped)
}) {
HStack {
Image(systemName: "arrow.right.square")
Text("退出登录")
}
.font(.body.weight(.medium))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.red.opacity(0.7))
.cornerRadius(12)
}
.padding(.horizontal, 24)
.padding(.bottom, 50)
}
}
}
}
}
.onAppear {
store.send(.onAppear)
}
}
}
}
// MARK: - Setting Row View
struct SettingRowView: View {
let icon: String
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack {
Image(systemName: icon)
.foregroundColor(.white.opacity(0.8))
.frame(width: 24)
Text(title)
.foregroundColor(.white)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.white.opacity(0.6))
.font(.caption)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.background(Color.black.opacity(0.2))
.cornerRadius(12)
}
}
}
//#Preview {
// SettingView(
// store: Store(
// initialState: SettingFeature.State()
// ) {
// SettingFeature()
// }
// )
//}

View File

@@ -6,29 +6,39 @@ 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()
},
onLogout: {
store.send(.navigateToLogin)
}
)
}
} else {
//
splashContent
}
}
.onAppear {
@@ -36,14 +46,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(NSLocalizedString("splash.title", comment: "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

@@ -0,0 +1,61 @@
import SwiftUI
struct TestView: View {
var body: some View {
ZStack {
//
Color.purple.ignoresSafeArea()
VStack(spacing: 30) {
//
Text("测试页面")
.font(.system(size: 32, weight: .bold))
.foregroundColor(.white)
//
Text("这是一个测试用的页面\n用于验证导航跳转功能")
.font(.system(size: 18))
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.center)
//
Button(action: {
debugInfoSync("[LOG] TestView button tapped")
}) {
Text("测试按钮")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.purple)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color.white)
.cornerRadius(8)
}
Spacer()
}
.padding(.top, 100)
}
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: {
debugInfoSync("[LOG] TestView back button tapped")
}) {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .medium))
Text("返回")
.font(.system(size: 16))
}
.foregroundColor(.white)
}
}
}
}
}
#Preview {
NavigationStack {
TestView()
}
}

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,13 +163,78 @@ 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)")
}
}
}
// MARK: - User Info API Tests
extension yanaAPITests {
func testGetUserInfoRequest() {
//
let uid = "12345"
let request = UserInfoHelper.createGetUserInfoRequest(uid: uid)
XCTAssertEqual(request.endpoint, "/user/get", "端点应该正确")
XCTAssertEqual(request.method, .GET, "请求方法应该是GET")
XCTAssertEqual(request.queryParameters?["uid"], uid, "UID参数应该正确")
XCTAssertFalse(request.shouldShowLoading, "不应该显示loading")
XCTAssertFalse(request.shouldShowError, "不应该显示错误")
}
func testGetUserInfoResponse() {
//
let responseData: [String: Any] = [
"code": 200,
"message": "success",
"timestamp": 1640995200000,
"data": [
"user_id": "12345",
"username": "testuser",
"nickname": "测试用户",
"avatar": "https://example.com/avatar.jpg",
"email": "test@example.com",
"phone": "13800138000",
"status": "active",
"create_time": "2024-01-01 00:00:00",
"update_time": "2024-01-01 00:00:00"
]
]
do {
let jsonData = try JSONSerialization.data(withJSONObject: responseData)
let response = try JSONDecoder().decode(GetUserInfoResponse.self, from: jsonData)
XCTAssertTrue(response.isSuccess, "响应应该成功")
XCTAssertEqual(response.code, 200, "状态码应该正确")
XCTAssertNotNil(response.data, "用户信息数据应该存在")
if let userInfo = response.data {
XCTAssertEqual(userInfo.userId, "12345", "用户ID应该正确")
XCTAssertEqual(userInfo.username, "testuser", "用户名应该正确")
XCTAssertEqual(userInfo.nickname, "测试用户", "昵称应该正确")
}
debugInfoSync("✅ 用户信息响应解析测试通过")
} catch {
XCTFail("解析用户信息响应失败: \(error)")
}
}
func testUserInfoHelper() {
// UserInfoHelper
let uid = "67890"
UserInfoHelper.debugGetUserInfoRequest(uid: uid)
let request = UserInfoHelper.createGetUserInfoRequest(uid: uid)
XCTAssertEqual(request.queryParameters?["uid"], uid, "Helper创建的请求应该正确")
}
}

View File

@@ -175,11 +175,13 @@ xcodebuild -workspace yana.xcworkspace -scheme yana -configuration Debug build
```
### 关键代码修改
1. **HomeFeature.swift**: 添加设置相关状态管理
2. **HomeView.swift**: 修复 TCA store 绑定语法
3. **SettingFeature.swift**: 确保 Action 完整性
### 构建结果
**编译成功**: Exit code 0
⚠️ **警告信息**: 仅 Swift 6 兼容性警告,不影响运行
@@ -188,18 +190,21 @@ xcodebuild -workspace yana.xcworkspace -scheme yana -configuration Debug build
## 预防措施
### 开发规范
1. **统一包管理**: 优先使用一种包管理工具
2. **定期清理**: 定期清理 DerivedData 避免缓存问题
3. **代码审查**: 确保 TCA Feature 结构完整
4. **版本控制**: 及时提交关键配置文件
### 监控指标
- [ ] 项目编译时间 < 30s
- [ ] 无编译错误
- [ ] 依赖解析正常
- [ ] TCA 结构完整
### 工具使用
```bash
# 项目健康检查脚本
check_project() {
@@ -255,6 +260,7 @@ pod install --clean-install
## 总结
本次问题解决涉及以下关键技术点
1. **Xcode 项目配置管理**
2. **Swift Package Manager 与 CocoaPods 共存**
3. **TCA (The Composable Architecture) 最佳实践**
@@ -265,5 +271,5 @@ pod install --clean-install
---
**文档更新时间**: 2025-07-10
**适用版本**: iOS 15.6+, Swift 6, TCA 1.20.2+
**维护者**: AI Assistant & 开发团队
**适用版本**: iOS 16+, Swift 6, TCA 1.20.2+
**维护者**: AI Assistant & 开发团队