48 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
edwinQQQ
f686480cdc feat: 添加图片缓存系统和优化FeedView组件
- 在yana/Utils中新增ImageCacheManager类,提供内存和磁盘缓存功能,支持图片的异步加载和预加载。
- 更新FeedView,使用优化后的动态卡片组件OptimizedDynamicCardView,集成图片缓存,提升用户体验。
- 在yana/yana-Bridging-Header.h中引入CommonCrypto以支持MD5哈希。
- 更新FeedFeature以增加动态请求的页面大小,提升数据加载效率。
- 删除不再使用的data.txt文件,保持项目整洁。
2025-07-11 20:18:36 +08:00
edwinQQQ
12bb4a5f8c feat: 更新Podfile和Podfile.lock,添加最新动态API文档和相关功能
- 在Podfile中添加Alamofire依赖,并更新Podfile.lock以反映更改。
- 新增动态内容API文档,详细描述`dynamic/square/latestDynamics`接口的请求参数、响应数据结构及示例。
- 实现动态内容的模型和API请求结构,支持获取最新动态列表。
- 更新FeedView和HomeView以集成动态内容展示,增强用户体验。
- 添加动态卡片组件,展示用户动态信息及互动功能。
2025-07-11 20:18:24 +08:00
edwinQQQ
f9f3dec53f feat: 更新Podfile和Podfile.lock,移除Alamofire依赖并添加API认证机制文档
- 注释掉Podfile中的Alamofire依赖,更新Podfile.lock以反映更改。
- 在yana/APIs/API-README.md中新增自动认证Header机制的详细文档,描述其工作原理、实现细节及最佳实践。
- 在yana/yanaApp.swift中将print语句替换为debugInfo以增强调试信息的输出。
- 在API相关文件中实现用户认证状态检查和相关header的自动添加逻辑,提升API请求的安全性和用户体验。
- 更新多个文件中的日志输出,确保在DEBUG模式下提供详细的调试信息。
2025-07-11 16:53:46 +08:00
edwinQQQ
750eecf6ff feat: 更新FeedView、HomeView和MeView以增强用户界面和交互体验
- 在FeedView中添加加号按钮,允许用户进行操作。
- 更新HomeView以支持全屏显示和更好的布局。
- 在MeView中优化用户信息展示,增加用户ID显示。
- 调整底部导航栏样式,提升视觉效果和用户体验。
- 确保视图在安全区域内适配,增强整体布局的适应性。
2025-07-11 12:01:47 +08:00
edwinQQQ
9844289d72 feat: 添加设置功能和动态视图
- 新增设置功能模块,包含用户信息管理和设置选项。
- 实现动态视图,展示用户动态内容。
- 更新HomeView以支持设置页面的展示和动态视图的切换。
- 添加底部导航栏,增强用户体验。
- 更新相关视图和组件,确保一致的UI风格和交互体验。
2025-07-11 10:42:28 +08:00
edwinQQQ
4a1b814902 feat: 实现数据迁移和用户信息管理优化
- 在AppDelegate中集成数据迁移管理器,支持从UserDefaults迁移到Keychain。
- 重构UserInfoManager,使用Keychain存储用户信息,增加内存缓存以提升性能。
- 添加API加载效果视图,增强用户体验。
- 更新SplashFeature以支持自动登录和认证状态检查。
- 语言设置迁移至Keychain,确保用户设置的安全性。
2025-07-10 17:20:20 +08:00
edwinQQQ
6084ade9ea 补充重置密码功能 2025-07-10 14:30:52 +08:00
edwinQQQ
e45ad3bad5 feat: 增强邮箱登录功能和密码恢复流程
- 更新邮箱登录相关功能,新增邮箱验证码获取和登录API端点。
- 添加AccountModel以管理用户认证信息,支持会话票据的存储和更新。
- 实现密码恢复功能,支持通过邮箱获取验证码和重置密码。
- 增加本地化支持,更新相关字符串以适应新功能。
- 引入ValidationHelper以验证邮箱和密码格式,确保用户输入的有效性。
- 更新视图以支持邮箱登录和密码恢复的用户交互。
2025-07-10 14:00:58 +08:00
edwinQQQ
c470dba79c feat: 更新项目配置和功能模块
- 修改Package.swift以支持iOS 15和macOS 12。
- 更新swift-tca-architecture-guidelines.mdc中的alwaysApply设置为false。
- 注释掉AppDelegate中的NIMSDK导入,移除不再使用的NIMConfigurationManager和NIMSessionManager文件。
- 添加新的API相关文件,包括EMailLoginFeature、IDLoginFeature和相关视图,增强登录功能。
- 更新APIConstants和APIEndpoints以反映新的API路径。
- 添加本地化支持文件,包含英文和中文简体的本地化字符串。
- 新增字体管理和安全工具类,支持AES和DES加密。
- 更新Xcode项目配置,调整版本号和启动画面设置。
2025-07-09 16:14:19 +08:00
edwinQQQ
5926906f3c first commit for e-party 2025-07-07 14:19:07 +08:00
149 changed files with 16072 additions and 968 deletions

View File

@@ -3,56 +3,33 @@ description:
globs:
alwaysApply: true
---
# CONTEXT
I am a native Chinese speaker who has just begun learning Swift 6 and Xcode 16, and I am enthusiastic about exploring new technologies. I wish to receive advice using the latest tools and
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.
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
## OBJECTIVE
As an expert AI programming assistant, your task is to provide me with clear and readable SwiftUI code. You should:
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 and Swift, being familiar with the newest features and best practices.
- 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
## 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 16, seeking guidance and advice on utilizing the latest technologies.
---
# RESPONSE FORMAT
## RESPONSE FORMAT
- **Utilize the Chain-of-Thought (CoT) method to reason and respond, explaining your thought process step by step.**
- Conduct reasoning, thinking, and code writing in English.
- The final reply should translate the English into Chinese for me.
- The reply should include:
1. **Step-by-Step Plan**: Describe the implementation process with detailed pseudocode or step-by-step explanations, showcasing your thought process.
2. **Code Implementation**: Provide correct, up-to-date, error-free, fully functional, runnable, secure, and efficient code. The code should:
- Include all necessary imports and properly name key components.
@@ -60,10 +37,3 @@ alwaysApply: true
3. **Concise Response**: Minimize unnecessary verbosity, focusing only on essential information.
- If a correct answer may not exist, please point it out. If you do not know the answer, please honestly inform me rather than guessing.
---
# START ANALYSIS
If you understand, please prepare to assist me and await my question.

View File

@@ -1,12 +1,11 @@
---
description:
globs:
alwaysApply: true
alwaysApply: false
---
You are an expert iOS developer using Swift and SwiftUI. Follow these guidelines:
# Architechture
- Use TCA(The Composable Architecture) architecture with SwiftUI & Swift
- Don't use TCA for UI Navigation
# Code Structure
- Use Swift's latest features and protocol-oriented programming

View File

@@ -1,7 +1,5 @@
---
description:
globs:
alwaysApply: true
alwaysApply: false
---
# TCA Architecture Guidelines
- Use The Composable Architecture (TCA) for state management and side effect handling.

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

626
API-README.md Normal file
View File

@@ -0,0 +1,626 @@
# Yana iOS API 使用指南
## 📋 目录
- [架构概览](#架构概览)
- [快速开始](#快速开始)
- [环境配置](#环境配置)
- [请求方式](#请求方式)
- [错误处理](#错误处理)
- [安全机制](#安全机制)
- [最佳实践](#最佳实践)
- [API接口列表](#api接口列表)
- [示例代码](#示例代码)
## 🏗️ 架构概览
Yana iOS 项目采用基于 **TCA (The Composable Architecture)** 的现代化 API 架构设计:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ SwiftUI View │───▶│ TCA Reducer │───▶│ API Service │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ App State │ │ Network Layer │
└─────────────────┘ └─────────────────┘
```
### 核心组件
- **APIService**: 网络请求核心服务
- **APIModels**: 数据模型和协议定义
- **APIEndpoints**: API 端点配置
- **APILogger**: 请求日志记录
- **BaseRequest**: 基础请求参数管理
## 🚀 快速开始
### 1. 基本使用
```swift
import SwiftUI
struct ContentView: View {
@State private var isLoading = false
@State private var result = ""
var body: some View {
VStack {
Button("发起 API 请求") {
Task {
await makeAPIRequest()
}
}
.disabled(isLoading)
if isLoading {
ProgressView("请求中...")
}
Text(result)
}
}
private func makeAPIRequest() async {
isLoading = true
do {
// 创建 API 服务实例
let apiService = LiveAPIService()
// 创建请求
let request = ConfigRequest()
// 发起请求
let response = try await apiService.request(request)
await MainActor.run {
result = "请求成功: \(response)"
isLoading = false
}
} catch {
await MainActor.run {
result = "请求失败: \(error.localizedDescription)"
isLoading = false
}
}
}
}
```
### 2. TCA 集成使用
```swift
import ComposableArchitecture
@Reducer
struct APIFeature {
@ObservableState
struct State: Equatable {
var data: ConfigResponse?
var isLoading = false
var errorMessage: String?
}
enum Action: Equatable {
case loadConfig
case configLoaded(ConfigResponse)
case loadingFailed(String)
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .loadConfig:
state.isLoading = true
state.errorMessage = nil
return .run { send in
do {
let request = ConfigRequest()
let response = try await apiService.request(request)
await send(.configLoaded(response))
} catch {
await send(.loadingFailed(error.localizedDescription))
}
}
case let .configLoaded(response):
state.isLoading = false
state.data = response
return .none
case let .loadingFailed(error):
state.isLoading = false
state.errorMessage = error
return .none
}
}
}
}
```
## ⚙️ 环境配置
### 服务器环境
| 环境 | 地址 | 说明 |
|------|------|------|
| 测试环境 | `http://beta.api.molistar.xyz` | 开发测试服务器 |
| 生产环境 | `https://api.hfighting.com` | 正式服务器 |
### 配置参数
```swift
struct APIConfiguration {
static let baseURL = "http://beta.api.molistar.xyz"
static let timeout: TimeInterval = 30.0
static let maxDataSize: Int = 50 * 1024 * 1024 // 50MB
}
```
### 默认请求头
所有请求都会自动添加以下请求头:
```swift
static var defaultHeaders: [String: String] {
return [
"Content-Type": "application/json",
"Accept": "application/json",
"Accept-Encoding": "gzip, br",
"Accept-Language": Locale.current.languageCode ?? "en",
"App-Version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
]
}
```
## 📡 请求方式
### 1. GET 请求
```swift
struct ConfigRequest: APIRequestProtocol {
typealias Response = ConfigResponse
let endpoint = "/client/config"
let method: HTTPMethod = .GET
let includeBaseParameters = true
let queryParameters: [String: String]? = nil
let bodyParameters: [String: Any]? = nil
let headers: [String: String]? = nil
let timeout: TimeInterval = 30.0
}
```
### 2. POST 请求
```swift
struct LoginRequest: APIRequestProtocol {
typealias Response = LoginResponse
let endpoint = "/auth/login"
let method: HTTPMethod = .POST
let includeBaseParameters = true
let queryParameters: [String: String]? = nil
let bodyParameters: [String: Any]?
let headers: [String: String]? = nil
let timeout: TimeInterval = 30.0
init(username: String, password: String) {
self.bodyParameters = [
"username": username,
"password": password
]
}
}
```
### 3. 基础参数说明
每个请求都会自动包含以下基础参数:
```swift
struct BaseRequest {
let acceptLanguage: String // 用户语言偏好
let os: String = "iOS" // 操作系统类型
let osVersion: String // 系统版本号
let netType: Int // 网络类型 (WiFi=2, 蜂窝=1)
let ispType: String // 运营商类型
let channel: String // 应用分发渠道
let model: String // 设备型号
let deviceId: String // 设备唯一标识
let appVersion: String // 应用版本
let app: String // 应用名称
let lang: String // 语言代码
let mcc: String? // 移动国家代码
let pubSign: String // 安全签名
}
```
## ❌ 错误处理
### 错误类型
```swift
enum APIError: Error, Equatable {
case invalidURL // 无效的 URL
case noData // 没有收到数据
case decodingError(String) // 数据解析失败
case networkError(String) // 网络错误
case httpError(statusCode: Int, message: String?) // HTTP 错误
case timeout // 请求超时
case resourceTooLarge // 响应数据过大
case unknown(String) // 未知错误
}
```
### 错误处理示例
```swift
do {
let response = try await apiService.request(request)
// 处理成功响应
} catch let apiError as APIError {
switch apiError {
case .networkError(let message):
print("网络错误: \(message)")
case .timeout:
print("请求超时,请检查网络连接")
case .httpError(let statusCode, let message):
print("服务器错误 \(statusCode): \(message ?? "未知错误")")
case .decodingError(let message):
print("数据解析失败: \(message)")
default:
print("其他错误: \(apiError.localizedDescription)")
}
} catch {
print("未知错误: \(error)")
}
```
## 🔐 安全机制
### 签名生成流程
1. **参数过滤**: 移除系统级参数
2. **参数排序**: 按字典 key 升序排序
3. **字符串拼接**: `"key0=value0&key1=value1"`
4. **添加密钥**: 拼接 `key=rpbs6us1m8r2j9g6u06ff2bo18orwaya`
5. **MD5加密**: 生成大写 MD5 签名
### 认证头部
```swift
// 用户认证相关头部(如果用户已登录)
if let userId = UserInfoManager.getCurrentUserId() {
headers["pub_uid"] = userId
}
if let userTicket = UserInfoManager.getCurrentUserTicket() {
headers["pub_ticket"] = userTicket
}
```
## 💡 最佳实践
### 1. 错误处理
```swift
// ✅ 推荐:完整的错误处理
do {
let response = try await apiService.request(request)
// 处理成功响应
} catch let urlError as URLError {
switch urlError.code {
case .notConnectedToInternet:
showAlert("网络不可用,请检查网络连接")
case .timedOut:
showAlert("请求超时,请重试")
default:
showAlert("网络错误: \(urlError.localizedDescription)")
}
} catch let apiError as APIError {
showAlert(apiError.localizedDescription)
} catch {
showAlert("未知错误: \(error)")
}
```
### 2. 主线程更新 UI
```swift
// ✅ 推荐:使用 MainActor 更新 UI
await MainActor.run {
self.isLoading = false
self.data = response
}
```
### 3. 取消请求
```swift
// ✅ 推荐:支持取消的请求
struct ContentView: View {
@State private var task: Task<Void, Never>?
private func makeRequest() {
task = Task {
do {
let response = try await apiService.request(request)
// 处理响应
} catch {
if !Task.isCancelled {
// 处理错误
}
}
}
}
private func cancelRequest() {
task?.cancel()
}
}
```
### 4. 重试机制
```swift
// ✅ 推荐:实现重试逻辑
func requestWithRetry<T: APIRequestProtocol>(
_ request: T,
maxRetries: Int = 3
) async throws -> T.Response {
var lastError: Error?
for attempt in 0..<maxRetries {
do {
return try await apiService.request(request)
} catch {
lastError = error
if attempt < maxRetries - 1 {
try await Task.sleep(nanoseconds: UInt64(pow(2.0, Double(attempt))) * 1_000_000_000)
}
}
}
throw lastError!
}
```
## 📋 API接口列表
### 当前可用接口
| 接口名称 | 端点 | 方法 | 说明 |
|---------|------|------|------|
| 配置获取 | `/client/config` | GET | 获取客户端配置信息 |
| 初始化配置 | `/client/init` | GET | 获取初始化配置 |
| 用户登录 | `/auth/login` | POST | 用户登录认证 |
### 接口扩展
要添加新的 API 接口,请按以下步骤操作:
1. **在 `APIEndpoints.swift` 中添加端点**:
```swift
enum APIEndpoint: String, CaseIterable {
case newEndpoint = "/new/endpoint"
// ...
}
```
2. **创建请求模型**:
```swift
struct NewRequest: APIRequestProtocol {
typealias Response = NewResponse
let endpoint = "/new/endpoint"
let method: HTTPMethod = .POST
let includeBaseParameters = true
// ... 其他属性
}
```
3. **创建响应模型**:
```swift
struct NewResponse: Codable {
let success: Bool
let data: SomeDataModel?
let message: String?
}
```
## 📝 示例代码
### 完整的登录功能示例
```swift
import SwiftUI
import ComposableArchitecture
// MARK: - Login Feature
@Reducer
struct LoginFeature {
@ObservableState
struct State: Equatable {
var username = ""
var password = ""
var isLoading = false
var isLoggedIn = false
var errorMessage: String?
}
enum Action: Equatable {
case usernameChanged(String)
case passwordChanged(String)
case loginButtonTapped
case loginResponse(Result<LoginResponse, APIError>)
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .usernameChanged(username):
state.username = username
return .none
case let .passwordChanged(password):
state.password = password
return .none
case .loginButtonTapped:
state.isLoading = true
state.errorMessage = nil
let request = LoginRequest(
username: state.username,
password: state.password
)
return .run { send in
do {
let response = try await apiService.request(request)
await send(.loginResponse(.success(response)))
} catch let error as APIError {
await send(.loginResponse(.failure(error)))
} catch {
await send(.loginResponse(.failure(.unknown(error.localizedDescription))))
}
}
case let .loginResponse(.success(response)):
state.isLoading = false
state.isLoggedIn = response.success
if !response.success {
state.errorMessage = response.message
}
return .none
case let .loginResponse(.failure(error)):
state.isLoading = false
state.errorMessage = error.localizedDescription
return .none
}
}
}
}
// MARK: - Login View
struct LoginView: View {
let store: StoreOf<LoginFeature>
var body: some View {
WithViewStore(self.store, observe: { $0 }) { viewStore in
VStack(spacing: 20) {
TextField("用户名", text: viewStore.binding(
get: \.username,
send: LoginFeature.Action.usernameChanged
))
.textFieldStyle(RoundedBorderTextFieldStyle())
SecureField("密码", text: viewStore.binding(
get: \.password,
send: LoginFeature.Action.passwordChanged
))
.textFieldStyle(RoundedBorderTextFieldStyle())
Button("登录") {
viewStore.send(.loginButtonTapped)
}
.disabled(viewStore.isLoading || viewStore.username.isEmpty || viewStore.password.isEmpty)
if viewStore.isLoading {
ProgressView("登录中...")
}
if let errorMessage = viewStore.errorMessage {
Text(errorMessage)
.foregroundColor(.red)
}
}
.padding()
}
}
}
// MARK: - Request & Response Models
struct LoginRequest: APIRequestProtocol {
typealias Response = LoginResponse
let endpoint = "/auth/login"
let method: HTTPMethod = .POST
let includeBaseParameters = true
let queryParameters: [String: String]? = nil
let bodyParameters: [String: Any]?
let headers: [String: String]? = nil
let timeout: TimeInterval = 30.0
init(username: String, password: String) {
self.bodyParameters = [
"username": username,
"password": password
]
}
}
struct LoginResponse: Codable {
let success: Bool
let message: String?
let data: UserData?
}
struct UserData: Codable {
let userId: String
let username: String
let token: String
}
```
## 🔧 调试和日志
### 启用详细日志
API 请求和响应会自动记录到控制台,包括:
- 请求 URL 和参数
- 请求头信息
- 响应状态码和数据
- 请求耗时
- 错误信息
### 日志示例
```
🚀 API Request: GET /client/config
📋 Headers: ["Content-Type": "application/json", "Accept": "application/json"]
📊 Query Parameters: ["deviceId": "ABC123", "appVersion": "1.0.0"]
✅ API Response: 200 OK (0.45s)
📦 Response Size: 1.2KB
📄 Response Data: {"success": true, "data": {...}}
```
## 📚 相关文档
- [API 规则详解](yana/APIs/API%20rule.md)
- [集成指南](yana/APIs/Integration-Guide.md)
- [TCA 官方文档](https://github.com/pointfreeco/swift-composable-architecture)
## 🤝 贡献指南
1. 遵循现有的代码风格和架构模式
2. 为新的 API 接口添加完整的文档和示例
3. 确保所有请求都包含适当的错误处理
4. 添加单元测试覆盖新功能
5. 更新相关文档
---
**注意**: 本文档基于当前项目架构编写,如有架构变更请及时更新文档内容。

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

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

19
Podfile
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
@@ -7,16 +7,19 @@ target 'yana' do
# Pods for yana
# IM 即时通讯
pod 'NIMSDK_LITE'
# 基础库
pod 'NEChatKit', '10.6.1'
pod 'NEChatUIKit', '10.6.1' # 会话(聊天)组件
pod 'NEContactUIKit', '10.6.1' # 通讯录组件
pod 'NELocalConversationUIKit', '10.6.1' # 本地会话列表组件。
# # IM 即时通讯
# pod 'NIMSDK_LITE'
# # 基础库
# pod 'NEChatKit', '10.6.1'
# pod 'NEChatUIKit', '10.6.1' # 会话(聊天)组件
# pod 'NEContactUIKit', '10.6.1' # 通讯录组件
# pod 'NELocalConversationUIKit', '10.6.1' # 本地会话列表组件。
# Networks
pod 'Alamofire'
# 腾讯云 COS 精简版 SDK
pod 'QCloudCOSXML'
end
post_install do |installer|

View File

@@ -1,127 +1,32 @@
PODS:
- Alamofire (5.10.2)
- CocoaLumberjack (3.8.5):
- CocoaLumberjack/Core (= 3.8.5)
- CocoaLumberjack/Core (3.8.5)
- libwebp (1.5.0):
- libwebp/demux (= 1.5.0)
- libwebp/mux (= 1.5.0)
- libwebp/sharpyuv (= 1.5.0)
- libwebp/webp (= 1.5.0)
- libwebp/demux (1.5.0):
- libwebp/webp
- libwebp/mux (1.5.0):
- libwebp/demux
- libwebp/sharpyuv (1.5.0)
- libwebp/webp (1.5.0):
- libwebp/sharpyuv
- MJRefresh (3.7.5)
- NEChatKit (10.6.1):
- NEChatKit/NOS (= 10.6.1)
- NEChatKit/NOS (10.6.1):
- NECommonKit (= 9.7.2)
- NECoreIM2Kit/NOS (= 1.0.9)
- NEChatUIKit (10.6.1):
- NEChatUIKit/NOS (= 10.6.1)
- NEChatUIKit/NOS (10.6.1):
- MJRefresh (= 3.7.5)
- NEChatKit/NOS
- NECommonUIKit (= 9.7.6)
- SDWebImageSVGKitPlugin
- SDWebImageWebPCoder
- NECommonKit (9.7.2):
- YXAlog
- NECommonUIKit (9.7.6):
- NECommonKit
- SDWebImage
- NEContactUIKit (10.6.1):
- NEContactUIKit/NOS (= 10.6.1)
- NEContactUIKit/NOS (10.6.1):
- MJRefresh (= 3.7.5)
- NEChatKit/NOS
- NECommonUIKit (= 9.7.6)
- NECoreIM2Kit/NOS (1.0.9):
- NECoreKit (= 9.7.5)
- NIMSDK_LITE (= 10.8.20)
- YXAlog (= 1.0.9)
- NECoreKit (9.7.5):
- YXAlog
- NELocalConversationUIKit (10.6.1):
- NELocalConversationUIKit/NOS (= 10.6.1)
- NELocalConversationUIKit/NOS (10.6.1):
- MJRefresh (= 3.7.5)
- NEChatKit/NOS
- NECommonUIKit (= 9.7.6)
- NIMSDK_LITE (10.8.20):
- NIMSDK_LITE/NOS (= 10.8.20)
- YXArtemis_XCFramework
- NIMSDK_LITE/NOS (10.8.20):
- YXArtemis_XCFramework
- SDWebImage (5.21.0):
- SDWebImage/Core (= 5.21.0)
- SDWebImage/Core (5.21.0)
- SDWebImageSVGKitPlugin (1.4.0):
- SDWebImage/Core (~> 5.10)
- SVGKit (~> 3.0)
- SDWebImageWebPCoder (0.14.6):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- SVGKit (3.0.0):
- CocoaLumberjack (~> 3.0)
- YXAlog (1.0.9)
- YXArtemis_XCFramework (1.1.4)
- 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
- NEChatKit (= 10.6.1)
- NEChatUIKit (= 10.6.1)
- NEContactUIKit (= 10.6.1)
- NELocalConversationUIKit (= 10.6.1)
- NIMSDK_LITE
- QCloudCOSXML
SPEC REPOS:
trunk:
- Alamofire
- CocoaLumberjack
- libwebp
- MJRefresh
- NEChatKit
- NEChatUIKit
- NECommonKit
- NECommonUIKit
- NEContactUIKit
- NECoreIM2Kit
- NECoreKit
- NELocalConversationUIKit
- NIMSDK_LITE
- SDWebImage
- SDWebImageSVGKitPlugin
- SDWebImageWebPCoder
- SVGKit
- YXAlog
- YXArtemis_XCFramework
- QCloudCore
- QCloudCOSXML
- QCloudTrack
SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
CocoaLumberjack: 6a459bc897d6d80bd1b8c78482ec7ad05dffc3f0
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
MJRefresh: fdf5e979eb406a0341468932d1dfc8b7f9fce961
NEChatKit: c36d5824242fcbff0790bfa76316faabf09df8df
NEChatUIKit: 8b431a7d1ec5fbe7c4d079b9ae0dc5062cd5e146
NECommonKit: f2359393571fcc105a7fc2fb0367a71319606042
NECommonUIKit: b5373164800ff138dd075abac90e95379603bb60
NEContactUIKit: 532609b8da3d2a7f274489e6e6109c6f8b774505
NECoreIM2Kit: 0faffb84b4a2ac0fcc3705dbf4e72f022c01320f
NECoreKit: 0ccc64f01c8fdc7266f5a4df41de67447db18503
NELocalConversationUIKit: 2f9208763b4f855d3cb3e3e105e733b020594f19
NIMSDK_LITE: 22740bf6e2660cb7bafc40f8293fa04d3a77948e
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
SDWebImageSVGKitPlugin: 7542dd07c344ec3415ded0461a1161a6f087e0c9
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
SVGKit: 1ad7513f8c74d9652f94ed64ddecda1a23864dea
YXAlog: 6fdd73102ba0a16933dd7bef426d6011d913c041
YXArtemis_XCFramework: d298161285aa9cf0c99800b17847dc99aef60617
QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798
QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7
QCloudTrack: 20b79388365b4c8ed150019c82a56f1569f237f8
PODFILE CHECKSUM: 1d74a8886888ebdfb5a6d41769a74dd0a3026dec
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,30 +62,67 @@ yana/
## 主要功能
- 即时通讯
- 会话管理
- 通讯录管理
- 本地会话列表
- **用户认证**
- 邮箱登录流程(带验证码)
- 多种认证方式
- **即时通讯**
- **会话管理**
- **通讯录管理**
- **本地会话列表**
- **云存储集成**
## UI 组件
项目包含多种自定义 UI 组件:
- 自定义登录按钮
- 底部标签导航
- API 调用加载效果
- Web 视图集成
- 图片预览功能
- 屏幕适配工具
## API 使用
项目提供了完整的 API 架构,基于 TCA (The Composable Architecture) 设计:
- 📖 **[API 使用指南](API-README.md)** - 完整的 API 使用文档
- 🔧 **[API 规则详解](yana/APIs/API%20rule.md)** - API 请求配置和安全机制
- 🚀 **[集成指南](yana/APIs/Integration-Guide.md)** - API 集成和最佳实践
### 快速开始
```swift
// 基本 API 请求示例
let apiService = LiveAPIService()
let request = ConfigRequest()
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,7 +10,9 @@
4C4C91092DE85A3E00384527 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */; };
4C4C910B2DE85A4F00384527 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */; };
4C4C91302DE864F000384527 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = 4C4C912F2DE864F000384527 /* ComposableArchitecture */; };
856EF8A28776CEF6CE595B76 /* Pods_yana.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8529F57AF9337F626C670ED /* Pods_yana.framework */; };
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 */
/* Begin PBXContainerItemProxy section */
@@ -24,13 +26,13 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
0977E1E6E883533DD125CAD4 /* Pods-yana.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-yana.debug.xcconfig"; path = "Target Support Files/Pods-yana/Pods-yana.debug.xcconfig"; sourceTree = "<group>"; };
4C3E651F2DB61F7A00E5A455 /* yana.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = yana.app; sourceTree = BUILT_PRODUCTS_DIR; };
4C4C8FBD2DE5AF9200384527 /* yanaAPITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = yanaAPITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; };
4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
977CD030E95CB064179F3A1B /* Pods-yana.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-yana.release.xcconfig"; path = "Target Support Files/Pods-yana/Pods-yana.release.xcconfig"; sourceTree = "<group>"; };
D8529F57AF9337F626C670ED /* Pods_yana.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_yana.framework; sourceTree = BUILT_PRODUCTS_DIR; };
A5E34AF461FA7ADC3DFB5276 /* Pods-yana.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-yana.debug.xcconfig"; path = "Target Support Files/Pods-yana/Pods-yana.debug.xcconfig"; sourceTree = "<group>"; };
E9DBA38268C435BE06F39AE2 /* Pods_yana.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_yana.framework; sourceTree = BUILT_PRODUCTS_DIR; };
EB8184AF9789E3C79FC90DBB /* Pods-yana.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-yana.release.xcconfig"; path = "Target Support Files/Pods-yana/Pods-yana.release.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -65,10 +67,12 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4CE9EFEA2E28FC3B0078D046 /* CasePaths in Frameworks */,
4C4C91092DE85A3E00384527 /* SystemConfiguration.framework in Frameworks */,
856EF8A28776CEF6CE595B76 /* Pods_yana.framework in Frameworks */,
4CE9EFEC2E28FC3B0078D046 /* CasePathsCore in Frameworks */,
4C4C91302DE864F000384527 /* ComposableArchitecture in Frameworks */,
4C4C910B2DE85A4F00384527 /* CoreFoundation.framework in Frameworks */,
DF26704F4C1F2ABA0405A0C3 /* Pods_yana.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -85,7 +89,6 @@
4C3E65162DB61F7A00E5A455 = {
isa = PBXGroup;
children = (
4C4C8FE72DE6F05300384527 /* tools */,
4C55BD992DB64C3C0021505D /* yana */,
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */,
4C3E65202DB61F7A00E5A455 /* Products */,
@@ -103,19 +106,12 @@
name = Products;
sourceTree = "<group>";
};
4C4C8FE72DE6F05300384527 /* tools */ = {
isa = PBXGroup;
children = (
);
path = tools;
sourceTree = "<group>";
};
556C2003CCDA5AC2C56882D0 /* Frameworks */ = {
isa = PBXGroup;
children = (
4C4C910A2DE85A4F00384527 /* CoreFoundation.framework */,
4C4C91082DE85A3E00384527 /* SystemConfiguration.framework */,
D8529F57AF9337F626C670ED /* Pods_yana.framework */,
E9DBA38268C435BE06F39AE2 /* Pods_yana.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -123,8 +119,8 @@
87A8B7A8B4E2D53BA55B66D1 /* Pods */ = {
isa = PBXGroup;
children = (
0977E1E6E883533DD125CAD4 /* Pods-yana.debug.xcconfig */,
977CD030E95CB064179F3A1B /* Pods-yana.release.xcconfig */,
A5E34AF461FA7ADC3DFB5276 /* Pods-yana.debug.xcconfig */,
EB8184AF9789E3C79FC90DBB /* Pods-yana.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@@ -146,12 +142,13 @@
isa = PBXNativeTarget;
buildConfigurationList = 4C3E652A2DB61F7B00E5A455 /* Build configuration list for PBXNativeTarget "yana" */;
buildPhases = (
E0BDB6E67FEFE696E7D48CE4 /* [CP] Check Pods Manifest.lock */,
5EE242260A8B893DC4D6B6DD /* [CP] Check Pods Manifest.lock */,
4C4C90522DE6FCF700384527 /* Headers */,
4C3E651B2DB61F7A00E5A455 /* Sources */,
4C3E651C2DB61F7A00E5A455 /* Frameworks */,
4C3E651D2DB61F7A00E5A455 /* Resources */,
80E012C82442392F08611AA3 /* [CP] Embed Pods Frameworks */,
0C5E1A8360250314246001A5 /* [CP] Embed Pods Frameworks */,
0BECA6AFA61BE910372F6299 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -194,7 +191,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1630;
LastUpgradeCheck = 1630;
LastUpgradeCheck = 1640;
TargetAttributes = {
4C3E651E2DB61F7A00E5A455 = {
CreatedOnToolsVersion = 16.3;
@@ -211,11 +208,13 @@
knownRegions = (
en,
Base,
"zh-Hans",
);
mainGroup = 4C3E65162DB61F7A00E5A455;
minimizedProjectReferenceProxies = 1;
packageReferences = (
4C4C912E2DE864F000384527 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */,
4CE9EFE82E28FC3B0078D046 /* XCRemoteSwiftPackageReference "swift-case-paths" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 4C3E65202DB61F7A00E5A455 /* Products */;
@@ -246,7 +245,28 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
80E012C82442392F08611AA3 /* [CP] Embed Pods Frameworks */ = {
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;
files = (
@@ -267,7 +287,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
E0BDB6E67FEFE696E7D48CE4 /* [CP] Check Pods Manifest.lock */ = {
5EE242260A8B893DC4D6B6DD /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -322,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";
@@ -378,6 +399,7 @@
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 6.0;
};
name = Debug;
};
@@ -386,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";
@@ -434,20 +457,24 @@
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_VERSION = 6.0;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
4C3E652B2DB61F7B00E5A455 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 0977E1E6E883533DD125CAD4 /* Pods-yana.debug.xcconfig */;
baseConfigurationReference = A5E34AF461FA7ADC3DFB5276 /* Pods-yana.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = yana/yana.entitlements;
CODE_SIGN_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;
@@ -464,43 +491,48 @@
"\"${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers\"",
);
INFOPLIST_FILE = yana/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "E-PARTi";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "此App将可发现和连接到您所用网络上的设备。";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“MoliStar”需要您的同意,才可以进行定位服务,访问网络状态";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“eparty”需要您的同意,才可以进行定位服务,访问网络状态";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 20.20.61;
MARKETING_VERSION = 1.0.0;
OTHER_LIBTOOLFLAGS = "-framework \"SystemConfiguration\"";
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yana.yana;
PRODUCT_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;
};
4C3E652C2DB61F7B00E5A455 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 977CD030E95CB064179F3A1B /* Pods-yana.release.xcconfig */;
baseConfigurationReference = EB8184AF9789E3C79FC90DBB /* Pods-yana.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_ENTITLEMENTS = yana/yana.entitlements;
CODE_SIGN_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;
@@ -517,29 +549,32 @@
"\"${PODS_CONFIGURATION_BUILD_DIR}/libwebp/libwebp.framework/Headers\"",
);
INFOPLIST_FILE = yana/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "E-PARTi";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "此App将可发现和连接到您所用网络上的设备。";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“MoliStar”需要您的同意,才可以进行定位服务,访问网络状态";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "“eparty”需要您的同意,才可以进行定位服务,访问网络状态";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 20.20.61;
MARKETING_VERSION = 1.0.0;
OTHER_LIBTOOLFLAGS = "-framework \"SystemConfiguration\"";
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yana.yana;
PRODUCT_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;
@@ -548,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";
};
@@ -575,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)";
@@ -584,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";
};
@@ -631,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 */
@@ -639,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

@@ -7,7 +7,20 @@
<key>yana.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>23</integer>
<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>

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,152 +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 = "BF83E194-5D1D-4B84-AD21-2D4CDCC124DE"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Managers/NIMSessionManager.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "97"
endingLineNumber = "97"
landmarkName = "onLoginStatus(_:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "5E054207-7C17-4F34-A910-1C9F814EC837"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Managers/NIMSessionManager.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "101"
endingLineNumber = "101"
landmarkName = "onLoginFailed(_:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "164971C8-E03E-4FAD-891E-C07DFA41444D"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Managers/NIMSessionManager.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "105"
endingLineNumber = "105"
landmarkName = "onKickedOffline(_:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "9A59F819-E987-4891-AEDD-AE98333E1722"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Managers/NIMSessionManager.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "112"
endingLineNumber = "112"
landmarkName = "onLoginClientChanged(_:clients:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "ADC3C5EC-46AE-4FDA-9FD6-D685B5C36044"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Managers/NetworkManager.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "521"
endingLineNumber = "521"
landmarkName = "unknown"
landmarkType = "0">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "492235D2-D281-4F70-B43C-C09990DC22EC"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Managers/NetworkManager.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "328"
endingLineNumber = "328"
landmarkName = "unknown"
landmarkType = "0">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "198A1AE8-A7A4-4A66-A4D3-DF86D873E2AE"
shouldBeEnabled = "No"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Managers/NetworkManager.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "363"
endingLineNumber = "363"
landmarkName = "unknown"
landmarkType = "0">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "E026A08A-FE1E-4C73-A2EC-9CCA3F2FB9C1"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "yana/Managers/NetworkManager.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "314"
endingLineNumber = "314"
landmarkName = "unknown"
landmarkType = "0">
</BreakpointContent>
</BreakpointProxy>
<BreakpointProxy
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
<BreakpointContent
uuid = "2591B697-A3D2-4AFB-8144-67EC0ADE3C6B"
shouldBeEnabled = "Yes"
ignoreCount = "0"
continueAfterRunningActions = "No"
filePath = "Pods/Alamofire/Source/Core/Session.swift"
startingColumnNumber = "9223372036854775807"
endingColumnNumber = "9223372036854775807"
startingLineNumber = "287"
endingLineNumber = "287"
landmarkName = "request(_:method:parameters:encoding:headers:interceptor:requestModifier:)"
landmarkType = "7">
</BreakpointContent>
</BreakpointProxy>
</Breakpoints>
</Bucket>

View File

@@ -0,0 +1,521 @@
# **dynamic/square/latestDynamics API 文档**
## **概述**
`dynamic/square/latestDynamics` 是获取朋友圈动态最新列表的 API 接口,用于获取用户动态内容的最新更新。
## **接口信息**
| 属性 | 值 |
|------|-----|
| **接口路径** | `GET /dynamic/square/latestDynamics` |
| **请求方法** | `GET` |
| **认证要求** | 需要 `pub_uid``pub_ticket` |
| **内容类型** | `application/json` |
## **请求参数**
| 参数名 | 类型 | 必填 | 描述 | 示例值 |
|--------|------|------|------|--------|
| `dynamicId` | `String` | 否 | 最新动态的ID用于分页加载。首次请求传空字符串 | `""``"123456"` |
| `pageSize` | `String` | 是 | 每页返回的数据数量 | `"20"` |
| `types` | `String` | 是 | 动态内容类型,多个类型用逗号分隔 | `"0,2"` |
### **types 参数说明**
- `0`: 纯文字动态
- `2`: 图片动态
## **响应数据结构**
### **成功响应 (200)**
```json
{
"code": 200,
"message": "success",
"data": {
"dynamicList": [
{
"dynamicId": "123456",
"uid": "789012",
"nick": "用户昵称",
"avatar": "https://example.com/avatar.jpg",
"gender": 1,
"age": 25,
"type": 0,
"content": "动态内容文字",
"likeCount": "15",
"isLike": false,
"commentCount": "3",
"publishTime": "2024-01-15 10:30:00",
"worldId": 456,
"worldName": "话题名称",
"squareTop": false,
"topicTop": false,
"newUser": false,
"defUser": 0,
"inRoomUid": "",
"dynamicResList": [
{
"resUrl": "https://example.com/image.jpg",
"format": "jpg",
"width": 720,
"height": 960
}
],
"userVipInfoVO": {
"vipLevel": 3,
"vipExpire": "2024-12-31"
},
"headwearPic": "https://example.com/headwear.png",
"headwearEffect": "https://example.com/effect.svga",
"headwearType": 1,
"expertLevelPic": "https://example.com/expert_lv3.png",
"charmLevelPic": "https://example.com/charm_lv2.png",
"nameplatePic": "https://example.com/nameplate.png",
"nameplateWord": "自定义铭牌",
"isCustomWord": true,
"labelList": ["新人", "活跃"]
}
],
"nextDynamicId": "123455"
}
}
```
### **错误响应**
```json
{
"code": 400,
"message": "参数错误",
"data": null
}
```
## **Swift 实现示例**
### **1. 数据模型定义**
```swift
// MARK: - 响应数据模型
struct MomentsLatestResponse: Codable {
let code: Int
let message: String
let data: MomentsListData?
}
struct MomentsListData: Codable {
let dynamicList: [MomentsInfo]
let nextDynamicId: String
}
struct MomentsInfo: Codable {
let dynamicId: String
let uid: String
let nick: String
let avatar: String
let gender: Int
let age: Int
let type: Int
let content: String
let likeCount: String
let isLike: Bool
let commentCount: String
let publishTime: String
let worldId: Int
let worldName: String?
let squareTop: Bool
let topicTop: Bool
let newUser: Bool
let defUser: Int
let inRoomUid: String?
let dynamicResList: [MomentsPicture]?
let userVipInfoVO: UserVipInfo?
let headwearPic: String?
let headwearEffect: String?
let headwearType: Int?
let expertLevelPic: String?
let charmLevelPic: String?
let nameplatePic: String?
let nameplateWord: String?
let isCustomWord: Bool?
let labelList: [String]?
}
struct MomentsPicture: Codable {
let resUrl: String
let format: String
let width: CGFloat
let height: CGFloat
}
struct UserVipInfo: Codable {
let vipLevel: Int
let vipExpire: String?
}
// MARK: - 内容类型枚举
enum MomentsContentType: Int, CaseIterable {
case text = 0 // 纯文字
case picture = 2 // 图片
}
```
### **2. API 服务实现**
```swift
import Foundation
import Combine
class MomentsAPIService {
private let baseURL = "https://api.yourapp.com"
private let session = URLSession.shared
// MARK: - 获取最新动态列表
func fetchLatestMoments(
dynamicId: String = "",
pageSize: Int = 20,
types: [MomentsContentType] = [.text, .picture]
) -> AnyPublisher<MomentsListData, Error> {
// 构建请求参数
var components = URLComponents(string: "\(baseURL)/dynamic/square/latestDynamics")!
components.queryItems = [
URLQueryItem(name: "dynamicId", value: dynamicId),
URLQueryItem(name: "pageSize", value: String(pageSize)),
URLQueryItem(name: "types", value: types.map { String($0.rawValue) }.joined(separator: ","))
]
guard let url = components.url else {
return Fail(error: APIError.invalidURL)
.eraseToAnyPublisher()
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
// 添加认证头
if let uid = AuthManager.shared.currentUID {
request.setValue(uid, forHTTPHeaderField: "pub_uid")
}
if let ticket = AuthManager.shared.currentTicket {
request.setValue(ticket, forHTTPHeaderField: "pub_ticket")
}
// 添加其他公共头
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(AppInfo.version, forHTTPHeaderField: "App-Version")
request.setValue(Locale.current.languageCode ?? "en", forHTTPHeaderField: "Accept-Language")
return session.dataTaskPublisher(for: request)
.map(\.data)
.decode(type: MomentsLatestResponse.self, decoder: JSONDecoder())
.compactMap { response in
guard response.code == 200 else {
throw APIError.serverError(response.code, response.message)
}
return response.data
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
// MARK: - 错误类型定义
enum APIError: Error, LocalizedError {
case invalidURL
case noData
case serverError(Int, String)
case networkError(Error)
var errorDescription: String? {
switch self {
case .invalidURL:
return "无效的URL"
case .noData:
return "无数据返回"
case .serverError(let code, let message):
return "服务器错误 (\(code)): \(message)"
case .networkError(let error):
return "网络错误: \(error.localizedDescription)"
}
}
}
```
### **3. ViewModel 实现**
```swift
import Foundation
import Combine
@MainActor
class MomentsLatestViewModel: ObservableObject {
@Published var moments: [MomentsInfo] = []
@Published var isLoading = false
@Published var hasMoreData = true
@Published var errorMessage: String?
private var nextDynamicId = ""
private let apiService = MomentsAPIService()
private var cancellables = Set<AnyCancellable>()
// MARK: - 加载最新数据
func loadLatestMoments() {
loadMoments(isRefresh: true)
}
// MARK: - 加载更多数据
func loadMoreMoments() {
guard hasMoreData && !isLoading else { return }
loadMoments(isRefresh: false)
}
// MARK: - 私有方法:统一加载逻辑
private func loadMoments(isRefresh: Bool) {
isLoading = true
errorMessage = nil
let dynamicId = isRefresh ? "" : nextDynamicId
apiService.fetchLatestMoments(
dynamicId: dynamicId,
pageSize: 20,
types: [.text, .picture]
)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
},
receiveValue: { [weak self] data in
if isRefresh {
self?.moments = data.dynamicList
} else {
self?.moments.append(contentsOf: data.dynamicList)
}
self?.nextDynamicId = data.nextDynamicId
self?.hasMoreData = !data.dynamicList.isEmpty
}
)
.store(in: &cancellables)
}
// MARK: - 点赞操作
func toggleLike(for momentId: String) {
// 实现点赞逻辑
guard let index = moments.firstIndex(where: { $0.dynamicId == momentId }) else { return }
moments[index] = MomentsInfo(
dynamicId: moments[index].dynamicId,
uid: moments[index].uid,
nick: moments[index].nick,
avatar: moments[index].avatar,
gender: moments[index].gender,
age: moments[index].age,
type: moments[index].type,
content: moments[index].content,
likeCount: moments[index].isLike ?
String(max(0, Int(moments[index].likeCount) ?? 0 - 1)) :
String((Int(moments[index].likeCount) ?? 0) + 1),
isLike: !moments[index].isLike,
commentCount: moments[index].commentCount,
publishTime: moments[index].publishTime,
worldId: moments[index].worldId,
worldName: moments[index].worldName,
squareTop: moments[index].squareTop,
topicTop: moments[index].topicTop,
newUser: moments[index].newUser,
defUser: moments[index].defUser,
inRoomUid: moments[index].inRoomUid,
dynamicResList: moments[index].dynamicResList,
userVipInfoVO: moments[index].userVipInfoVO,
headwearPic: moments[index].headwearPic,
headwearEffect: moments[index].headwearEffect,
headwearType: moments[index].headwearType,
expertLevelPic: moments[index].expertLevelPic,
charmLevelPic: moments[index].charmLevelPic,
nameplatePic: moments[index].nameplatePic,
nameplateWord: moments[index].nameplateWord,
isCustomWord: moments[index].isCustomWord,
labelList: moments[index].labelList
)
}
}
```
### **4. SwiftUI 视图实现**
```swift
import SwiftUI
struct MomentsLatestView: View {
@StateObject private var viewModel = MomentsLatestViewModel()
var body: some View {
NavigationView {
List {
ForEach(viewModel.moments, id: \.dynamicId) { moment in
MomentCardView(moment: moment) {
viewModel.toggleLike(for: moment.dynamicId)
}
.onAppear {
// 当显示最后一个元素时加载更多
if moment.dynamicId == viewModel.moments.last?.dynamicId {
viewModel.loadMoreMoments()
}
}
}
if viewModel.isLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
}
.refreshable {
viewModel.loadLatestMoments()
}
.navigationTitle("最新动态")
.onAppear {
if viewModel.moments.isEmpty {
viewModel.loadLatestMoments()
}
}
.alert("错误", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("确定") {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}
}
struct MomentCardView: View {
let moment: MomentsInfo
let onLike: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// 用户信息
HStack {
AsyncImage(url: URL(string: moment.avatar)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
}
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text(moment.nick)
.font(.headline)
Text(moment.publishTime)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
// 动态内容
if !moment.content.isEmpty {
Text(moment.content)
.font(.body)
}
// 图片内容
if let pictures = moment.dynamicResList, !pictures.isEmpty {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3)) {
ForEach(pictures.indices, id: \.self) { index in
AsyncImage(url: URL(string: pictures[index].resUrl)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.3))
}
.frame(height: 100)
.clipped()
}
}
}
// 操作栏
HStack {
Button(action: onLike) {
HStack {
Image(systemName: moment.isLike ? "heart.fill" : "heart")
.foregroundColor(moment.isLike ? .red : .gray)
Text(moment.likeCount)
.foregroundColor(.gray)
}
}
Spacer()
HStack {
Image(systemName: "message")
.foregroundColor(.gray)
Text(moment.commentCount)
.foregroundColor(.gray)
}
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(8)
}
}
```
## **使用说明**
### **基本用法**
```swift
let viewModel = MomentsLatestViewModel()
// 加载最新数据
viewModel.loadLatestMoments()
// 加载更多数据
viewModel.loadMoreMoments()
```
### **分页逻辑**
- 首次请求:`dynamicId` 传空字符串
- 后续分页:使用上次响应中的 `nextDynamicId`
- 无更多数据:返回的 `dynamicList` 为空数组
### **错误处理**
- 网络错误:检查网络连接
- 401 认证失败:重新登录获取 ticket
- 其他服务器错误:显示具体错误信息
### **性能优化建议**
1. 使用图片缓存库(如 Kingfisher
2. 实现虚拟列表避免内存过载
3. 预加载下一页数据提升用户体验
4. 实现本地缓存减少网络请求
## **注意事项**
1. **认证要求**:所有请求必须包含有效的 `pub_uid``pub_ticket`
2. **参数验证**`pageSize` 建议范围为 10-50
3. **类型过滤**`types` 参数支持多选,用逗号分隔
4. **数据更新**:推荐使用下拉刷新获取最新数据
5. **错误重试**:网络错误时实现自动重试机制

180
yana/APIs/API rule.md Normal file
View File

@@ -0,0 +1,180 @@
# YuMi iOS 项目 API 请求配置分析
## 📋 目录
- [主机地址配置](#主机地址配置)
- [网络基础配置](#网络基础配置)
- [自定义HTTP Headers](#自定义http-headers)
- [默认请求参数](#默认请求参数)
- [安全签名机制](#安全签名机制)
- [请求内容类型](#请求内容类型)
- [SSL安全配置](#ssl安全配置)
- [特殊功能](#特殊功能)
- [应用信息配置](#应用信息配置)
## 🌐 主机地址配置
| 环境 | 地址 | 说明 |
|------|------|------|
| 生产环境 | `https://api.epartylive.com` | 正式服务器 |
| 测试环境 | `http://beta.api.molistar.xyz` | 开发测试服务器 |
| 图片服务 | `https://image.hfighting.com` | 静态资源服务器 |
**环境切换机制:**
- 通过 `kIsProductionEnvironment` 用户偏好设置控制
- DEBUG 模式下可动态切换环境
- 发布版本强制使用生产环境
## 🔧 网络基础配置
```objective-c
// AFHTTPSessionManager 优化配置
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
configuration.HTTPShouldUsePipelining = YES; // 启用 HTTP/2 pipelining
configuration.HTTPMaximumConnectionsPerHost = 15; // 提升并发连接数到15
// 超时设置
manager.requestSerializer.timeoutInterval = 60; // 默认超时60秒
manager.requestSerializer.HTTPShouldHandleCookies = YES; // 启用Cookie处理
```
## 📋 自定义HTTP Headers
### 认证相关 Headers
| Header 名称 | 值来源 | 说明 |
|-------------|--------|------|
| `pub_uid` | `[AccountInfoStorage instance].getUid` | 用户唯一标识符 |
| `pub_ticket` | `[AccountInfoStorage instance].getTicket` | 用户身份认证票据 |
| `Accept-Language` | `[NSBundle uploadLanguageText]` | 用户语言偏好 |
| `App-Version` | `PI_App_Version` | 应用版本号 (1.0.28.1) |
### 压缩相关 Headers
| Header 名称 | 值 | 说明 |
|-------------|-----|------|
| `Accept-Encoding` | `"gzip, br"` | 支持 gzip 和 Brotli 压缩 |
| `Content-Encoding` | `"gzip"` | POST 请求数据压缩 |
| `Content-Type` | `"application/json; charset=UTF-8"` | 特殊接口使用 |
## 🎯 默认请求参数
每个 API 请求都会自动添加以下基础参数:
```objective-c
NSDictionary *defaultBasciParame = @{
@"Accept-Language": [NSBundle uploadLanguageText], // 界面语言
@"os": @"iOS", // 操作系统类型
@"osVersion": [YYUtility systemVersion], // 系统版本号
@"netType": ([YYUtility networkStatus] == ReachableViaWiFi) ? @2 : @1, // 网络类型
@"ispType": @([YYUtility carrierIdentifier]), // 运营商类型
@"channel": [YYUtility getAppSource] ?: @"", // 应用分发渠道
@"model": [YYUtility modelType], // 设备型号
@"deviceId": [YYUtility deviceUniqueIdentification], // 设备唯一标识
@"appVersion": [YYUtility appVersion], // 应用版本
@"app": [YYUtility appName], // 应用名称
@"lang": [YYUtility getLanguage], // 语言代码
@"mcc": [YYUtility getMobileCountryCode] // 移动国家代码(条件性添加)
};
```
### 参数说明
- **netType**: WiFi=2, 蜂窝网络=1
- **channel**: 默认 "appstore",支持 "TestFlight"
- **mcc**: 移动国家代码,值为 "65535" 时不添加
## 🔐 安全签名机制
### 签名生成流程
1. **参数过滤**: 移除系统级参数
```objective-c
// 被移除的参数
@[@"Accept-Language", @"pub_uid", @"appVersion", @"appVersionCode",
@"channel", @"deviceId", @"ispType", @"netType", @"os",
@"osVersion", @"app", @"ticket", @"client", @"lang", @"mcc"]
```
2. **参数排序**: 按字典 key 升序排序
3. **字符串拼接**: `"key0=value0&key1=value1&key2=value2"`
4. **添加密钥**: 拼接 `key=PARAMSSECRET`
5. **MD5加密**: 生成大写 MD5 签名
6. **添加签名**: 以 `pub_sign` 参数名添加到请求中
## 📊 请求内容类型
支持的响应内容类型:
```objective-c
@"application/json" // 主要 JSON 响应
@"text/json" // JSON 文本格式
@"text/javascript" // JavaScript 格式
@"text/html" // HTML 响应
@"text/plain" // 纯文本
@"image/jpeg" // JPEG 图片
@"image/png" // PNG 图片
```
## 🛡️ SSL安全配置
```objective-c
manager.securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeNone];
manager.securityPolicy.allowInvalidCertificates = NO; // 不允许无效证书
manager.securityPolicy.validatesDomainName = YES; // 验证域名
```
## ⚡ 特殊功能
### 1. 网络状态检测
- 自动检测网络连接状态
- 离线时立即返回失败回调
- 避免无效请求
### 2. 动态超时设置
```objective-c
// 通过参数动态设置超时时间
@{@"NeedChangeTimeOut": @30} // 设置30秒超时
```
### 3. 数据压缩
- POST 请求自动进行 Gzip 压缩
- 减少网络传输数据量
- 提升请求效率
### 4. 错误追踪
- 集成 Bugly 错误上报
- 5xx 错误自动上报
- 包含调用堆栈信息
### 5. 参数解码
- `MSParamsDecode` 处理参数加密
- 自动生成安全签名
- 保护 API 请求安全
## 📱 应用信息配置
| 配置项 | 值 | 说明 |
|--------|-----|------|
| 应用版本 | `1.0.28.1` | 内置版本号 |
| 默认渠道 | `appstore` | App Store 渠道 |
| 测试渠道 | `TestFlight` | TestFlight 测试 |
| 图片域名 | `https://image.hfighting.com` | 静态资源服务 |
## 🏗️ 架构特点
1. **统一管理**: `HttpRequestHelper` 类统一管理所有网络请求
2. **模块化**: API 接口按功能模块分类 (`Api+Mine`, `Api+DressUp` 等)
3. **安全性**: 多层安全机制保护 API 调用
4. **性能优化**: HTTP/2 支持、连接复用、数据压缩
5. **错误处理**: 完善的错误处理和上报机制
6. **环境切换**: 支持开发和生产环境无缝切换
## 📝 总结
YuMi iOS 项目的 API 架构设计了完整的网络请求体系,包含:
- 🔐 **安全机制**: 用户认证、参数签名、SSL验证
- 📊 **设备信息**: 完整的设备和应用信息收集
-**性能优化**: HTTP/2、连接池、数据压缩
- 🛠️ **开发支持**: 环境切换、错误追踪、调试日志
- 🏗️ **架构清晰**: 模块化设计、统一管理、易于维护
这种设计确保了 API 请求的安全性、稳定性和高性能,为应用提供了可靠的网络服务基础。

152
yana/APIs/API-README.md Normal file
View File

@@ -0,0 +1,152 @@
## 🔐 **自动认证 Header 机制**
### 概述
系统会自动检查用户的登录状态并在所有API请求中自动添加认证相关的header。
### 工作原理
1. **检查认证状态**每次发起API请求时系统会检查`AccountModel`的有效性
2. **自动添加Header**如果用户已登录且认证信息有效自动添加以下header
- `pub_uid`: 用户唯一标识(来自`AccountModel.uid`
- `pub_ticket`: 业务会话票据(来自`AccountModel.ticket`
### 实现细节
```swift
// 在 APIConfiguration.defaultHeaders 中实现
static var defaultHeaders: [String: String] {
var headers = [
"Content-Type": "application/json",
"Accept": "application/json",
// ... 其他基础header
]
// 检查用户认证状态并添加相关 headers
let authStatus = UserInfoManager.checkAuthenticationStatus()
if authStatus.canAutoLogin {
// 添加认证 headers仅在 AccountModel 有效时)
if let userId = UserInfoManager.getCurrentUserId() {
headers["pub_uid"] = userId
}
if let userTicket = UserInfoManager.getCurrentUserTicket() {
headers["pub_ticket"] = userTicket
}
}
return headers
}
```
### 认证状态检查
系统使用`UserInfoManager.checkAuthenticationStatus()`检查认证状态:
```swift
enum AuthenticationStatus {
case valid // 认证有效,可以自动登录
case invalid // 认证信息不完整或无效
case notFound // 未找到认证信息
}
```
**认证有效的条件**
- `AccountModel`存在
- `uid`不为空
- `ticket`不为空
- `accessToken`不为空
### 使用方式
认证header的添加是**完全自动的**,开发者无需手动处理:
```swift
// 示例发起API请求
let request = ConfigRequest()
let response = try await apiService.request(request)
// 如果用户已登录,以上请求会自动包含:
// Header: pub_uid = "12345"
// Header: pub_ticket = "eyJhbGciOiJIUzI1NiJ9..."
```
### 测试功能
在DEBUG模式下可以使用测试方法验证功能
```swift
#if DEBUG
// 运行认证header测试
UserInfoManager.testAuthenticationHeaders()
#endif
```
测试包括:
1. **未登录状态测试**验证不会添加认证header
2. **已登录状态测试**验证正确添加认证header
3. **清理测试**:验证测试数据正确清理
### 调试日志
在DEBUG模式下系统会输出认证header的添加情况
```
🔐 添加认证 header: pub_uid = 12345
🔐 添加认证 header: pub_ticket = eyJhbGciOiJIUzI1NiJ9...
```
或者:
```
🔐 跳过认证 header 添加 - 认证状态: 未找到认证信息
```
### 最佳实践
1. **登录成功后保存完整认证信息**
```swift
UserInfoManager.saveCompleteAuthenticationData(
accessToken: loginResponse.accessToken,
ticket: ticketResponse.ticket,
uid: loginResponse.uid,
userInfo: loginResponse.userInfo
)
```
2. **登出时清理认证信息**
```swift
UserInfoManager.clearAllAuthenticationData()
```
3. **应用启动时检查认证状态**
```swift
let authStatus = UserInfoManager.checkAuthenticationStatus()
if authStatus.canAutoLogin {
// 可以直接进入主界面
} else {
// 需要重新登录
}
```
### 安全考虑
- **内存安全**ticket存储在内存中应用重启需重新获取
- **持久化安全**uid和accessToken存储在Keychain中确保安全性
- **自动清理**认证失效时系统会自动停止添加认证header
### 故障排除
1. **认证header未添加**
- 检查用户是否已正确登录
- 验证AccountModel是否包含有效的uid和ticket
- 确认认证状态为valid
2. **ticket为空**
- 检查登录流程是否正确获取了ticket
- 验证ticket是否正确保存到AccountModel
3. **调试模式下查看详细日志**
- 启用DEBUG模式查看认证header添加日志
- 使用测试方法验证功能正确性

View File

@@ -1,10 +1,19 @@
import Foundation
/// API
///
/// API
/// -
/// - API
/// -
///
/// baseURLAppConfig
/// APIConfiguration
enum APIConstants {
// MARK: - Base URLs
static let baseURL = "http://beta.api.molistar.xyz"
// MARK: - Common Headers
///
/// Content-TypeAccept
static let defaultHeaders: [String: String] = [
"Content-Type": "application/json",
"Accept": "application/json",
@@ -13,12 +22,20 @@ enum APIConstants {
]
// MARK: - Endpoints
/// API
///
/// 使 APIEndpoints.swift
///
enum Endpoints {
static let clientInit = "/client/config"
static let login = "/user/login"
///
static let clientInit = "/client/init"
///
static let login = "/oauth/token"
}
// MARK: - Common Parameters
///
/// BaseRequest
static let commonParameters: [String: String] = [:
// "platform": "ios",
// "version": "1.0.0"

View File

@@ -1,10 +1,35 @@
import Foundation
// MARK: - API Endpoints
/// API
///
/// API
/// 使
///
/// case
///
/// 使
/// ```swift
/// let configPath = APIEndpoint.config.path // "/client/config"
/// ```
enum APIEndpoint: String, CaseIterable {
case config = "/client/config"
case login = "/auth/login"
//
case configInit = "/client/init"
case login = "/oauth/token"
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
@@ -12,20 +37,87 @@ enum APIEndpoint: String, CaseIterable {
}
// MARK: - API Configuration
/// API
///
/// API
/// -
/// -
/// -
/// -
///
///
/// -
/// -
/// -
struct APIConfiguration {
static let baseURL = "http://beta.api.molistar.xyz"
static var baseURL: String { AppConfig.baseURL }
static let timeout: TimeInterval = 30.0
static let maxDataSize: Int = 50 * 1024 * 1024 // 50MB
//
static let defaultHeaders: [String: String] = [
/// URL
/// - Parameter endpoint: API
/// - Returns: URL
static func fullURL(for endpoint: APIEndpoint) -> String {
return baseURL + endpoint.path
}
/// URL
/// - Parameter endpoint: API
/// - Returns: URL nil
static func url(for endpoint: APIEndpoint) -> URL? {
return URL(string: fullURL(for: endpoint))
}
/// Web URL
/// - Parameter endpoint: API
/// - Returns: Web URL
static func fullWebURL(for endpoint: APIEndpoint) -> String {
return baseURL + AppConfig.webPathPrefix + endpoint.path
}
/// Web URL
/// - Parameter endpoint: API
/// - Returns: Web URL nil
static func webURL(for endpoint: APIEndpoint) -> URL? {
return URL(string: fullWebURL(for: endpoint))
}
///
///
/// API
/// - Content-Type Accept
/// -
/// -
/// -
///
///
static func defaultHeaders() async -> [String: String] {
var headers = [
"Content-Type": "application/json",
"Accept": "application/json",
// "User-Agent": "yana-iOS/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0")"
"User-Agent": "YuMi/20.20.61 (iPhone; iOS 18.3.1; Scale/3.00)",
"Accept-Language": "zh-Hant",
"Accept-Encoding": "gzip, br"
"Accept-Encoding": "gzip, br",
"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 = await UserInfoManager.checkAuthenticationStatus()
if authStatus.canAutoLogin {
// headers AccountModel
if let userId = await UserInfoManager.getCurrentUserId() {
headers["pub_uid"] = userId
debugInfoSync("🔐 添加认证 header: pub_uid = \(userId)")
}
if let userTicket = await UserInfoManager.getCurrentUserTicket() {
headers["pub_ticket"] = userTicket
debugInfoSync("🔐 添加认证 header: pub_ticket = \(userTicket.prefix(20))...")
}
} else {
debugInfoSync("🔐 跳过认证 header 添加 - 认证状态: \(authStatus.description)")
}
return headers
}
}
// MARK: - Request Models

View File

@@ -1,6 +1,7 @@
import Foundation
// MARK: - API Logger
@MainActor
class APILogger {
enum LogLevel {
case none
@@ -21,8 +22,17 @@ 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
return
#endif
let timestamp = dateFormatter.string(from: Date())
@@ -107,7 +117,11 @@ class APILogger {
// MARK: - Response Logging
static func logResponse(data: Data, response: HTTPURLResponse, duration: TimeInterval) {
#if DEBUG
guard logLevel != .none else { return }
#else
return
#endif
let timestamp = dateFormatter.string(from: Date())
let statusEmoji = response.statusCode < 400 ? "" : ""
@@ -143,7 +157,11 @@ class APILogger {
// MARK: - Error Logging
static func logError(_ error: Error, url: URL?, duration: TimeInterval) {
#if DEBUG
guard logLevel != .none else { return }
#else
return
#endif
let timestamp = dateFormatter.string(from: Date())
@@ -186,7 +204,11 @@ class APILogger {
// MARK: - Decoded Response Logging
static func logDecodedResponse<T>(_ response: T, type: T.Type) {
#if DEBUG
guard logLevel == .detailed else { return }
#else
return
#endif
let timestamp = dateFormatter.string(from: Date())
print("🎯 [Decoded Response] [\(timestamp)] Type: \(type)")
@@ -203,7 +225,11 @@ class APILogger {
// MARK: - Performance Logging
static func logPerformanceWarning(duration: TimeInterval, threshold: TimeInterval = 5.0) {
#if DEBUG
guard logLevel != .none && duration > threshold else { return }
#else
return
#endif
let timestamp = dateFormatter.string(from: Date())
print("\n⚠️ [Performance Warning] [\(timestamp)] ============")

View File

@@ -2,6 +2,11 @@ import Foundation
import ComposableArchitecture
// MARK: - HTTP Method
/// HTTP
///
/// API HTTP
/// URLRequest
enum HTTPMethod: String, CaseIterable {
case GET = "GET"
case POST = "POST"
@@ -11,6 +16,17 @@ enum HTTPMethod: String, CaseIterable {
}
// MARK: - API Error Types
/// API
///
/// API
/// 便
///
///
/// -
/// -
/// - HTTP
/// -
enum APIError: Error, Equatable {
case invalidURL
case noData
@@ -19,6 +35,10 @@ enum APIError: Error, Equatable {
case httpError(statusCode: Int, message: String?)
case timeout
case resourceTooLarge
case encryptionFailed //
case invalidResponse //
case ticketFailed //
case custom(String) //
case unknown(String)
var localizedDescription: String {
@@ -37,6 +57,14 @@ enum APIError: Error, Equatable {
return "请求超时"
case .resourceTooLarge:
return "响应数据过大"
case .encryptionFailed:
return "数据加密失败"
case .invalidResponse:
return "服务器响应无效"
case .ticketFailed:
return "获取会话票据失败"
case .custom(let message):
return message
case .unknown(let message):
return "未知错误: \(message)"
}
@@ -44,31 +72,51 @@ enum APIError: Error, Equatable {
}
// MARK: - Base Request Parameters
///
///
/// API
/// API
///
///
/// -
/// -
/// -
///
/// 使
/// ```swift
/// var baseRequest = BaseRequest()
/// baseRequest.generateSignature(with: ["key": "value"])
/// ```
struct BaseRequest: Codable {
let acceptLanguage: String
let os: String = "iOS"
let osVersion: String
let netType: Int
let ispType: String
let channel: String = "molistar_enterprise"
let channel: String
let model: String
let deviceId: String
let appVersion: String
let app: String = "youmi"
let app: String
let lang: String
let mcc: String?
let spType: String?
let pubSign: String
var pubSign: String
enum CodingKeys: String, CodingKey {
case acceptLanguage = "Accept-Language"
case appVersion = "appVersion"
case os, osVersion, ispType, channel, model, deviceId
case app, mcc, spType
case os, osVersion, netType, ispType, channel, model, deviceId
case appVersion, app, lang, mcc, spType
case pubSign = "pub_sign"
}
@MainActor
init() {
//
self.acceptLanguage = Locale.current.languageCode ?? "en"
let preferredLanguage = Locale.current.language.languageCode?.identifier ?? "en"
self.acceptLanguage = preferredLanguage
self.lang = preferredLanguage
//
self.osVersion = UIDevice.current.systemVersion
@@ -76,40 +124,531 @@ struct BaseRequest: Codable {
//
self.model = UIDevice.current.model
// ID (使 identifierForVendor)
// ID
self.deviceId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
//
self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
//
self.ispType = "65535"
self.mcc = nil
self.spType = nil
//
self.app = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "eparty"
// 使 MD5
let timestamp = String(Int(Date().timeIntervalSince1970))
self.pubSign = timestamp.md5()
// WiFi=2, =1
self.netType = NetworkTypeDetector.getCurrentNetworkType()
//
let carrierInfo = CarrierInfoManager.getCarrierInfo()
self.ispType = carrierInfo.ispType
self.mcc = carrierInfo.mcc == "65535" ? nil : carrierInfo.mcc
self.spType = self.mcc
//
#if DEBUG
self.channel = "molistar_enterprise"
#else
self.channel = "appstore"
#endif
//
self.pubSign = "" //
}
/// API
///
///
/// 1.
/// 2.
/// 3. key
/// 4.
/// 5. MD5
///
/// - Parameter requestParams:
mutating func generateSignature(with requestParams: [String: Any] = [:]) {
// 1.
var allParams = requestParams
//
allParams["Accept-Language"] = self.acceptLanguage
allParams["os"] = self.os
allParams["osVersion"] = self.osVersion
allParams["netType"] = self.netType
allParams["ispType"] = self.ispType
allParams["channel"] = self.channel
allParams["model"] = self.model
allParams["deviceId"] = self.deviceId
allParams["appVersion"] = self.appVersion
allParams["app"] = self.app
allParams["lang"] = self.lang
if let mcc = self.mcc {
allParams["mcc"] = mcc
}
if let spType = self.spType {
allParams["spType"] = spType
}
// 2. API rule
let systemParams = [
"Accept-Language", "pub_uid", "appVersion", "appVersionCode",
"channel", "deviceId", "ispType", "netType", "os",
"osVersion", "app", "ticket", "client", "lang", "mcc"
]
var filteredParams = allParams
for param in systemParams {
filteredParams.removeValue(forKey: param)
}
// 3. key
// "key0=value0&key1=value1&key2=value2"
let sortedKeys = filteredParams.keys.sorted()
let paramString = sortedKeys.map { key in
"\(key)=\(String(describing: filteredParams[key] ?? ""))"
}.joined(separator: "&")
// 4.
let keyString = "key=rpbs6us1m8r2j9g6u06ff2bo18orwaya"
let finalString = paramString.isEmpty ? keyString : "\(paramString)&\(keyString)"
// 5. MD5
self.pubSign = finalString.md5().uppercased()
}
}
// MARK: - Network Type Detector
struct NetworkTypeDetector {
static func getCurrentNetworkType() -> Int {
// WiFi = 2, = 1
//
return 2 //
}
}
// MARK: - Carrier Info Manager
struct CarrierInfoManager {
struct CarrierInfo {
let ispType: String
let mcc: String?
}
static func getCarrierInfo() -> CarrierInfo {
//
return CarrierInfo(ispType: "65535", mcc: nil)
}
}
// MARK: - User Info Manager (for Headers)
struct UserInfoManager {
@MainActor
private static let keychain = KeychainManager.shared
// MARK: - Storage Keys
private enum StorageKeys {
static let accountModel = "account_model"
static let userInfo = "user_info"
}
// MARK: -
// UserInfoCacheActor
private static let cacheQueue = DispatchQueue(label: "com.yana.userinfo.cache", attributes: .concurrent)
// MARK: - User ID Management ( AccountModel)
static func getCurrentUserId() async -> String? {
return await getAccountModel()?.uid
}
// MARK: - Access Token Management ( AccountModel)
static func getAccessToken() async -> String? {
return await getAccountModel()?.accessToken
}
// MARK: - Ticket Management ( AccountModel )
// UserInfoCacheActor
static func getCurrentUserTicket() async -> String? {
// AccountModel ticket
if let accountTicket = await getAccountModel()?.ticket, !accountTicket.isEmpty {
return accountTicket
}
// actor
return await cacheActor.getCurrentTicket()
}
static func saveTicket(_ ticket: String) async {
await cacheActor.setCurrentTicket(ticket)
debugInfoSync("💾 保存 Ticket 到内存")
}
static func clearTicket() async {
await cacheActor.clearCurrentTicket()
debugInfoSync("🗑️ 清除 Ticket")
}
// MARK: - User Info Management
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() 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
}
}
// MARK: - Complete Authentication Data Management
/// OAuth Token + Ticket +
static func saveCompleteAuthenticationData(
accessToken: String,
ticket: String,
uid: Int?,
userInfo: UserInfo?
) async {
// AccountModel
let accountModel = AccountModel(
uid: uid != nil ? "\(uid!)" : nil,
jti: nil,
tokenType: "bearer",
refreshToken: nil,
netEaseToken: nil,
accessToken: accessToken,
expiresIn: nil,
scope: nil,
ticket: ticket
)
await saveAccountModel(accountModel)
await saveTicket(ticket)
if let userInfo = userInfo {
await saveUserInfo(userInfo)
}
debugInfoSync("✅ 完整认证信息保存成功")
}
///
static func hasValidAuthentication() async -> Bool {
let token = await getAccessToken()
let ticket = await getCurrentUserTicket()
return token != nil && ticket != nil
}
///
static func clearAllAuthenticationData() async {
await clearAccountModel()
await clearUserInfo()
await clearTicket()
debugInfoSync("🗑️ 清除所有认证信息")
}
/// Ticket
static func restoreTicketIfNeeded() async -> Bool {
guard let _ = await getAccessToken(),
await getCurrentUserTicket() == nil else {
return false
}
debugInfoSync("🔄 尝试使用 Access Token 恢复 Ticket...")
// APIService false
// TicketHelper.createTicketRequest
return false
}
// MARK: - Account Model Management
/// AccountModel
/// - Parameter accountModel:
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() 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) async {
guard var accountModel = await getAccountModel() else {
debugErrorSync("❌ 无法更新 ticketAccountModel 不存在")
return
}
accountModel = AccountModel(
uid: accountModel.uid,
jti: accountModel.jti,
tokenType: accountModel.tokenType,
refreshToken: accountModel.refreshToken,
netEaseToken: accountModel.netEaseToken,
accessToken: accountModel.accessToken,
expiresIn: accountModel.expiresIn,
scope: accountModel.scope,
ticket: ticket
)
await saveAccountModel(accountModel)
await saveTicket(ticket) // ticket
}
/// AccountModel
/// - Returns:
static func hasValidAccountModel() async -> Bool {
guard let accountModel = await getAccountModel() else {
return false
}
return accountModel.hasValidAuthentication
}
/// AccountModel
static func clearAccountModel() async {
do {
try await keychain.delete(forKey: StorageKeys.accountModel)
await cacheActor.clearAccountModel()
debugInfoSync("🗑️ AccountModel 已清除")
} catch {
debugErrorSync("❌ 清除 AccountModel 失败: \(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() async {
await cacheActor.clearAccountModel()
await cacheActor.clearUserInfo()
debugInfoSync("🗑️ 清除所有内存缓存")
}
/// 访
static func preloadCache() async {
await cacheActor.setAccountModel(await getAccountModel())
await cacheActor.setUserInfo(await getUserInfo())
debugInfoSync("🚀 缓存预加载完成")
}
// MARK: - Authentication Validation
///
/// - Returns:
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
}
///
enum AuthenticationStatus: Equatable {
case valid //
case invalid //
case notFound //
var description: String {
switch self {
case .valid:
return "认证有效"
case .invalid:
return "认证无效"
case .notFound:
return "未找到认证信息"
}
}
///
var canAutoLogin: Bool {
return self == .valid
}
}
// MARK: - Testing and Debugging
/// header
/// header
static func testAuthenticationHeaders() async {
#if DEBUG
debugInfoSync("\n🧪 开始测试认证 header 功能")
// 1
debugInfoSync("📝 测试1未登录状态")
await clearAllAuthenticationData()
let headers1 = await APIConfiguration.defaultHeaders()
let hasAuthHeaders1 = headers1.keys.contains("pub_uid") || headers1.keys.contains("pub_ticket")
debugInfoSync(" 认证 headers 存在: \(hasAuthHeaders1) (应该为 false)")
// 2
debugInfoSync("📝 测试2模拟登录状态")
let testAccount = AccountModel(
uid: "12345",
jti: "test-jti",
tokenType: "bearer",
refreshToken: nil,
netEaseToken: nil,
accessToken: "test-access-token",
expiresIn: 3600,
scope: "read write",
ticket: "test-ticket-12345678901234567890"
)
await saveAccountModel(testAccount)
let headers2 = await APIConfiguration.defaultHeaders()
let hasUid = headers2["pub_uid"] == "12345"
let hasTicket = headers2["pub_ticket"] == "test-ticket-12345678901234567890"
debugInfoSync(" pub_uid 正确: \(hasUid) (应该为 true)")
debugInfoSync(" pub_ticket 正确: \(hasTicket) (应该为 true)")
// 3
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
protocol APIRequestProtocol {
associatedtype Response: Codable
/// API
///
/// API
/// API
///
///
/// - Response: Sendable
/// - endpoint: API
/// - method: HTTP
/// -
///
/// 使
/// ```swift
/// struct LoginRequest: APIRequestProtocol {
/// typealias Response = LoginResponse
/// let endpoint = "/auth/login"
/// let method: HTTPMethod = .POST
/// // ...
/// }
/// ```
protocol APIRequestProtocol: Sendable {
associatedtype Response: Codable & Sendable
var endpoint: String { get }
var method: HTTPMethod { get }
var queryParameters: [String: String]? { get }
var bodyParameters: [String: Any]? { get }
var headers: [String: String]? { get }
var customHeaders: [String: String]? { get } //
var timeout: TimeInterval { get }
var includeBaseParameters: Bool { get }
// MARK: - Loading Configuration
/// loading true
var shouldShowLoading: Bool { get }
/// true
var shouldShowError: Bool { get }
}
extension APIRequestProtocol {
var timeout: TimeInterval { 30.0 }
var includeBaseParameters: Bool { true }
var headers: [String: String]? { nil }
var customHeaders: [String: String]? { nil } //
// MARK: - Loading Configuration Defaults
var shouldShowLoading: Bool { true }
var shouldShowError: Bool { true }
}
// MARK: - Generic API Response
@@ -120,18 +659,216 @@ struct APIResponse<T: Codable>: Codable {
let code: Int?
}
// MARK: - String MD5 Extension
extension String {
func md5() -> String {
let data = Data(self.utf8)
let hash = data.withUnsafeBytes { bytes -> [UInt8] in
var hash = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
CC_MD5(bytes.baseAddress, CC_LONG(data.count), &hash)
return hash
// 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 //
}
return hash.map { String(format: "%02x", $0) }.joined()
/// 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)
}
}
// CommonCrypto
import CommonCrypto
// 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

@@ -2,22 +2,59 @@ import Foundation
import ComposableArchitecture
// MARK: - API Service Protocol
protocol APIServiceProtocol {
/// API
///
/// `APIRequestProtocol`
///
///
/// 使
/// ```swift
/// let apiService: APIServiceProtocol = LiveAPIService()
/// let request = ConfigRequest()
/// let response = try await apiService.request(request)
/// ```
protocol APIServiceProtocol: Sendable {
///
/// - Parameter request: APIRequestProtocol
/// - Returns:
/// - Throws: APIError
func request<T: APIRequestProtocol>(_ request: T) async throws -> T.Response
}
// MARK: - Live API Service Implementation
struct LiveAPIService: APIServiceProtocol {
/// API
///
///
/// - URL
/// -
/// -
/// -
/// -
///
///
/// - GET/POST/PUT/DELETE HTTP
/// -
/// -
/// - /
/// -
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
init(baseURL: String = APIConfiguration.baseURL) {
/// API
/// - 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
@@ -27,11 +64,31 @@ struct LiveAPIService: APIServiceProtocol {
self.session = URLSession(configuration: config)
}
///
///
///
/// 1. URL
/// 2.
/// 3.
/// 4.
/// 5.
/// 6.
///
/// - Parameter request: APIRequestProtocol
/// - Returns:
/// - Throws: APIError
func request<T: APIRequestProtocol>(_ request: T) async throws -> T.Response {
let startTime = Date()
// Loading
let loadingId = await APILoadingManager.shared.startLoading(
shouldShowLoading: request.shouldShowLoading,
shouldShowError: request.shouldShowError
)
// URL
guard let url = buildURL(for: request) else {
guard let url = await buildURL(for: request) else {
await APILoadingManager.shared.setError(loadingId, errorMessage: APIError.invalidURL.localizedDescription)
throw APIError.invalidURL
}
@@ -39,13 +96,19 @@ struct LiveAPIService: APIServiceProtocol {
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
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 }
}
//
if let additionalHeaders = request.customHeaders {
headers.merge(additionalHeaders) { _, new in new }
}
for (key, value) in headers {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
@@ -54,23 +117,39 @@ struct LiveAPIService: APIServiceProtocol {
var requestBody: Data? = nil
if request.method != .GET, let bodyParams = request.bodyParameters {
do {
//
var finalBody = bodyParams
//
if request.includeBaseParameters {
let baseParams = BaseRequest()
//
var baseParams = await BaseRequest()
// bodyParams +
baseParams.generateSignature(with: bodyParams)
//
let baseDict = try baseParams.toDictionary()
finalBody.merge(baseDict) { existing, _ in existing }
finalBody.merge(baseDict) { _, new in new } //
debugInfoSync("🔐 签名生成完成 - 基于所有参数统一生成: \(baseParams.pubSign)")
}
requestBody = try JSONSerialization.data(withJSONObject: finalBody, options: [])
urlRequest.httpBody = requestBody
// urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
if let httpBody = urlRequest.httpBody,
let bodyString = String(data: httpBody, encoding: .utf8) {
debugInfoSync("HTTP Body: \(bodyString)")
}
} catch {
throw APIError.decodingError("请求体编码失败: \(error.localizedDescription)")
let encodingError = APIError.decodingError("请求体编码失败: \(error.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 {
//
@@ -79,29 +158,37 @@ struct LiveAPIService: APIServiceProtocol {
//
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.networkError("无效的响应类型")
let networkError = APIError.networkError("无效的响应类型")
await APILoadingManager.shared.setError(loadingId, errorMessage: networkError.localizedDescription)
throw networkError
}
//
if data.count > APIConfiguration.maxDataSize {
APILogger.logError(APIError.resourceTooLarge, url: url, duration: duration)
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)
throw APIError.httpError(statusCode: httpResponse.statusCode, message: errorMessage)
let httpError = APIError.httpError(statusCode: httpResponse.statusCode, message: errorMessage)
await APILoadingManager.shared.setError(loadingId, errorMessage: httpError.localizedDescription)
throw httpError
}
//
guard !data.isEmpty else {
await APILoadingManager.shared.setError(loadingId, errorMessage: APIError.noData.localizedDescription)
throw APIError.noData
}
@@ -109,27 +196,49 @@ 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
await APILoadingManager.shared.finishLoading(loadingId)
return decodedResponse
} catch {
throw APIError.decodingError("响应解析失败: \(error.localizedDescription)")
let decodingError = APIError.decodingError("响应解析失败: \(error.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)
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)
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
private func buildURL<T: APIRequestProtocol>(for request: T) -> URL? {
/// URL
///
///
/// - URL
/// -
/// - GET
///
/// - Parameter request: API
/// - Returns: URL nil
@MainActor private func buildURL<T: APIRequestProtocol>(for request: T) -> URL? {
guard var urlComponents = URLComponents(string: baseURL + request.endpoint) else {
return nil
}
@@ -140,13 +249,22 @@ struct LiveAPIService: APIServiceProtocol {
// GET
if request.method == .GET && request.includeBaseParameters {
do {
let baseParams = BaseRequest()
//
var baseParams = BaseRequest()
// queryParams +
let queryParamsDict = request.queryParameters ?? [:]
baseParams.generateSignature(with: queryParamsDict)
//
let baseDict = try baseParams.toDictionary()
for (key, value) in baseDict {
queryItems.append(URLQueryItem(name: key, value: "\(value)"))
}
debugInfoSync("🔐 GET请求签名生成完成 - 基于所有参数统一生成: \(baseParams.pubSign)")
} catch {
print("警告:无法添加基础参数到查询字符串")
debugWarnSync("警告:无法添加基础参数到查询字符串")
}
}
@@ -164,6 +282,12 @@ struct LiveAPIService: APIServiceProtocol {
return urlComponents.url
}
///
///
/// JSON
///
/// - Parameter data:
/// - Returns: nil
private func extractErrorMessage(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
@@ -181,6 +305,13 @@ struct LiveAPIService: APIServiceProtocol {
return nil
}
/// API
///
/// URLError APIError
/// 便
///
/// - Parameter error:
/// - Returns: APIError
private func mapSystemError(_ error: Error) -> APIError {
if let urlError = error as? URLError {
switch urlError.code {
@@ -200,33 +331,32 @@ struct LiveAPIService: APIServiceProtocol {
}
// MARK: - Mock API Service (for testing)
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

@@ -0,0 +1,283 @@
import Foundation
import ComposableArchitecture
// MARK: -
///
struct MomentsLatestResponse: Codable, Equatable, Sendable {
let code: Int
let message: String
let data: MomentsListData?
let timestamp: Int?
}
///
struct MomentsListData: Codable, Equatable, Sendable {
let dynamicList: [MomentsInfo]
let nextDynamicId: Int
}
///
public struct MomentsInfo: Codable, Equatable, Sendable {
let dynamicId: Int
let uid: Int
let nick: String
let avatar: String
let type: Int
let content: String
let likeCount: Int
let isLike: Bool
let commentCount: Int
let publishTime: Int
let worldId: Int
let status: Int
// 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]?
//
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, Sendable {
let id: Int?
let resUrl: String?
let format: String?
let width: Int?
let height: Int?
let resDuration: Int? //
}
/// VIP -
struct UserVipInfo: Codable, Equatable, Sendable {
let vipLevel: Int?
let vipName: String?
let vipIcon: String?
let vipLogo: String?
let nameplateId: Int?
let nameplateUrl: String?
let userCardBG: String?
let expireTime: Int?
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 roomPicScreen: Bool?
let uploadGifAvatar: Bool?
let enterHide: Bool?
}
// MARK: -
///
enum MomentsContentType: Int, CaseIterable {
case text = 0 //
case picture = 2 //
/// API
static func toAPIParameter(_ types: [MomentsContentType]) -> String {
return types.map { String($0.rawValue) }.joined(separator: ",")
}
}
// MARK: - API
/// API
struct LatestDynamicsRequest: APIRequestProtocol {
typealias Response = MomentsLatestResponse
let endpoint: String = APIEndpoint.latestDynamics.path
let method: HTTPMethod = .GET
let dynamicId: String
let pageSize: Int
let types: [MomentsContentType]
///
/// - Parameters:
/// - dynamicId: ID
/// - pageSize: 20
/// - types:
init(
dynamicId: String = "",
pageSize: Int = 20,
types: [MomentsContentType] = [.text, .picture]
) {
self.dynamicId = dynamicId
self.pageSize = pageSize
self.types = types
}
var queryParameters: [String: String]? {
return [
"dynamicId": dynamicId,
"pageSize": String(pageSize),
"types": MomentsContentType.toAPIParameter(types)
]
}
var bodyParameters: [String: Any]? { nil }
var includeBaseParameters: Bool { true }
// 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 }
}

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

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

330
yana/APIs/data.md Normal file
View File

@@ -0,0 +1,330 @@
📦 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

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 42 KiB

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

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

1
yana/APIs/oauth flow.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -1,31 +1,11 @@
import UIKit
import NIMSDK
//import NIMSDK
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
private func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
//
// NetworkManager.shared.networkStatusChanged = { status in
// print("🌍 \(status)")
// }
#if DEBUG
//
let testURL = URL(string: "http://beta.api.molistar.xyz/client/init")!
let request = URLRequest(url: testURL)
print("🛠 原生URLSession测试开始")
URLSession.shared.dataTask(with: request) { data, response, error in
print("""
=== 网络诊断结果 ===
响应状态码: \((response as? HTTPURLResponse)?.statusCode ?? -1)
错误信息: \(error?.localizedDescription ?? "")
原始数据: \(data?.count ?? 0) bytes
==================
""")
}.resume()
#endif
//
await UserInfoManager.preloadCache()
// NIMConfigurationManager.setupNimSDK()

View File

@@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "logo.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

View File

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

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "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" : "发布@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -4,7 +4,7 @@ enum Environment {
}
struct AppConfig {
static var current: Environment = {
static let current: Environment = {
#if DEBUG
return .development
#else
@@ -15,9 +15,22 @@ struct AppConfig {
static var baseURL: String {
switch current {
case .development:
// return "http://192.168.10.211:8080"
return "http://beta.api.molistar.xyz"
case .production:
return "https://api.hfighting.com"
return "https://api.epartylive.com"
}
}
/// Web
/// - development: "/molistar"
/// - production: "/eparty"
static var webPathPrefix: String {
switch current {
case .development:
return "/molistar"
case .production:
return "/eparty"
}
}
@@ -30,33 +43,36 @@ struct AppConfig {
}
//
static func switchEnvironment(to env: Environment) {
current = env
}
// static func switchEnvironment(to env: Environment) {
// current = env
// }
//
//
static var enableNetworkDebug: Bool {
#if DEBUG
switch current {
case .development:
return true
#else
case .production:
return false
#endif
}
}
//
//
static var serverTrustPolicies: [String: ServerTrustEvaluating] {
#if DEBUG
switch current {
case .development:
return ["beta.api.molistar.xyz": DisabledTrustEvaluator()]
#else
return ["api.hfighting.com": PublicKeysTrustEvaluator()]
#endif
case .production:
return ["api.epartylive.com": PublicKeysTrustEvaluator()]
}
}
static var networkDebugEnabled: Bool {
#if DEBUG
switch current {
case .development:
return true
#else
case .production:
return false
#endif
}
}
}

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() {
print("✅ 开始初始化客户端 - URL: \(AppConfig.baseURL)/client/init")
debugInfoSync("✅ 开始初始化客户端 - URL: \(AppConfig.baseURL)/client/init")
callClientInitAPI() //
}
func callClientInitAPI() {
print("🆕 使用GET方法调用初始化接口")
debugInfoSync("🆕 使用GET方法调用初始化接口")
// let queryParams = [
// "debug": "1",

View File

@@ -28,23 +28,18 @@ enum UILogLevel: String, CaseIterable {
case detailed = "详细日志"
}
struct ContentView: View {
struct LoginTabView: View {
let store: StoreOf<LoginFeature>
let initStore: StoreOf<InitFeature>
let configStore: StoreOf<ConfigFeature>
@State private var selectedLogLevel: APILogger.LogLevel = APILogger.logLevel
@State private var selectedTab = 0
@Binding var selectedLogLevel: APILogger.LogLevel
var body: some View {
TabView(selection: $selectedTab) {
//
VStack {
//
VStack(alignment: .leading, spacing: 8) {
Text("日志级别:")
.font(.headline)
.foregroundColor(.primary)
Picker("日志级别", selection: $selectedLogLevel) {
Text("无日志").tag(APILogger.LogLevel.none)
Text("基础日志").tag(APILogger.LogLevel.basic)
@@ -55,90 +50,76 @@ struct ContentView: View {
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
Spacer()
VStack(spacing: 20) {
Text("yana")
Text("eparty")
.font(.largeTitle)
.fontWeight(.bold)
VStack(spacing: 15) {
WithViewStore(store, observe: { $0 }) { viewStore in
TextField("账号", text: viewStore.binding(
get: \.account,
send: { LoginFeature.Action.updateAccount($0) }
TextField("账号", text: Binding(
get: { store.account },
set: { store.send(.updateAccount($0)) }
))
.textFieldStyle(RoundedBorderTextFieldStyle())
.autocorrectionDisabled(true)
SecureField("密码", text: viewStore.binding(
get: \.password,
send: { LoginFeature.Action.updatePassword($0) }
SecureField("密码", text: Binding(
get: { store.password },
set: { store.send(.updatePassword($0)) }
))
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
.padding(.horizontal)
WithViewStore(store, observe: { $0 }) { viewStore in
if let error = viewStore.error {
if let error = store.error {
Text(error)
.foregroundColor(.red)
.font(.caption)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
VStack(spacing: 10) {
Button(action: {
viewStore.send(.login)
store.send(.login)
}) {
HStack {
if viewStore.isLoading {
if store.isLoading {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewStore.isLoading ? "登录中..." : "登录")
Text(store.isLoading ? "登录中..." : "登录")
}
.frame(maxWidth: .infinity)
.padding()
.background(viewStore.isLoading ? Color.gray : Color.blue)
.background(store.isLoading ? Color.gray : Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(viewStore.isLoading || viewStore.account.isEmpty || viewStore.password.isEmpty)
WithViewStore(initStore, observe: { $0 }) { initViewStore in
.disabled(store.isLoading || store.account.isEmpty || store.password.isEmpty)
Button(action: {
initViewStore.send(.initialize)
initStore.send(.initialize)
}) {
HStack {
if initViewStore.isLoading {
if initStore.isLoading {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(initViewStore.isLoading ? "测试中..." : "测试初始化")
Text(initStore.isLoading ? "测试中..." : "测试初始化")
}
.frame(maxWidth: .infinity)
.padding()
.background(initViewStore.isLoading ? Color.gray : Color.green)
.background(initStore.isLoading ? Color.gray : Color.green)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(initViewStore.isLoading)
// API
if let response = initViewStore.response {
.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)")
@@ -169,8 +150,7 @@ struct ContentView: View {
.background(Color.gray.opacity(0.05))
.cornerRadius(10)
}
if let error = initViewStore.error {
if let error = initStore.error {
Text(error)
.foregroundColor(.red)
.font(.caption)
@@ -180,18 +160,27 @@ struct ContentView: View {
}
.padding(.horizontal)
}
}
}
Spacer()
}
.padding()
}
}
struct ContentView: View {
let store: StoreOf<LoginFeature>
let initStore: StoreOf<InitFeature>
let configStore: StoreOf<ConfigFeature>
@State private var selectedLogLevel: APILogger.LogLevel = APILogger.logLevel
@State private var selectedTab = 0
var body: some View {
WithPerceptionTracking {
TabView(selection: $selectedTab) {
LoginTabView(store: store, initStore: initStore, selectedLogLevel: $selectedLogLevel)
.tabItem {
Label("登录", systemImage: "person.circle")
}
.tag(0)
// API
ConfigView(store: configStore)
.tabItem {
Label("API 测试", systemImage: "network")
@@ -203,6 +192,7 @@ struct ContentView: View {
}
}
}
}
#Preview {
ContentView(

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

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

View File

@@ -0,0 +1,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

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

@@ -0,0 +1,111 @@
import Foundation
import ComposableArchitecture
@Reducer
struct FeedFeature {
@ObservableState
struct State: Equatable {
var moments: [MomentsInfo] = []
var isLoading = false
var isRefreshing = false
var hasMoreData = true
var error: String?
var nextDynamicId: Int = 0
// 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.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])
return .run { send in
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])
return .run { send in
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 {
let errorMsg = response.message.isEmpty ? "获取动态失败" : response.message
state.error = errorMsg
return .none
}
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

@@ -0,0 +1,92 @@
import Foundation
import ComposableArchitecture
struct HomeFeature: Reducer {
enum Route: Equatable {
case createFeed
}
struct State: Equatable {
var isInitialized = false
var userInfo: UserInfo?
var accountModel: AccountModel?
var error: String?
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
case userInfoLoaded(UserInfo?)
case loadAccountModel
case accountModelLoaded(AccountModel?)
case logoutTapped
case logout
case feed(FeedFeature.Action)
case meDynamic(MeDynamicFeature.Action)
case logoutCompleted
case showCreateFeed
case createFeedDismissed
}
var body: some ReducerOf<Self> {
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:
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:
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:
return .run { send in
await UserInfoManager.clearAllAuthenticationData()
await send(.logoutCompleted)
}
case .logoutCompleted:
state.isLoggedOut = true
return .none
case .feed:
return .none
case .meDynamic:
return .none
case .showCreateFeed:
state.route = .createFeed
return .none
case .createFeedDismissed:
state.route = nil
return .none
}
}
}
}

View File

@@ -0,0 +1,191 @@
import Foundation
import ComposableArchitecture
@Reducer
struct IDLoginFeature {
@ObservableState
struct State: Equatable {
var userID: String = ""
var password: String = ""
var isPasswordVisible = false
var isLoading = false
var errorMessage: String?
// Account Model Ticket
var accountModel: AccountModel?
var isTicketLoading = false
var ticketError: String?
var loginStep: LoginStep = .initial
enum LoginStep: Equatable {
case initial //
case authenticating // OAuth
case gettingTicket // Ticket
case completed //
case failed //
}
#if DEBUG
init() {
self.userID = "2356814"
self.password = "a123456"
}
#endif
}
enum Action: Equatable {
case togglePasswordVisibility
case loginButtonTapped(userID: String, password: String)
case forgotPasswordTapped
case backButtonTapped
case loginResponse(TaskResult<IDLoginResponse>)
// Ticket actions
case requestTicket(accessToken: String)
case ticketResponse(TaskResult<TicketResponse>)
case clearTicketError
case resetLogin
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .togglePasswordVisibility:
state.isPasswordVisible.toggle()
return .none
case let .loginButtonTapped(userID, password):
state.userID = userID
state.password = password
state.isLoading = true
state.errorMessage = nil
state.ticketError = nil
state.loginStep = .authenticating
// API Effect
return .run { send in
do {
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 {
if let apiError = error as? APIError {
await send(.loginResponse(.failure(apiError)))
} else {
await send(.loginResponse(.failure(APIError.unknown(error.localizedDescription))))
}
}
}
case .forgotPasswordTapped:
return .none
case .backButtonTapped:
return .none
case let .loginResponse(.success(response)):
state.isLoading = false
if response.isSuccess {
state.errorMessage = nil
if let loginData = response.data,
let accountModel = AccountModel.from(loginData: loginData) {
state.accountModel = accountModel
// Effect userInfo
if let userInfo = loginData.userInfo {
return .run { _ in await UserInfoManager.saveUserInfo(userInfo) }
}
// ticket
return .send(.requestTicket(accessToken: accountModel.accessToken!))
} else {
state.errorMessage = "登录数据格式错误"
state.loginStep = .failed
}
} else {
state.errorMessage = response.errorMessage
state.loginStep = .failed
}
return .none
case let .loginResponse(.failure(error)):
state.isLoading = false
state.errorMessage = error.localizedDescription
state.loginStep = .failed
return .none
case let .requestTicket(accessToken):
state.isTicketLoading = true
state.ticketError = nil
state.loginStep = .gettingTicket
//
let uid: Int? = {
if let am = state.accountModel, let uidStr = am.uid { return Int(uidStr) } else { return nil }
}()
return .run { send in
do {
let ticketRequest = TicketHelper.createTicketRequest(accessToken: accessToken, uid: uid)
let response = try await apiService.request(ticketRequest)
await send(.ticketResponse(.success(response)))
} catch {
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")")
if let ticket = response.ticket, let oldAccountModel = state.accountModel {
let newAccountModel = oldAccountModel.withTicket(ticket)
state.accountModel = newAccountModel
return .run { _ in
await UserInfoManager.saveAccountModel(newAccountModel)
//
debugInfoSync("🔄 登录成功,开始获取用户信息")
if let _ = await UserInfoManager.fetchUserInfoFromServer(
uid: newAccountModel.uid,
apiService: apiService
) {
debugInfoSync("✅ 用户信息获取成功")
} else {
debugErrorSync("❌ 用户信息获取失败,但不影响登录流程")
}
}
} 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
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
state.loginStep = .initial
// Effect
return .run { _ in await UserInfoManager.clearAllAuthenticationData() }
}
}
}
}

View File

@@ -1,12 +1,6 @@
import Foundation
import ComposableArchitecture
struct LoginResponse: Codable, Equatable {
let status: String
let message: String?
let token: String?
}
@Reducer
struct LoginFeature {
@ObservableState
@@ -15,25 +9,89 @@ struct LoginFeature {
var password: String = ""
var isLoading = false
var error: String?
var idLoginState = IDLoginFeature.State()
var emailLoginState = EMailLoginFeature.State() //
// HomeFeature
var homeState = HomeFeature.State()
// Account Model Ticket
var accountModel: AccountModel?
var isTicketLoading = false
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
case gettingTicket // Ticket
case completed //
case failed //
}
#if DEBUG
init() {
self.account = "3184"
self.password = "a0d5da073d14731cc7a01ecaa17b9174"
//
self.account = ""
self.password = ""
}
#endif
}
enum Action: Equatable {
enum Action {
case onAppear
case updateAccount(String)
case updatePassword(String)
case login
case loginResponse(TaskResult<LoginResponse>)
case loginResponse(TaskResult<IDLoginResponse>)
case idLogin(IDLoginFeature.Action)
case emailLogin(EMailLoginFeature.Action) // action
// HomeFeature action
case home(HomeFeature.Action)
// Ticket actions
case requestTicket(accessToken: String)
case ticketResponse(TaskResult<TicketResponse>)
case clearTicketError
case resetLogin
}
@Dependency(\.apiService) var apiService
var body: some ReducerOf<Self> {
Scope(state: \.idLoginState, action: \.idLogin) {
IDLoginFeature()
}
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
@@ -45,39 +103,152 @@ struct LoginFeature {
case .login:
state.isLoading = true
state.error = nil
state.ticketError = nil
state.loginStep = .authenticating
let loginBody = [
"account": state.account,
"password": state.password
]
return .run { send in
// 使accountpassword
return .run { [account = state.account, password = state.password] send in
do {
let response: LoginResponse = try await APIClientManager.shared.post(
path: APIConstants.Endpoints.login,
body: loginBody,
headers: APIConstants.defaultHeaders
)
// 使LoginHelper
guard let loginRequest = await LoginHelper.createIDLoginRequest(userID: account, password: password) else {
await send(.loginResponse(.failure(APIError.decodingError("加密失败"))))
return
}
//
let response = try await apiService.request(loginRequest)
await send(.loginResponse(.success(response)))
} catch {
await send(.loginResponse(.failure(error)))
if let apiError = error as? APIError {
await send(.loginResponse(.failure(apiError)))
} else {
await send(.loginResponse(.failure(APIError.unknown(error.localizedDescription))))
}
}
}
case let .loginResponse(.success(response)):
state.isLoading = false
if response.status == "success" {
// TODO: token
if response.isSuccess {
// OAuth
state.error = nil
// AccountModel
if let loginData = response.data,
let accountModel = AccountModel.from(loginData: loginData) {
state.accountModel = accountModel
debugInfoSync("✅ OAuth 认证成功")
debugInfoSync("🔑 Access Token: \(accountModel.accessToken ?? "nil")")
debugInfoSync("🆔 用户 UID: \(accountModel.uid ?? "nil")")
// ticket
return .send(.requestTicket(accessToken: accountModel.accessToken!))
} else {
state.error = response.message ?? "登录失败"
state.error = "登录数据格式错误"
state.loginStep = .failed
}
} else {
state.error = response.errorMessage
state.loginStep = .failed
}
return .none
case let .loginResponse(.failure(error)):
state.isLoading = false
state.error = error.localizedDescription
state.loginStep = .failed
return .none
case let .requestTicket(accessToken):
state.isTicketLoading = true
state.ticketError = nil
state.loginStep = .gettingTicket
return .run { [accountModel = state.accountModel] send in
do {
// AccountModel uid Int
let uid = accountModel?.uid != nil ? Int(accountModel!.uid!) : nil
let ticketRequest = TicketHelper.createTicketRequest(accessToken: accessToken, uid: uid)
let response = try await apiService.request(ticketRequest)
await send(.ticketResponse(.success(response)))
} catch {
debugErrorSync("❌ 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("✅ 完整登录流程成功")
debugInfoSync("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
// AccountModel ticket
if let ticket = response.ticket {
if let oldAccountModel = state.accountModel {
let newAccountModel = oldAccountModel.withTicket(ticket)
state.accountModel = newAccountModel
// Effect AccountModel
return .run { _ in
await UserInfoManager.saveAccountModel(newAccountModel)
}
} else {
debugErrorSync("❌ AccountModel 不存在,无法保存 ticket")
state.ticketError = "内部错误:账户信息丢失"
state.loginStep = .failed
}
} else {
state.ticketError = "Ticket 为空"
state.loginStep = .failed
}
} else {
state.ticketError = response.errorMessage
state.loginStep = .failed
}
return .none
case let .ticketResponse(.failure(error)):
state.isTicketLoading = false
state.ticketError = error.localizedDescription
state.loginStep = .failed
debugErrorSync("❌ Ticket 获取失败: \(error.localizedDescription)")
return .none
case .clearTicketError:
state.ticketError = nil
return .none
case .resetLogin:
state.isLoading = false
state.isTicketLoading = false
state.error = nil
state.ticketError = nil
state.accountModel = nil // AccountModel
state.loginStep = .initial
// Effect
return .run { _ in
await UserInfoManager.clearAllAuthenticationData()
}
case .idLogin:
// IDLoginfeature
return .none
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

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

View File

@@ -0,0 +1,110 @@
import Foundation
import ComposableArchitecture
@Reducer
struct SplashFeature {
@ObservableState
struct State: Equatable {
var isLoading = true
var shouldShowMainApp = false
var authenticationStatus: UserInfoManager.AuthenticationStatus = .notFound
var isCheckingAuthentication = false
//
var navigationDestination: NavigationDestination?
}
//
enum NavigationDestination: Equatable {
case login //
case main //
}
enum Action: Equatable {
case onAppear
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 = 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
// Splash
return .send(.checkAuthentication)
case .checkAuthentication:
state.isCheckingAuthentication = true
//
return .run { send in
let authStatus = await UserInfoManager.checkAuthenticationStatus()
await send(.authenticationChecked(authStatus))
}
case let .authenticationChecked(status):
state.isCheckingAuthentication = false
state.authenticationStatus = status
//
if status.canAutoLogin {
debugInfoSync("🎉 自动登录成功,开始获取用户信息")
//
return .send(.fetchUserInfo)
} else {
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
}
}
}
}

Binary file not shown.

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

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

View File

@@ -9,5 +9,11 @@
</dict>
<key>NSWiFiUsageDescription</key>
<string>应用需要访问 Wi-Fi 信息以提供网络相关功能</string>
<key>NSCameraUsageDescription</key>
<string>需要使用相机拍照上传图片</string>
<key>UIAppFonts</key>
<array>
<string>Bayon-Regular.ttf</string>
</array>
</dict>
</plist>

View File

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

View File

@@ -9,6 +9,7 @@ public enum LogLevel: Int {
case error
}
@MainActor
public class LogManager {
///
public static let shared = LogManager()
@@ -18,34 +19,126 @@ public class LogManager {
/// - Parameters:
/// - level:
/// - message:
/// - onlyRelease: Release falseDebug
/// - onlyRelease: Release
public func log(_ level: LogLevel, _ message: @autoclosure () -> String, onlyRelease: Bool = false) {
#if DEBUG
if onlyRelease { return }
// DEBUG onlyRelease true
if !onlyRelease {
print("[\(level)] \(message())")
}
#else
// RELEASE onlyRelease true
if onlyRelease {
print("[\(level)] \(message())")
}
#endif
}
/// DEBUG 使
/// - Parameters:
/// - level:
/// - message:
public func debugLog(_ level: LogLevel, _ message: @autoclosure () -> String) {
#if DEBUG
print("[\(level)] \(message())")
#endif
}
}
// MARK: -
// 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) async {
let msg = message()
await MainActor.run {
LogManager.shared.debugLog(.verbose, msg)
}
}
public func debugLog(_ message: @autoclosure () -> String) async {
let msg = message()
await MainActor.run {
LogManager.shared.debugLog(.debug, msg)
}
}
public func debugInfo(_ message: @autoclosure () -> String) async {
let msg = message()
await MainActor.run {
LogManager.shared.debugLog(.info, msg)
}
}
public func debugWarn(_ message: @autoclosure () -> String) async {
let msg = message()
await MainActor.run {
LogManager.shared.debugLog(.warn, msg)
}
}
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

@@ -1,35 +0,0 @@
import NIMSDK
import NECoreKit
import NECoreIM2Kit
import NEChatKit
import NEChatUIKit
struct NIMConfigurationManager {
static func setupNimSDK() {
let option = configureNIMSDKOption()
setupSDK(with: option)
setupChatSDK(with: option)
}
static func setupSDK(with option: NIMSDKOption) {
NIMSDK.shared().register(with: option)
NIMSDKConfig.shared().shouldConsiderRevokedMessageUnreadCount = true
NIMSDKConfig.shared().shouldSyncStickTopSessionInfos = true
}
static func setupChatSDK(with option: NIMSDKOption) {
let v2Option = V2NIMSDKOption()
v2Option.enableV2CloudConversation = false
// TODO: IMKitClient API
// IMKitClient.shared.setupIM2(option, v2Option)
print("⚠️ NIM SDK 配置暂时被注释,需要修复 IMKitClient API")
}
static func configureNIMSDKOption() -> NIMSDKOption {
let option = NIMSDKOption()
option.appKey = "79bc37000f4018a2a24ea9dc6ca08d32"
option.apnsCername = "pikoDevelopPush"
return option
}
}

View File

@@ -1,127 +0,0 @@
import Foundation
import NIMSDK
// MARK: -
extension Notification.Name {
static let NIMNetworkStateChanged = Notification.Name("NIMNetworkStateChangedNotification")
static let NIMTokenExpired = Notification.Name("NIMTokenExpiredNotification")
}
@objc
@objcMembers
final class NIMSessionManager: NSObject {
static let shared = NIMSessionManager()
// MARK: -
func autoLogin(account: String, token: String, completion: @escaping (Error?) -> Void) {
NIMSDK.shared().v2LoginService.add(self)
let data = NIMAutoLoginData()
data.account = account
data.token = token
data.forcedMode = false
NIMSDK.shared().loginManager.autoLogin(data)
}
func login(account: String, token: String, completion: @escaping (Error?) -> Void) {
NIMSDK.shared().loginManager.login(account, token: token) { error in
if error == nil {
self.registerObservers()
}
completion(error)
}
}
func logout() {
NIMSDK.shared().loginManager.logout { _ in
self.removeObservers()
}
}
// MARK: -
private func registerObservers() {
// autoLogin
// NIMSDK.shared().v2LoginService.add(self as! V2NIMLoginServiceDelegate)
// registerObservers
// NIMSDK.shared().v2LoginService.add(self as! V2NIMLoginServiceDelegate)
// removeObservers
// NIMSDK.shared().v2LoginService.remove(self as! V2NIMLoginServiceDelegate)
NIMSDK.shared().chatManager.add(self)
NIMSDK.shared().loginManager.add(self)
}
private func removeObservers() {
NIMSDK.shared().v2LoginService.remove(self)
NIMSDK.shared().chatManager.remove(self)
NIMSDK.shared().loginManager.remove(self)
}
}
// MARK: - NIMChatManagerDelegate
extension NIMSessionManager: NIMChatManagerDelegate {
func onRecvMessages(_ messages: [NIMMessage]) {
NotificationCenter.default.post(
name: .NIMDidReceiveMessage,
object: messages
)
}
}
// MARK: - NIMLoginManagerDelegate
extension NIMSessionManager: NIMLoginManagerDelegate {
func onLogin(_ step: NIMLoginStep) {
NotificationCenter.default.post(
name: .NIMLoginStateChanged,
object: step
)
}
func onAutoLoginFailed(_ error: Error) {
if (error as NSError).code == 302 {
NotificationCenter.default.post(name: .NIMTokenExpired, object: nil)
}
}
}
// MARK: -
extension Notification.Name {
static let NIMDidReceiveMessage = Notification.Name("NIMDidReceiveMessageNotification")
static let NIMLoginStateChanged = Notification.Name("NIMLoginStateChangedNotification")
}
// MARK: - NIMV2LoginServiceDelegate
extension NIMSessionManager: V2NIMLoginListener {
func onLoginStatus(_ status: V2NIMLoginStatus) {
}
func onLoginFailed(_ error: V2NIMError) {
}
func onKickedOffline(_ detail: V2NIMKickedOfflineDetail) {
}
func onLoginClientChanged(
_ change: V2NIMLoginClientChange,
clients: [V2NIMLoginClient]?
) {
}
// @objc func onLoginProcess(step: NIMV2LoginStep) {
// NotificationCenter.default.post(
// name: .NIMV2LoginStateChanged,
// object: step
// )
// }
//
// @objc func onKickOut(result: NIMKickOutResult) {
// NotificationCenter.default.post(
// name: .NIMKickOutNotification,
// object: result
// )
// }
}

View File

@@ -0,0 +1,132 @@
/*
Localizable.strings
yana
English localization file (auto-aligned)
*/
// MARK: - Login Screen
"login.id_login" = "ID Login";
"login.email_login" = "Email Login";
"login.app_title" = "E-PARTI";
"login.agreement_policy" = "Agree to the \"User Service Agreement\" and \"Privacy Policy\"";
"login.agreement" = "User Service Agreement";
"login.policy" = "Privacy Policy";
// MARK: - Common Buttons
"common.login" = "Login";
"common.register" = "Register";
"common.cancel" = "Cancel";
"common.confirm" = "Confirm";
"common.ok" = "OK";
// MARK: - Error Messages
"error.network" = "Network Error";
"error.invalid_input" = "Invalid Input";
"error.login_failed" = "Login Failed";
// 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 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: - 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";
"email_login.fields_required" = "Please enter email and verification code";
"email_login.get_code" = "Get";
"email_login.resend_code" = "Resend";
"email_login.code_sent" = "Verification code sent";
"email_login.login_button" = "Login";
"email_login.logging_in" = "Logging in...";
"placeholder.enter_email" = "Please enter email";
"placeholder.enter_verification_code" = "Please enter verification code";
// MARK: - Validation 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: - Password Recovery Page
"recover_password.title" = "Recover Password";
"recover_password.placeholder_email" = "Please enter email";
"recover_password.placeholder_verification_code" = "Please enter verification code";
"recover_password.placeholder_new_password" = "6-16 Digits + English Letters";
"recover_password.get_code" = "Get";
"recover_password.confirm_button" = "Confirm";
"recover_password.email_required" = "Please enter email";
"recover_password.invalid_email" = "Please enter a valid email address";
"recover_password.fields_required" = "Please fill in all fields";
"recover_password.invalid_password" = "Password must be 6-16 characters with digits and letters";
"recover_password.code_send_failed" = "Failed to send verification code";
"recover_password.reset_failed" = "Failed to reset password";
"recover_password.reset_success" = "Password reset successfully";
"recover_password.resetting" = "Resetting...";
// MARK: - Home
"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

@@ -0,0 +1,128 @@
/*
Localizable.strings
yana
Created on 2024.
中文简体本地化文件
*/
// MARK: - 登录界面
"login.id_login" = "ID 登录";
"login.email_login" = "邮箱登录";
"login.app_title" = "E-PARTI";
"login.agreement_policy" = "同意《用戶服務協議》和《隱私政策》";
"login.agreement" = "《用戶服務協議》";
"login.policy" = "《隱私政策》";
// MARK: - 通用按钮
"common.login" = "登录";
"common.register" = "注册";
"common.cancel" = "取消";
"common.confirm" = "确认";
"common.ok" = "确定";
// MARK: - 错误信息
"error.network" = "网络错误";
"error.invalid_input" = "输入无效";
"error.login_failed" = "登录失败";
// MARK: - 占位符文本
"placeholder.email" = "请输入邮箱";
"placeholder.password" = "请输入密码";
"placeholder.username" = "请输入用户名";
"placeholder.enter_id" = "请输入ID";
"placeholder.enter_password" = "请输入密码";
// MARK: - ID登录页面
"id_login.title" = "ID 登录";
"id_login.forgot_password" = "忘记密码?";
"id_login.login_button" = "登录";
"id_login.logging_in" = "登录中...";
// MARK: - 邮箱登录页面
"email_login.title" = "邮箱登录";
"email_login.email_required" = "请输入邮箱";
"email_login.invalid_email" = "请输入有效的邮箱地址";
"email_login.fields_required" = "请输入邮箱和验证码";
"email_login.get_code" = "获取验证码";
"email_login.resend_code" = "重新发送";
"email_login.code_sent" = "验证码已发送";
"email_login.login_button" = "登录";
"email_login.logging_in" = "登录中...";
"placeholder.enter_email" = "请输入邮箱";
"placeholder.enter_verification_code" = "请输入验证码";
// MARK: - 验证和错误信息
"validation.id_required" = "请输入您的ID";
"validation.password_required" = "请输入您的密码";
"error.encryption_failed" = "加密失败,请重试";
"error.login_failed" = "登录失败,请检查您的凭据";
// MARK: - 密码恢复页面
"recover_password.title" = "找回密码";
"recover_password.placeholder_email" = "请输入邮箱";
"recover_password.placeholder_verification_code" = "请输入验证码";
"recover_password.placeholder_new_password" = "6-16位数字+英文字母";
"recover_password.get_code" = "获取";
"recover_password.confirm_button" = "确认";
"recover_password.email_required" = "请输入邮箱";
"recover_password.invalid_email" = "请输入有效的邮箱地址";
"recover_password.fields_required" = "请填写所有字段";
"recover_password.invalid_password" = "密码必须是6-16位数字和字母";
"recover_password.code_send_failed" = "验证码发送失败";
"recover_password.reset_failed" = "密码重置失败";
"recover_password.reset_success" = "密码重置成功";
"recover_password.resetting" = "重置中...";
// MARK: - 主页
"home.title" = "享受您的生活时光";
"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

@@ -0,0 +1,227 @@
import SwiftUI
// MARK: - API Loading Effect View
/// API
///
///
/// - Loading 88x8860% alpha
/// - 2
/// -
/// -
struct APILoadingEffectView: View {
@ObservedObject private var loadingManager = APILoadingManager.shared
var body: some View {
ZStack {
// 🚨 ForEach
if let firstItem = getFirstDisplayItem() {
SingleLoadingView(item: firstItem)
.onAppear {
debugInfoSync("🔍 Loading item appeared: \(firstItem.id)")
}
.onDisappear {
debugInfoSync("🔍 Loading item disappeared: \(firstItem.id)")
}
}
}
.allowsHitTesting(false) //
.ignoresSafeArea(.all) //
.onReceive(loadingManager.$loadingItems) { items in
debugInfoSync("🔍 Loading items updated: \(items.count) items")
}
}
///
private func getFirstDisplayItem() -> APILoadingItem? {
guard Thread.isMainThread else {
debugWarnSync("⚠️ getFirstDisplayItem called from background thread")
return nil
}
return loadingManager.loadingItems.first { $0.shouldDisplay }
}
}
// MARK: - Single Loading View
/// -
private struct SingleLoadingView: View {
let item: APILoadingItem
var body: some View {
Group {
switch item.state {
case .loading:
SimpleLoadingView()
case .error(let message):
if item.shouldShowError {
SimpleErrorView(message: message)
}
case .success:
EmptyView() //
}
}
// 🚨
}
}
// MARK: - Simple Loading View
/// Loading
private struct SimpleLoadingView: View {
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
// +
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.6))
.frame(width: 88, height: 88)
// 使 ProgressView
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(1.2)
}
Spacer()
}
Spacer()
}
}
}
// MARK: - Simple Error View
///
private struct SimpleErrorView: View {
let message: String
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
//
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.white)
.font(.title2)
Text(message)
.foregroundColor(.white)
.font(.system(size: 14))
.multilineTextAlignment(.center)
.lineLimit(2)
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.6))
)
.frame(maxWidth: 250)
Spacer()
}
Spacer()
}
}
}
// MARK: - Preview
#if DEBUG
struct APILoadingEffectView_Previews: PreviewProvider {
static var previews: some View {
ZStack {
//
Rectangle()
.fill(Color.blue.opacity(0.3))
.ignoresSafeArea()
VStack(spacing: 20) {
Text("背景内容")
.font(.title)
Button("测试按钮") {
debugInfoSync("按钮被点击了!")
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
// Loading Effect View
APILoadingEffectView()
}
.previewDisplayName("API Loading Effect")
.onAppear {
//
Task {
let manager = APILoadingManager.shared
// loading
let id1 = manager.startLoading()
// 2
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
Task {
manager.setError(id1, errorMessage: "网络连接失败,请检查网络设置")
}
}
}
}
}
}
// MARK: - Preview Helpers
///
private struct PreviewStateModifier: ViewModifier {
let showLoading: Bool
let showError: Bool
let errorMessage: String
func body(content: Content) -> some View {
content
.onAppear {
Task {
let manager = APILoadingManager.shared
if showLoading {
let _ = manager.startLoading()
}
if showError {
let id = manager.startLoading()
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1
manager.setError(id, errorMessage: errorMessage)
}
}
}
}
}
extension View {
///
func previewLoadingState(
showLoading: Bool = false,
showError: Bool = false,
errorMessage: String = "示例错误信息"
) -> some View {
self.modifier(PreviewStateModifier(
showLoading: showLoading,
showError: showError,
errorMessage: errorMessage
))
}
}
#endif

View File

@@ -0,0 +1,137 @@
import Foundation
import SwiftUI
import Combine
// MARK: - API Loading Manager
/// API
///
///
/// - API
/// - 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
/// - shouldShowError:
/// - 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
)
loadingItems.append(loadingItem)
return loadingId
}
/// loading
/// - Parameter id: ID
func finishLoading(_ id: UUID) {
removeLoading(id)
}
/// loading
/// - Parameters:
/// - id: ID
/// - errorMessage:
func setError(_ id: UUID, errorMessage: String) {
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)
loadingItems.removeAll { $0.id == id }
}
///
func clearAll() {
errorCleanupTasks.values.forEach { $0.cancel() }
errorCleanupTasks.removeAll()
loadingItems.removeAll()
}
// MARK: - Computed Properties
/// loading
var hasActiveLoading: Bool {
loadingItems.contains { $0.state == .loading && $0.shouldDisplay }
}
///
var hasActiveError: Bool {
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) {
errorCleanupTasks[id]?.cancel()
errorCleanupTasks.removeValue(forKey: id)
}
}
// MARK: - Convenience Extensions
extension APILoadingManager {
/// 便 loading
/// - Parameters:
/// - shouldShowLoading: loading
/// - shouldShowError:
/// - operation:
/// - Returns:
@MainActor
func withLoading<T: Sendable>(
shouldShowLoading: Bool = true,
shouldShowError: Bool = true,
operation: @escaping () async throws -> T
) async -> Result<T, Error> {
let loadingId = startLoading(
shouldShowLoading: shouldShowLoading,
shouldShowError: shouldShowError
)
do {
let result = try await operation()
finishLoading(loadingId)
return .success(result)
} catch {
setError(loadingId, errorMessage: error.localizedDescription)
return .failure(error)
}
}
}

View File

@@ -0,0 +1,73 @@
import Foundation
// MARK: - API Loading State
/// API
enum APILoadingState: Equatable {
case loading //
case error(message: String) //
case success //
}
// MARK: - API Loading Item
/// API
struct APILoadingItem: Identifiable, Equatable {
let id: UUID
let state: APILoadingState
let shouldShowError: Bool //
let shouldShowLoading: Bool // loading
let createdAt: Date
init(id: UUID = UUID(), state: APILoadingState, shouldShowError: Bool = true, shouldShowLoading: Bool = true) {
self.id = id
self.state = state
self.shouldShowError = shouldShowError
self.shouldShowLoading = shouldShowLoading
self.createdAt = Date()
}
///
var shouldDisplay: Bool {
switch state {
case .loading:
return shouldShowLoading
case .error:
return shouldShowError
case .success:
return false
}
}
///
var isError: Bool {
if case .error = state {
return true
}
return false
}
///
var errorMessage: String? {
if case .error(let message) = state {
return message
}
return nil
}
}
// MARK: - API Loading Configuration
/// API Loading
struct APILoadingConfiguration {
/// Loading
static let loadingSize: CGFloat = 88
///
static let backgroundAlpha: CGFloat = 0.6
///
static let cornerRadius: CGFloat = 12
///
static let errorDisplayDuration: TimeInterval = 2.0
///
static let animationDuration: Double = 0.3
}

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

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

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

View File

@@ -0,0 +1,39 @@
import Foundation
import CommonCrypto
import CryptoKit
// MARK: - String Hash Extensions
extension String {
/// SHA256使
/// - Returns: SHA256
@available(iOS 13.0, *)
func sha256() -> String {
let data = Data(self.utf8)
let digest = SHA256.hash(data: data)
return digest.compactMap { String(format: "%02x", $0) }.joined()
}
/// MD5
///
/// MD5iOS 13.0
/// 使 sha256()
///
/// - Returns: MD5
func md5() -> String {
if #available(iOS 13.0, *) {
// iOS 13+ 使 CryptoKit Insecure.MD5
let data = Data(self.utf8)
let digest = Insecure.MD5.hash(data: data)
return digest.compactMap { String(format: "%02x", $0) }.joined()
} else {
// iOS 13 使 CommonCrypto
let data = Data(self.utf8)
let hash = data.withUnsafeBytes { bytes -> [UInt8] in
var hash = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
CC_MD5(bytes.baseAddress, CC_LONG(data.count), &hash)
return hash
}
return hash.map { String(format: "%02x", $0) }.joined()
}
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More