Compare commits
47 Commits
e-party/1.
...
e-party/tc
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a37d7c6eb8 | ||
![]() |
bc96cc47ff | ||
![]() |
ac0d622c97 | ||
![]() |
2f3ef22ce5 | ||
![]() |
2cfdf110af | ||
![]() |
79fc03b52a | ||
![]() |
815091a2ff | ||
![]() |
fb09ddb956 | ||
![]() |
343fd9e2df | ||
![]() |
c072a7e73d | ||
![]() |
6cc4b11e93 | ||
![]() |
cb325724dc | ||
![]() |
71c40e465d | ||
![]() |
f30026821a | ||
![]() |
25fec8a2e6 | ||
![]() |
3a74547684 | ||
![]() |
bb49b00a59 | ||
![]() |
772543243f | ||
![]() |
8b09653c4c | ||
![]() |
3a68270ca9 | ||
![]() |
0fe3b6cb7a | ||
![]() |
8362142c49 | ||
![]() |
ed3e7100c3 | ||
![]() |
fd6e44c6f9 | ||
![]() |
2a02553015 | ||
![]() |
4eb01bde7c | ||
![]() |
60b3f824be | ||
![]() |
c8ff40cac1 | ||
![]() |
6c363ea884 | ||
![]() |
d4bef537d9 | ||
![]() |
ba991598be | ||
![]() |
5f65df0e7f | ||
![]() |
9a49d591c3 | ||
![]() |
fb7ae9e0ad | ||
![]() |
128bf36c88 | ||
![]() |
4bbb4f8434 | ||
![]() |
33a558ae7b | ||
![]() |
1f98ed534d | ||
![]() |
f686480cdc | ||
![]() |
12bb4a5f8c | ||
![]() |
f9f3dec53f | ||
![]() |
750eecf6ff | ||
![]() |
9844289d72 | ||
![]() |
4a1b814902 | ||
![]() |
6084ade9ea | ||
![]() |
e45ad3bad5 | ||
![]() |
c470dba79c |
@@ -3,67 +3,37 @@ description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# CONTEXT
|
||||
|
||||
# CONTEXT
|
||||
|
||||
I am a native Chinese speaker who has just begun learning Swift 6 and Xcode 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.
|
||||
|
||||
---
|
||||
|
||||
# OBJECTIVE
|
||||
|
||||
As an expert AI programming assistant, your task is to provide me with clear and readable SwiftUI code. You should:
|
||||
|
||||
- Utilize the latest versions of SwiftUI and Swift, being familiar with the newest features and best practices.
|
||||
- Provide careful and accurate answers that are well-founded and thoughtfully considered.
|
||||
- **Explicitly use the Chain-of-Thought (CoT) method in your reasoning and answers, explaining your thought process step by step.**
|
||||
- Strictly adhere to my requirements and meticulously complete the tasks.
|
||||
- Begin by outlining your proposed approach with detailed steps or pseudocode.
|
||||
- Upon confirming the plan, proceed to write the code.
|
||||
|
||||
---
|
||||
|
||||
# STYLE
|
||||
|
||||
- Keep answers concise and direct, minimizing unnecessary wording.
|
||||
- Emphasize code readability over performance optimization.
|
||||
- Maintain a professional and supportive tone, ensuring clarity of content.
|
||||
|
||||
---
|
||||
|
||||
# TONE
|
||||
|
||||
- Be positive and encouraging, helping me improve my programming skills.
|
||||
- Be professional and patient, assisting me in understanding each step.
|
||||
|
||||
---
|
||||
|
||||
# AUDIENCE
|
||||
|
||||
The target audience is me—a native Chinese developer eager to learn Swift 6 and Xcode 16, seeking guidance and advice on utilizing the latest technologies.
|
||||
|
||||
---
|
||||
|
||||
# RESPONSE FORMAT
|
||||
|
||||
- **Utilize the Chain-of-Thought (CoT) method to reason and respond, explaining your thought process step by step.**
|
||||
- Conduct reasoning, thinking, and code writing in English.
|
||||
- The final reply should translate the English into Chinese for me.
|
||||
- The reply should include:
|
||||
|
||||
This project based on iOS 16.0+ & SwiftUI & TCA 1.20.2
|
||||
|
||||
I wish to receive advice using the latest tools and seek step-by-step guidance to fully understand the implementation process.
|
||||
|
||||
## OBJECTIVE
|
||||
|
||||
As an expert AI programming assistant, your task is to provide me with clear, readable, and effective code. You should:
|
||||
|
||||
- Utilize the latest versions of SwiftUI, Swift(6) and TCA(1.20.2), being familiar with the newest features and best practices.
|
||||
- Provide careful and accurate answers that are well-founded and thoughtfully considered.
|
||||
- **Explicitly use the Chain-of-Thought (CoT) method in your reasoning and answers, explaining your thought process step by step.**
|
||||
- Strictly adhere to my requirements and meticulously complete the tasks.
|
||||
- Begin by outlining your proposed approach with detailed steps or pseudocode.
|
||||
- Upon confirming the plan, proceed to write the code.
|
||||
|
||||
## STYLE
|
||||
|
||||
- Keep answers concise and direct, minimizing unnecessary wording.
|
||||
- Emphasize code readability over performance optimization.
|
||||
- Maintain a professional and supportive tone, ensuring clarity of content.
|
||||
|
||||
## RESPONSE FORMAT
|
||||
|
||||
- **Utilize the Chain-of-Thought (CoT) method to reason and respond, explaining your thought process step by step.**
|
||||
- The reply should include:
|
||||
1. **Step-by-Step Plan**: Describe the implementation process with detailed pseudocode or step-by-step explanations, showcasing your thought process.
|
||||
2. **Code Implementation**: Provide correct, up-to-date, error-free, fully functional, runnable, secure, and efficient code. The code should:
|
||||
- Include all necessary imports and properly name key components.
|
||||
- Fully implement all requested features, leaving no to-dos, placeholders, or omissions.
|
||||
3. **Concise Response**: Minimize unnecessary verbosity, focusing only on essential information.
|
||||
|
||||
- If a correct answer may not exist, please point it out. If you do not know the answer, please honestly inform me rather than guessing.
|
||||
|
||||
---
|
||||
|
||||
# START ANALYSIS
|
||||
|
||||
If you understand, please prepare to assist me and await my question.
|
||||
|
||||
- If a correct answer may not exist, please point it out. If you do not know the answer, please honestly inform me rather than guessing.
|
||||
|
@@ -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
|
||||
|
@@ -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
@@ -8,3 +8,7 @@ yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkp
|
||||
.cursor
|
||||
.swiftpm
|
||||
yana.xcworkspace/xcuserdata/edwinqqq.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
|
||||
Doc
|
||||
DerivedData
|
||||
.kiro
|
||||
yana.xcworkspace/xcuserdata
|
||||
|
79
CreateFeedView-Analysis.md
Normal file
@@ -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。
|
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@@ -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",
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
5
Podfile
@@ -1,5 +1,5 @@
|
||||
# Uncomment the next line to define a global platform for your project
|
||||
platform :ios, '13.0'
|
||||
platform :ios, '16.0'
|
||||
|
||||
target 'yana' do
|
||||
# Comment the next line if you don't want to use dynamic frameworks
|
||||
@@ -17,6 +17,9 @@ target 'yana' do
|
||||
|
||||
# Networks
|
||||
pod 'Alamofire'
|
||||
|
||||
# 腾讯云 COS 精简版 SDK
|
||||
pod 'QCloudCOSXML'
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
|
18
Podfile.lock
@@ -1,16 +1,32 @@
|
||||
PODS:
|
||||
- Alamofire (5.10.2)
|
||||
- QCloudCore (6.5.1):
|
||||
- QCloudCore/Default (= 6.5.1)
|
||||
- QCloudCore/Default (6.5.1):
|
||||
- QCloudTrack/Beacon (= 6.5.1)
|
||||
- QCloudCOSXML (6.5.1):
|
||||
- QCloudCOSXML/Default (= 6.5.1)
|
||||
- QCloudCOSXML/Default (6.5.1):
|
||||
- QCloudCore (= 6.5.1)
|
||||
- QCloudTrack/Beacon (6.5.1)
|
||||
|
||||
DEPENDENCIES:
|
||||
- Alamofire
|
||||
- QCloudCOSXML
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- Alamofire
|
||||
- QCloudCore
|
||||
- QCloudCOSXML
|
||||
- QCloudTrack
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||
QCloudCore: 6f8c67b96448472d2c6a92b9cfe1bdb5abbb1798
|
||||
QCloudCOSXML: 92f50a787b4e8d9a7cb6ea8e626775256b4840a7
|
||||
QCloudTrack: 20b79388365b4c8ed150019c82a56f1569f237f8
|
||||
|
||||
PODFILE CHECKSUM: 4ccb5fbbedd3dcb71c35d00e7bfd0d280d4ced88
|
||||
PODFILE CHECKSUM: cd339c4c75faaa076936a34cac2be597c64f138a
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
81
README.md
@@ -2,37 +2,50 @@
|
||||
|
||||
## 项目简介
|
||||
|
||||
Yana 是一个基于 iOS 平台的即时通讯应用,使用 Swift 语言开发,集成了网易云信 SDK 实现即时通讯功能。
|
||||
Yana 是一个基于 iOS 平台的即时通讯应用,使用 Swift 语言开发,集成了网易云信 SDK 实现即时通讯功能,并采用 The Composable Architecture (TCA) 架构设计。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 开发语言:Swift
|
||||
- 最低支持版本:iOS 15.6
|
||||
- 主要框架:
|
||||
- NIMSDK_LITE:网易云信即时通讯 SDK
|
||||
- **开发语言**:Swift (主要),Objective-C (部分组件)
|
||||
- **最低支持版本**:iOS 16
|
||||
- **架构模式**:The Composable Architecture (TCA) - 1.20.2
|
||||
- **UI 框架**:SwiftUI
|
||||
- **依赖管理**:
|
||||
- CocoaPods
|
||||
- Swift Package Manager
|
||||
- **主要框架**:
|
||||
- NIMSDK_LITE:网易云信即时通讯 SDK (10.6.1)
|
||||
- NEChatKit:聊天核心组件
|
||||
- NEChatUIKit:会话(聊天)UI 组件
|
||||
- NEContactUIKit:通讯录 UI 组件
|
||||
- NELocalConversationUIKit:本地会话列表 UI 组件
|
||||
- Alamofire:网络请求框架
|
||||
- ComposableArchitecture:状态管理 (v1.20.2+)
|
||||
- CasePaths:枚举模式匹配
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
yana/
|
||||
├── AppDelegate.swift # 应用程序代理
|
||||
├── yanaApp.swift # SwiftUI 应用入口
|
||||
├── ContentView.swift # 主视图
|
||||
├── Managers/ # 管理器类
|
||||
├── Models/ # 数据模型
|
||||
├── Configs/ # 配置文件
|
||||
└── Assets.xcassets/ # 资源文件
|
||||
├── yana/ # 主应用源代码
|
||||
│ ├── Info.plist
|
||||
│ ├── yana-Bridging-Header.h # Objective-C 集成桥接头文件
|
||||
│ ├── AppDelegate.swift # 应用程序代理
|
||||
│ ├── yanaApp.swift # SwiftUI 应用入口
|
||||
│ ├── ContentView.swift # 主视图
|
||||
│ ├── Managers/ # 管理器类
|
||||
│ ├── Models/ # 数据模型
|
||||
│ ├── Configs/ # 配置文件
|
||||
│ ├── APIs/ # API 相关文件
|
||||
│ └── Assets.xcassets/ # 资源文件
|
||||
├── yanaAPITests/ # API 测试目标
|
||||
└── Pods/ # CocoaPods 依赖
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Xcode 13.0 或更高版本
|
||||
- iOS 15.6 或更高版本
|
||||
- iOS 16 或更高版本
|
||||
- CocoaPods 包管理器
|
||||
|
||||
## 安装步骤
|
||||
@@ -49,10 +62,24 @@ yana/
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 即时通讯
|
||||
- 会话管理
|
||||
- 通讯录管理
|
||||
- 本地会话列表
|
||||
- **用户认证**:
|
||||
- 邮箱登录流程(带验证码)
|
||||
- 多种认证方式
|
||||
- **即时通讯**
|
||||
- **会话管理**
|
||||
- **通讯录管理**
|
||||
- **本地会话列表**
|
||||
- **云存储集成**
|
||||
|
||||
## UI 组件
|
||||
|
||||
项目包含多种自定义 UI 组件:
|
||||
- 自定义登录按钮
|
||||
- 底部标签导航
|
||||
- API 调用加载效果
|
||||
- Web 视图集成
|
||||
- 图片预览功能
|
||||
- 屏幕适配工具
|
||||
|
||||
## API 使用
|
||||
|
||||
@@ -75,21 +102,27 @@ let response = try await apiService.request(request)
|
||||
|
||||
- 项目使用 CocoaPods 管理依赖
|
||||
- 需要配置网易云信相关密钥
|
||||
- 最低支持 iOS 15.6 版本
|
||||
- 最低支持 iOS 16 版本
|
||||
- 仅支持 iPhone 设备(不支持 iPad、Mac Catalyst 或 Vision Pro)
|
||||
|
||||
## 开发规范
|
||||
|
||||
- 遵循 Swift 官方编码规范
|
||||
- 使用 SwiftUI 构建用户界面
|
||||
- 采用 MVVM 架构模式
|
||||
- 采用 TCA 架构模式
|
||||
- 支持多语言(包含中文本地化)
|
||||
|
||||
## 依赖版本
|
||||
## 测试
|
||||
|
||||
- NIMSDK 相关组件版本:10.6.1
|
||||
- Alamofire:最新版本
|
||||
项目包含专门的 API 测试目标 "yanaAPITests",用于对主应用的 API 功能进行单元测试。
|
||||
|
||||
## 开发团队
|
||||
|
||||
项目由团队 "EKM7RAGNA6" 开发,测试目标的包标识符为 "com.stupidmonkey.yanaAPITests"。
|
||||
|
||||
## 构建配置
|
||||
|
||||
- 项目使用动态框架
|
||||
- 支持 iOS 13.0 及以上版本
|
||||
- 已配置框架冲突处理脚本
|
||||
- 支持 iOS 16 及以上版本
|
||||
- Swift 版本:6.0
|
||||
- 已配置框架冲突处理脚本
|
@@ -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 */
|
||||
@@ -47,8 +49,6 @@
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = yanaAPITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -67,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;
|
||||
};
|
||||
@@ -87,7 +89,6 @@
|
||||
4C3E65162DB61F7A00E5A455 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4C4C8FE72DE6F05300384527 /* tools */,
|
||||
4C55BD992DB64C3C0021505D /* yana */,
|
||||
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */,
|
||||
4C3E65202DB61F7A00E5A455 /* Products */,
|
||||
@@ -105,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>";
|
||||
@@ -125,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>";
|
||||
@@ -148,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 = (
|
||||
);
|
||||
@@ -196,7 +191,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1630;
|
||||
LastUpgradeCheck = 1630;
|
||||
LastUpgradeCheck = 1640;
|
||||
TargetAttributes = {
|
||||
4C3E651E2DB61F7A00E5A455 = {
|
||||
CreatedOnToolsVersion = 16.3;
|
||||
@@ -213,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 */;
|
||||
@@ -248,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 = (
|
||||
@@ -256,16 +274,20 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
E0BDB6E67FEFE696E7D48CE4 /* [CP] Check Pods Manifest.lock */ = {
|
||||
5EE242260A8B893DC4D6B6DD /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -320,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";
|
||||
@@ -376,6 +399,7 @@
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 6.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -384,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";
|
||||
@@ -432,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;
|
||||
@@ -462,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;
|
||||
@@ -515,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;
|
||||
@@ -546,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";
|
||||
};
|
||||
@@ -573,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)";
|
||||
@@ -582,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";
|
||||
};
|
||||
@@ -629,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 */
|
||||
@@ -637,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 */;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "e7024a9cd3fa1c40fa4003b3b2b186c00ba720c787de8ba274caf8fc530677e8",
|
||||
"originHash" : "411c4947a4ddec377a0aac37852b26ccdf5b2dd58cb99bedfb2d4c108efd60fd",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
@@ -15,8 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||
"state" : {
|
||||
"revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25",
|
||||
"version" : "1.7.0"
|
||||
"branch" : "main",
|
||||
"revision" : "9810c8d6c2914de251e072312f01d3bf80071852"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
91
yana.xcodeproj/xcshareddata/xcschemes/yana.xcscheme
Normal file
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4C3E651E2DB61F7A00E5A455"
|
||||
BuildableName = "yana.app"
|
||||
BlueprintName = "yana"
|
||||
ReferencedContainer = "container:yana.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4C4C8FBC2DE5AF9200384527"
|
||||
BuildableName = "yanaAPITests.xctest"
|
||||
BlueprintName = "yanaAPITests"
|
||||
ReferencedContainer = "container:yana.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4C3E651E2DB61F7A00E5A455"
|
||||
BuildableName = "yana.app"
|
||||
BlueprintName = "yana"
|
||||
ReferencedContainer = "container:yana.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4C3E651E2DB61F7A00E5A455"
|
||||
BuildableName = "yana.app"
|
||||
BlueprintName = "yana"
|
||||
ReferencedContainer = "container:yana.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
@@ -10,5 +10,18 @@
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>4C3E651E2DB61F7A00E5A455</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>4C4C8FBC2DE5AF9200384527</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "e7024a9cd3fa1c40fa4003b3b2b186c00ba720c787de8ba274caf8fc530677e8",
|
||||
"originHash" : "411c4947a4ddec377a0aac37852b26ccdf5b2dd58cb99bedfb2d4c108efd60fd",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "combine-schedulers",
|
||||
@@ -15,8 +15,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||
"state" : {
|
||||
"revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25",
|
||||
"version" : "1.7.0"
|
||||
"branch" : "main",
|
||||
"revision" : "9810c8d6c2914de251e072312f01d3bf80071852"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
@@ -1,168 +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>
|
||||
<BreakpointProxy
|
||||
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
||||
<BreakpointContent
|
||||
uuid = "B01C5DEF-AE4C-4FE7-B7E5-9EED0586DF0E"
|
||||
shouldBeEnabled = "Yes"
|
||||
ignoreCount = "0"
|
||||
continueAfterRunningActions = "No"
|
||||
filePath = "yana/Configs/ClientConfig.swift"
|
||||
startingColumnNumber = "9223372036854775807"
|
||||
endingColumnNumber = "9223372036854775807"
|
||||
startingLineNumber = "10"
|
||||
endingLineNumber = "10"
|
||||
landmarkName = "initializeClient()"
|
||||
landmarkType = "7">
|
||||
</BreakpointContent>
|
||||
</BreakpointProxy>
|
||||
</Breakpoints>
|
||||
</Bucket>
|
521
yana/APIs/API dynamic feed rule.md
Normal 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. **错误重试**:网络错误时实现自动重试机制
|
@@ -15,7 +15,7 @@
|
||||
|
||||
| 环境 | 地址 | 说明 |
|
||||
|------|------|------|
|
||||
| 生产环境 | `https://api.hfighting.com` | 正式服务器 |
|
||||
| 生产环境 | `https://api.epartylive.com` | 正式服务器 |
|
||||
| 测试环境 | `http://beta.api.molistar.xyz` | 开发测试服务器 |
|
||||
| 图片服务 | `https://image.hfighting.com` | 静态资源服务器 |
|
||||
|
||||
@@ -177,4 +177,4 @@ YuMi iOS 项目的 API 架构设计了完整的网络请求体系,包含:
|
||||
- 🛠️ **开发支持**: 环境切换、错误追踪、调试日志
|
||||
- 🏗️ **架构清晰**: 模块化设计、统一管理、易于维护
|
||||
|
||||
这种设计确保了 API 请求的安全性、稳定性和高性能,为应用提供了可靠的网络服务基础。
|
||||
这种设计确保了 API 请求的安全性、稳定性和高性能,为应用提供了可靠的网络服务基础。
|
||||
|
152
yana/APIs/API-README.md
Normal 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添加日志
|
||||
- 使用测试方法验证功能正确性
|
@@ -3,17 +3,13 @@ import Foundation
|
||||
/// API 常量定义
|
||||
///
|
||||
/// 集中管理 API 相关的常量值,包括:
|
||||
/// - 服务器地址
|
||||
/// - 通用请求头
|
||||
/// - API 端点路径
|
||||
/// - 通用参数
|
||||
///
|
||||
/// 注意:此文件与 APIConfiguration 有部分重复,
|
||||
/// 注意:baseURL已统一到AppConfig中管理
|
||||
/// 建议后续重构时统一到 APIConfiguration 中
|
||||
enum APIConstants {
|
||||
// MARK: - Base URLs
|
||||
/// 测试环境服务器地址
|
||||
static let baseURL = "http://beta.api.molistar.xyz"
|
||||
|
||||
// MARK: - Common Headers
|
||||
/// 通用请求头配置
|
||||
@@ -34,7 +30,7 @@ enum APIConstants {
|
||||
/// 客户端初始化接口
|
||||
static let clientInit = "/client/init"
|
||||
/// 用户登录接口
|
||||
static let login = "/user/login"
|
||||
static let login = "/oauth/token"
|
||||
}
|
||||
|
||||
// MARK: - Common Parameters
|
||||
|
@@ -16,8 +16,20 @@ import Foundation
|
||||
enum APIEndpoint: String, CaseIterable {
|
||||
case config = "/client/config"
|
||||
case configInit = "/client/init"
|
||||
case login = "/auth/login"
|
||||
// 可以继续添加其他端点
|
||||
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
|
||||
@@ -39,10 +51,38 @@ enum APIEndpoint: String, CaseIterable {
|
||||
/// - 防止资源超限的保护机制
|
||||
/// - 自动添加认证和设备信息头部
|
||||
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 限制,防止资源超限
|
||||
|
||||
/// 构建完整的 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 请求都需要的基础请求头,包括:
|
||||
@@ -52,24 +92,30 @@ struct APIConfiguration {
|
||||
/// - 用户认证信息(如果已登录)
|
||||
///
|
||||
/// 这些头部会自动添加到每个请求中,确保服务器能够正确处理请求
|
||||
static var defaultHeaders: [String: String] {
|
||||
static func defaultHeaders() async -> [String: String] {
|
||||
var headers = [
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip, br",
|
||||
"Accept-Language": Locale.current.languageCode ?? "en",
|
||||
"App-Version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||
"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(如果存在)
|
||||
if let userId = UserInfoManager.getCurrentUserId() {
|
||||
headers["pub_uid"] = userId
|
||||
// 检查用户认证状态并添加相关 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)")
|
||||
}
|
||||
|
||||
if let userTicket = UserInfoManager.getCurrentUserTicket() {
|
||||
headers["pub_ticket"] = userTicket
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
}
|
||||
|
@@ -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)] ============")
|
||||
@@ -211,4 +237,4 @@ class APILogger {
|
||||
print("💡 建议:检查网络条件或优化 API 响应")
|
||||
print("================================================\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -35,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 {
|
||||
@@ -53,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)"
|
||||
}
|
||||
@@ -99,9 +111,10 @@ struct BaseRequest: Codable {
|
||||
case pubSign = "pub_sign"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
init() {
|
||||
// 获取系统首选语言
|
||||
let preferredLanguage = Locale.current.languageCode ?? "en"
|
||||
let preferredLanguage = Locale.current.language.languageCode?.identifier ?? "en"
|
||||
self.acceptLanguage = preferredLanguage
|
||||
self.lang = preferredLanguage
|
||||
|
||||
@@ -118,7 +131,7 @@ struct BaseRequest: Codable {
|
||||
self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||
|
||||
// 应用名称
|
||||
self.app = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "yana"
|
||||
self.app = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "eparty"
|
||||
|
||||
// 网络类型检测(WiFi=2, 蜂窝网络=1)
|
||||
self.netType = NetworkTypeDetector.getCurrentNetworkType()
|
||||
@@ -131,7 +144,7 @@ struct BaseRequest: Codable {
|
||||
|
||||
// 渠道信息
|
||||
#if DEBUG
|
||||
self.channel = "TestFlight"
|
||||
self.channel = "molistar_enterprise"
|
||||
#else
|
||||
self.channel = "appstore"
|
||||
#endif
|
||||
@@ -186,9 +199,10 @@ struct BaseRequest: Codable {
|
||||
}
|
||||
|
||||
// 3. 按 key 升序排序并拼接
|
||||
// 拼接格式 "key0=value0&key1=value1&key2=value2"
|
||||
let sortedKeys = filteredParams.keys.sorted()
|
||||
let paramString = sortedKeys.map { key in
|
||||
"\(key)=\(filteredParams[key] ?? "")"
|
||||
"\(key)=\(String(describing: filteredParams[key] ?? ""))"
|
||||
}.joined(separator: "&")
|
||||
|
||||
// 4. 添加密钥
|
||||
@@ -205,7 +219,7 @@ struct NetworkTypeDetector {
|
||||
static func getCurrentNetworkType() -> Int {
|
||||
// WiFi = 2, 蜂窝网络 = 1
|
||||
// 这里是简化实现,实际应该检测网络状态
|
||||
return 1 // 默认蜂窝网络
|
||||
return 2 // 默认蜂窝网络
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,17 +238,365 @@ struct CarrierInfoManager {
|
||||
|
||||
// MARK: - User Info Manager (for Headers)
|
||||
struct UserInfoManager {
|
||||
static func getCurrentUserId() -> String? {
|
||||
// 从存储中获取当前用户 ID
|
||||
// 实际实现应该从 AccountInfoStorage 或类似的地方获取
|
||||
return nil
|
||||
@MainActor
|
||||
private static let keychain = KeychainManager.shared
|
||||
|
||||
// MARK: - Storage Keys
|
||||
private enum StorageKeys {
|
||||
static let accountModel = "account_model"
|
||||
static let userInfo = "user_info"
|
||||
}
|
||||
|
||||
static func getCurrentUserTicket() -> String? {
|
||||
// 从存储中获取当前用户认证票据
|
||||
// 实际实现应该从 AccountInfoStorage 或类似的地方获取
|
||||
return nil
|
||||
// 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("❌ 无法更新 ticket:AccountModel 不存在")
|
||||
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
|
||||
@@ -245,7 +607,7 @@ struct UserInfoManager {
|
||||
/// 每个具体的 API 请求都应该实现这个协议。
|
||||
///
|
||||
/// 协议要求:
|
||||
/// - Response: 关联类型,定义响应数据的类型
|
||||
/// - Response: 关联类型,定义响应数据的类型,必须 Sendable
|
||||
/// - endpoint: API 端点路径
|
||||
/// - method: HTTP 请求方法
|
||||
/// - 可选的查询参数、请求体参数、请求头等
|
||||
@@ -259,22 +621,34 @@ struct UserInfoManager {
|
||||
/// // ... 其他属性
|
||||
/// }
|
||||
/// ```
|
||||
protocol APIRequestProtocol {
|
||||
associatedtype Response: Codable
|
||||
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
|
||||
@@ -285,19 +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
|
||||
}
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
// 注意: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 // 不显示错误,静默处理
|
||||
}
|
||||
|
||||
// 需要导入 CommonCrypto
|
||||
import CommonCrypto
|
||||
/// 腾讯云 COS Token 响应模型
|
||||
struct TcTokenResponse: Codable, Equatable {
|
||||
let code: Int
|
||||
let message: String
|
||||
let data: TcTokenData?
|
||||
let timestamp: Int64
|
||||
}
|
||||
|
||||
/// 腾讯云 COS Token 数据模型
|
||||
/// 包含完整的腾讯云 COS 配置信息
|
||||
struct TcTokenData: Codable, Equatable {
|
||||
let bucket: String // 存储桶名称
|
||||
let sessionToken: String // 临时会话令牌
|
||||
let region: String // 地域
|
||||
let customDomain: String // 自定义域名
|
||||
let accelerate: Bool // 是否启用加速
|
||||
let appId: String // 应用 ID
|
||||
let secretKey: String // 临时密钥
|
||||
let expireTime: Int64 // 过期时间戳
|
||||
let startTime: Int64 // 开始时间戳
|
||||
let secretId: String // 临时密钥 ID
|
||||
|
||||
/// 检查 Token 是否已过期
|
||||
var isExpired: Bool {
|
||||
let currentTime = Int64(Date().timeIntervalSince1970)
|
||||
return currentTime >= expireTime
|
||||
}
|
||||
|
||||
/// 获取过期时间
|
||||
var expirationDate: Date {
|
||||
return Date(timeIntervalSince1970: TimeInterval(expireTime))
|
||||
}
|
||||
|
||||
/// 获取开始时间
|
||||
var startDate: Date {
|
||||
return Date(timeIntervalSince1970: TimeInterval(startTime))
|
||||
}
|
||||
|
||||
/// 获取剩余有效时间(秒)
|
||||
var remainingTime: Int64 {
|
||||
let currentTime = Int64(Date().timeIntervalSince1970)
|
||||
return max(0, expireTime - currentTime)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Info API Management
|
||||
extension UserInfoManager {
|
||||
|
||||
/// 从服务器获取用户信息
|
||||
/// - Parameters:
|
||||
/// - uid: 用户ID,如果为nil则使用当前登录用户的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?
|
||||
}
|
||||
|
||||
|
@@ -14,7 +14,7 @@ import ComposableArchitecture
|
||||
/// let request = ConfigRequest()
|
||||
/// let response = try await apiService.request(request)
|
||||
/// ```
|
||||
protocol APIServiceProtocol {
|
||||
protocol APIServiceProtocol: Sendable {
|
||||
/// 发起网络请求
|
||||
/// - Parameter request: 符合 APIRequestProtocol 的请求对象
|
||||
/// - Returns: 请求对应的响应对象
|
||||
@@ -39,19 +39,22 @@ protocol APIServiceProtocol {
|
||||
/// - 完整的错误处理和重试机制
|
||||
/// - 详细的请求/响应日志记录
|
||||
/// - 防止资源超限的保护机制
|
||||
struct LiveAPIService: APIServiceProtocol {
|
||||
struct LiveAPIService: APIServiceProtocol, Sendable {
|
||||
private let session: URLSession
|
||||
private let baseURL: String
|
||||
// 缓存主 actor 配置,避免并发隔离问题
|
||||
private static let cachedBaseURL: String = APIConfiguration.baseURL
|
||||
private static let cachedTimeout: TimeInterval = APIConfiguration.timeout
|
||||
|
||||
/// 初始化 API 服务
|
||||
/// - Parameter baseURL: API 服务器基础 URL,默认使用配置中的地址
|
||||
init(baseURL: String = APIConfiguration.baseURL) {
|
||||
/// - Parameter baseURL: API 服务器基础 URL,默认使用静态缓存
|
||||
init(baseURL: String = LiveAPIService.cachedBaseURL) {
|
||||
self.baseURL = baseURL
|
||||
|
||||
// 配置 URLSession 以防止资源超限问题
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = APIConfiguration.timeout
|
||||
config.timeoutIntervalForResource = APIConfiguration.timeout * 2
|
||||
config.timeoutIntervalForRequest = LiveAPIService.cachedTimeout
|
||||
config.timeoutIntervalForResource = LiveAPIService.cachedTimeout * 2
|
||||
config.waitsForConnectivity = true
|
||||
config.allowsCellularAccess = true
|
||||
|
||||
@@ -77,8 +80,15 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -86,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)
|
||||
}
|
||||
@@ -101,25 +117,39 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
var requestBody: Data? = nil
|
||||
if request.method != .GET, let bodyParams = request.bodyParameters {
|
||||
do {
|
||||
// 如果需要包含基础参数,则合并
|
||||
var finalBody = bodyParams
|
||||
|
||||
// 如果需要包含基础参数,则先合并所有参数,再统一生成签名
|
||||
if request.includeBaseParameters {
|
||||
var baseParams = BaseRequest()
|
||||
// 生成符合 API rule 的签名
|
||||
// 第一步:创建基础参数实例(不包含签名)
|
||||
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 {
|
||||
// 发起请求
|
||||
@@ -128,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
|
||||
}
|
||||
|
||||
@@ -158,24 +196,37 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let decodedResponse = try decoder.decode(T.Response.self, from: data)
|
||||
APILogger.logDecodedResponse(decodedResponse, type: T.Response.self)
|
||||
await APILogger.logDecodedResponse(decodedResponse, type: T.Response.self)
|
||||
|
||||
// 请求成功,完成 loading
|
||||
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
|
||||
|
||||
/// 构建完整的请求 URL
|
||||
@@ -187,7 +238,7 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
///
|
||||
/// - Parameter request: API 请求对象
|
||||
/// - Returns: 构建完成的 URL,如果构建失败则返回 nil
|
||||
private func buildURL<T: APIRequestProtocol>(for request: T) -> URL? {
|
||||
@MainActor private func buildURL<T: APIRequestProtocol>(for request: T) -> URL? {
|
||||
guard var urlComponents = URLComponents(string: baseURL + request.endpoint) else {
|
||||
return nil
|
||||
}
|
||||
@@ -198,16 +249,22 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
// 对于 GET 请求,将基础参数添加到查询参数中
|
||||
if request.method == .GET && request.includeBaseParameters {
|
||||
do {
|
||||
// 第一步:创建基础参数实例(不包含签名)
|
||||
var baseParams = BaseRequest()
|
||||
// 为 GET 请求生成签名(合并查询参数)
|
||||
|
||||
// 第二步:基于所有参数(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("警告:无法添加基础参数到查询字符串")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,46 +332,31 @@ struct LiveAPIService: APIServiceProtocol {
|
||||
|
||||
// MARK: - Mock API Service (for testing)
|
||||
|
||||
/// 模拟 API 服务,用于测试和开发
|
||||
///
|
||||
/// 该类提供了一个可配置的模拟 API 服务,可以:
|
||||
/// - 设置预定义的响应数据
|
||||
/// - 模拟网络延迟
|
||||
/// - 用于单元测试和 UI 预览
|
||||
///
|
||||
/// 使用示例:
|
||||
/// ```swift
|
||||
/// var mockService = MockAPIService()
|
||||
/// mockService.setMockResponse(for: "/client/config", response: mockConfigResponse)
|
||||
/// let response = try await mockService.request(ConfigRequest())
|
||||
/// ```
|
||||
struct MockAPIService: APIServiceProtocol {
|
||||
/// 并发安全的 Mock API Service
|
||||
actor MockAPIServiceActor: APIServiceProtocol, Sendable {
|
||||
private var mockResponses: [String: Any] = [:]
|
||||
|
||||
mutating func setMockResponse<T>(for endpoint: String, response: T) {
|
||||
|
||||
func setMockResponse<T>(for endpoint: String, response: T) {
|
||||
mockResponses[endpoint] = response
|
||||
}
|
||||
|
||||
|
||||
func request<T: APIRequestProtocol>(_ request: T) async throws -> T.Response {
|
||||
// 模拟网络延迟
|
||||
try await Task.sleep(nanoseconds: 500_000_000) // 0.5 秒
|
||||
|
||||
if let mockResponse = mockResponses[request.endpoint] as? T.Response {
|
||||
return mockResponse
|
||||
}
|
||||
|
||||
throw APIError.noData
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TCA Dependency Integration
|
||||
private enum APIServiceKey: DependencyKey {
|
||||
static let liveValue: APIServiceProtocol = LiveAPIService()
|
||||
static let testValue: APIServiceProtocol = MockAPIService()
|
||||
static let liveValue: any APIServiceProtocol & Sendable = LiveAPIService()
|
||||
static let testValue: any APIServiceProtocol & Sendable = MockAPIServiceActor()
|
||||
}
|
||||
|
||||
extension DependencyValues {
|
||||
var apiService: APIServiceProtocol {
|
||||
var apiService: (any APIServiceProtocol & Sendable) {
|
||||
get { self[APIServiceKey.self] }
|
||||
set { self[APIServiceKey.self] = newValue }
|
||||
}
|
||||
@@ -329,4 +371,4 @@ extension BaseRequest {
|
||||
}
|
||||
return dictionary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
283
yana/APIs/DynamicsModels.swift
Normal 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
@@ -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: DES加密后的用户ID/手机号
|
||||
/// - password: DES加密后的密码
|
||||
/// - clientSecret: 客户端密钥,固定为"uyzjdhds"
|
||||
/// - version: 版本号,固定为"1"
|
||||
/// - clientId: 客户端ID,固定为"erban-client"
|
||||
/// - grantType: 授权类型,固定为"password"
|
||||
init(phone: String, password: String, clientSecret: String = "uyzjdhds", version: String = "1", clientId: String = "erban-client", grantType: String = "password") {
|
||||
self.queryParameters = [
|
||||
"phone": phone,
|
||||
"password": password,
|
||||
"client_secret": clientSecret,
|
||||
"version": version,
|
||||
"client_id": clientId,
|
||||
"grant_type": grantType
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 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: 配置好的API请求,如果加密失败返回nil
|
||||
static func createIDLoginRequest(userID: String, password: String) async -> IDLoginAPIRequest? {
|
||||
// 使用DES加密ID和密码
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedID = DESEncrypt.encryptUseDES(userID, key: encryptionKey),
|
||||
let encryptedPassword = DESEncrypt.encryptUseDES(password, key: encryptionKey) else {
|
||||
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: 配置好的API请求,如果加密失败返回nil
|
||||
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: 配置好的API请求,如果加密失败返回nil
|
||||
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
@@ -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
|
||||
}
|
||||
=====================================
|
434
yana/APIs/email login flow.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# 邮箱验证码登录流程文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档详细描述了 YuMi iOS 应用中 `LoginTypesViewController` 在 `LoginDisplayType_email` 模式下的邮箱验证码登录流程。该流程实现了基于邮箱和验证码的用户认证机制。
|
||||
|
||||
## 系统架构
|
||||
|
||||
### 核心组件
|
||||
- **LoginTypesViewController**: 登录类型控制器,负责 UI 展示和用户交互
|
||||
- **LoginPresenter**: 登录业务逻辑处理器,负责与 API 交互
|
||||
- **LoginInputItemView**: 输入组件,提供邮箱和验证码输入界面
|
||||
- **Api+Login**: 登录相关 API 接口封装
|
||||
- **AccountInfoStorage**: 账户信息本地存储管理
|
||||
|
||||
### 数据模型
|
||||
|
||||
#### LoginDisplayType 枚举
|
||||
```objc
|
||||
typedef NS_ENUM(NSUInteger, LoginDisplayType) {
|
||||
LoginDisplayType_id, // ID 登录
|
||||
LoginDisplayType_email, // 邮箱登录 ✓
|
||||
LoginDisplayType_phoneNum, // 手机号登录
|
||||
LoginDisplayType_email_forgetPassword, // 邮箱忘记密码
|
||||
LoginDisplayType_phoneNum_forgetPassword, // 手机号忘记密码
|
||||
};
|
||||
```
|
||||
|
||||
#### LoginInputType 枚举
|
||||
```objc
|
||||
typedef NS_ENUM(NSUInteger, LoginInputType) {
|
||||
LoginInputType_email, // 邮箱输入
|
||||
LoginInputType_verificationCode, // 验证码输入
|
||||
LoginInputType_login, // 登录按钮
|
||||
// ... 其他类型
|
||||
};
|
||||
```
|
||||
|
||||
#### GetSmsType 验证码类型
|
||||
```objc
|
||||
typedef NS_ENUM(NSUInteger, GetSmsType) {
|
||||
GetSmsType_Regist = 1, // 注册(邮箱登录使用此类型)
|
||||
GetSmsType_Login = 2, // 登录
|
||||
GetSmsType_Reset_Password = 3, // 重设密码
|
||||
// ... 其他类型
|
||||
};
|
||||
```
|
||||
|
||||
## 登录流程详解
|
||||
|
||||
### 1. 界面初始化流程
|
||||
|
||||
#### 1.1 控制器初始化
|
||||
```objc
|
||||
// 在 LoginViewController 中点击邮箱登录按钮
|
||||
- (void)didTapEntrcyButton:(UIButton *)sender {
|
||||
if (sender.tag == LoginType_Email) {
|
||||
LoginTypesViewController *vc = [[LoginTypesViewController alloc] init];
|
||||
[self.navigationController pushViewController:vc animated:YES];
|
||||
[vc updateLoginType:LoginDisplayType_email]; // 设置为邮箱登录模式
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 输入区域设置
|
||||
```objc
|
||||
- (void)setupEmailInputArea {
|
||||
[self setupInpuArea:LoginInputType_email // 第一行:邮箱输入
|
||||
second:LoginInputType_verificationCode // 第二行:验证码输入
|
||||
third:LoginInputType_none // 第三行:无
|
||||
action:LoginInputType_login // 操作按钮:登录
|
||||
showForgetPassword:NO]; // 不显示忘记密码
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 UI 组件配置
|
||||
- **第一行输入框**: 邮箱地址输入
|
||||
- 占位符: "请输入邮箱地址"
|
||||
- 键盘类型: `UIKeyboardTypeEmailAddress`
|
||||
- 回调: `handleFirstInputContentUpdate`
|
||||
|
||||
- **第二行输入框**: 验证码输入
|
||||
- 占位符: "请输入验证码"
|
||||
- 键盘类型: `UIKeyboardTypeDefault`
|
||||
- 附带"获取验证码"按钮
|
||||
- 回调: `handleSecondInputContentUpdate`
|
||||
|
||||
### 2. 验证码获取流程
|
||||
|
||||
#### 2.1 用户交互触发
|
||||
```objc
|
||||
// 用户点击"获取验证码"按钮
|
||||
[self.secondLineInputView setHandleItemAction:^(LoginInputType inputType) {
|
||||
if (inputType == LoginInputType_verificationCode) {
|
||||
if (self.type == LoginDisplayType_email) {
|
||||
[self handleTapGetMailVerificationCode];
|
||||
}
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
#### 2.2 邮箱验证码获取处理
|
||||
```objc
|
||||
- (void)handleTapGetMailVerificationCode {
|
||||
NSString *email = [self.firstLineInputView inputContent];
|
||||
|
||||
// 邮箱地址验证
|
||||
if (email.length == 0) {
|
||||
[self.secondLineInputView endVerificationCountDown];
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用 Presenter 发送验证码
|
||||
[self.presenter sendMailVerificationCode:email type:GetSmsType_Regist];
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 Presenter 层处理
|
||||
```objc
|
||||
- (void)sendMailVerificationCode:(NSString *)emailAddress type:(NSInteger)type {
|
||||
// DES 加密邮箱地址
|
||||
NSString *desEmail = [DESEncrypt encryptUseDES:emailAddress
|
||||
key:KeyWithType(KeyType_PasswordEncode)];
|
||||
|
||||
@kWeakify(self);
|
||||
[Api emailGetCode:[self createHttpCompletion:^(BaseModel *data) {
|
||||
@kStrongify(self);
|
||||
if ([[self getView] respondsToSelector:@selector(emailCodeSucess:type:)]) {
|
||||
[[self getView] emailCodeSucess:@"" type:type];
|
||||
}
|
||||
} fail:^(NSInteger code, NSString *msg) {
|
||||
@kStrongify(self);
|
||||
if ([[self getView] respondsToSelector:@selector(emailCodeFailure)]) {
|
||||
[[self getView] emailCodeFailure];
|
||||
}
|
||||
} showLoading:YES errorToast:YES]
|
||||
emailAddress:desEmail
|
||||
type:@(type)];
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.4 API 接口调用
|
||||
```objc
|
||||
+ (void)emailGetCode:(HttpRequestHelperCompletion)completion
|
||||
emailAddress:(NSString *)emailAddress
|
||||
type:(NSNumber *)type {
|
||||
[self makeRequest:@"email/getCode"
|
||||
method:HttpRequestHelperMethodPOST
|
||||
completion:completion, __FUNCTION__, emailAddress, type, nil];
|
||||
}
|
||||
```
|
||||
|
||||
**API 详情**:
|
||||
- **接口路径**: `POST /email/getCode`
|
||||
- **请求参数**:
|
||||
- `emailAddress`: 邮箱地址(DES 加密)
|
||||
- `type`: 验证码类型(1=注册)
|
||||
|
||||
#### 2.5 获取验证码成功处理
|
||||
```objc
|
||||
- (void)emailCodeSucess:(NSString *)message type:(GetSmsType)type {
|
||||
[self showSuccessToast:YMLocalizedString(@"XPLoginPhoneViewController2")]; // "验证码已发送"
|
||||
[self.secondLineInputView startVerificationCountDown]; // 开始倒计时
|
||||
[self.secondLineInputView displayKeyboard]; // 显示键盘
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.6 获取验证码失败处理
|
||||
```objc
|
||||
- (void)emailCodeFailure {
|
||||
[self.secondLineInputView endVerificationCountDown]; // 结束倒计时
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 邮箱登录流程
|
||||
|
||||
#### 3.1 登录按钮状态检查
|
||||
```objc
|
||||
- (void)checkActionButtonStatus {
|
||||
switch (self.type) {
|
||||
case LoginDisplayType_email: {
|
||||
NSString *accountString = [self.firstLineInputView inputContent]; // 邮箱
|
||||
NSString *codeString = [self.secondLineInputView inputContent]; // 验证码
|
||||
|
||||
// 只有当邮箱和验证码都不为空时才启用登录按钮
|
||||
if (![NSString isEmpty:accountString] && ![NSString isEmpty:codeString]) {
|
||||
self.bottomActionButton.enabled = YES;
|
||||
} else {
|
||||
self.bottomActionButton.enabled = NO;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 登录按钮点击处理
|
||||
```objc
|
||||
- (void)didTapActionButton {
|
||||
[self.view endEditing:true];
|
||||
|
||||
switch (self.type) {
|
||||
case LoginDisplayType_email: {
|
||||
// 调用 Presenter 进行邮箱登录
|
||||
[self.presenter loginWithEmail:[self.firstLineInputView inputContent]
|
||||
code:[self.secondLineInputView inputContent]];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 Presenter 层登录处理
|
||||
```objc
|
||||
- (void)loginWithEmail:(NSString *)email code:(NSString *)code {
|
||||
// DES 加密邮箱地址
|
||||
NSString *desMail = [DESEncrypt encryptUseDES:email
|
||||
key:KeyWithType(KeyType_PasswordEncode)];
|
||||
|
||||
@kWeakify(self);
|
||||
[Api loginWithCode:[self createHttpCompletion:^(BaseModel *data) {
|
||||
@kStrongify(self);
|
||||
|
||||
// 解析账户模型
|
||||
AccountModel *accountModel = [AccountModel modelWithDictionary:data.data];
|
||||
|
||||
// 保存账户信息
|
||||
if (accountModel && accountModel.access_token.length > 0) {
|
||||
[[AccountInfoStorage instance] saveAccountInfo:accountModel];
|
||||
}
|
||||
|
||||
// 通知登录成功
|
||||
if ([[self getView] respondsToSelector:@selector(loginSuccess)]) {
|
||||
[[self getView] loginSuccess];
|
||||
}
|
||||
} fail:^(NSInteger code, NSString *msg) {
|
||||
@kStrongify(self);
|
||||
[[self getView] loginFailWithMsg:msg];
|
||||
} errorToast:NO]
|
||||
email:desMail
|
||||
code:code
|
||||
client_secret:clinet_s // 客户端密钥
|
||||
version:@"1"
|
||||
client_id:@"erban-client"
|
||||
grant_type:@"email"]; // 邮箱登录类型
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4 API 接口调用
|
||||
```objc
|
||||
+ (void)loginWithCode:(HttpRequestHelperCompletion)completion
|
||||
email:(NSString *)email
|
||||
code:(NSString *)code
|
||||
client_secret:(NSString *)client_secret
|
||||
version:(NSString *)version
|
||||
client_id:(NSString *)client_id
|
||||
grant_type:(NSString *)grant_type {
|
||||
|
||||
NSString *fang = [NSString stringFromBase64String:@"b2F1dGgvdG9rZW4="]; // oauth/token
|
||||
[self makeRequest:fang
|
||||
method:HttpRequestHelperMethodPOST
|
||||
completion:completion, __FUNCTION__, email, code, client_secret,
|
||||
version, client_id, grant_type, nil];
|
||||
}
|
||||
```
|
||||
|
||||
**API 详情**:
|
||||
- **接口路径**: `POST /oauth/token`
|
||||
- **请求参数**:
|
||||
- `email`: 邮箱地址(DES 加密)
|
||||
- `code`: 验证码
|
||||
- `client_secret`: 客户端密钥
|
||||
- `version`: 版本号 "1"
|
||||
- `client_id`: 客户端ID "erban-client"
|
||||
- `grant_type`: 授权类型 "email"
|
||||
|
||||
#### 3.5 登录成功处理
|
||||
```objc
|
||||
- (void)loginSuccess {
|
||||
[self showSuccessToast:YMLocalizedString(@"XPLoginPhoneViewController1")]; // "登录成功"
|
||||
[PILoginManager loginWithVC:self isLoginPhone:NO]; // 执行登录后续处理
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.6 登录失败处理
|
||||
```objc
|
||||
- (void)loginFailWithMsg:(NSString *)msg {
|
||||
[self showSuccessToast:msg]; // 显示错误信息
|
||||
}
|
||||
```
|
||||
|
||||
## 数据流时序图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant VC as LoginTypesViewController
|
||||
participant IV as LoginInputItemView
|
||||
participant P as LoginPresenter
|
||||
participant API as Api+Login
|
||||
participant Storage as AccountInfoStorage
|
||||
|
||||
Note over User,Storage: 1. 初始化邮箱登录界面
|
||||
User->>VC: 选择邮箱登录
|
||||
VC->>VC: updateLoginType(LoginDisplayType_email)
|
||||
VC->>VC: setupEmailInputArea()
|
||||
VC->>IV: 创建邮箱输入框
|
||||
VC->>IV: 创建验证码输入框
|
||||
|
||||
Note over User,Storage: 2. 获取邮箱验证码
|
||||
User->>IV: 输入邮箱地址
|
||||
User->>IV: 点击"获取验证码"
|
||||
IV->>VC: handleTapGetMailVerificationCode
|
||||
VC->>VC: 验证邮箱地址非空
|
||||
VC->>P: sendMailVerificationCode(email, GetSmsType_Regist)
|
||||
P->>P: DES加密邮箱地址
|
||||
P->>API: emailGetCode(encryptedEmail, type=1)
|
||||
API-->>P: 验证码发送结果
|
||||
P-->>VC: emailCodeSucess / emailCodeFailure
|
||||
VC->>IV: startVerificationCountDown / endVerificationCountDown
|
||||
VC->>User: 显示成功/失败提示
|
||||
|
||||
Note over User,Storage: 3. 邮箱验证码登录
|
||||
User->>IV: 输入验证码
|
||||
IV->>VC: 输入内容变化回调
|
||||
VC->>VC: checkActionButtonStatus()
|
||||
VC->>User: 启用/禁用登录按钮
|
||||
User->>VC: 点击登录按钮
|
||||
VC->>VC: didTapActionButton()
|
||||
VC->>P: loginWithEmail(email, code)
|
||||
P->>P: DES加密邮箱地址
|
||||
P->>API: loginWithCode(email, code, ...)
|
||||
API-->>P: OAuth Token 响应
|
||||
P->>P: 解析 AccountModel
|
||||
P->>Storage: saveAccountInfo(accountModel)
|
||||
P-->>VC: loginSuccess / loginFailWithMsg
|
||||
VC->>User: 显示登录结果
|
||||
VC->>User: 跳转到主界面
|
||||
```
|
||||
|
||||
## 安全机制
|
||||
|
||||
### 1. 数据加密
|
||||
- **邮箱地址加密**: 使用 DES 算法加密邮箱地址后传输
|
||||
```objc
|
||||
NSString *desEmail = [DESEncrypt encryptUseDES:email key:KeyWithType(KeyType_PasswordEncode)];
|
||||
```
|
||||
|
||||
### 2. 输入验证
|
||||
- **邮箱格式验证**: 通过 `UIKeyboardTypeEmailAddress` 键盘类型引导正确输入
|
||||
- **非空验证**: 邮箱和验证码都必须非空才能执行登录
|
||||
|
||||
### 3. 验证码安全
|
||||
- **时效性**: 验证码具有倒计时机制,防止重复获取
|
||||
- **类型标识**: 使用 `GetSmsType_Regist = 1` 标识登录验证码
|
||||
|
||||
### 4. 网络安全
|
||||
- **错误处理**: 完整的成功/失败回调机制
|
||||
- **加载状态**: `showLoading:YES` 防止重复请求
|
||||
- **错误提示**: `errorToast:YES` 显示网络错误
|
||||
|
||||
## 错误处理机制
|
||||
|
||||
### 1. 邮箱验证码获取错误
|
||||
```objc
|
||||
- (void)emailCodeFailure {
|
||||
[self.secondLineInputView endVerificationCountDown]; // 停止倒计时
|
||||
// 用户可以重新获取验证码
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 登录失败处理
|
||||
```objc
|
||||
- (void)loginFailWithMsg:(NSString *)msg {
|
||||
[self showSuccessToast:msg]; // 显示具体错误信息
|
||||
// 用户可以重新尝试登录
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 网络请求错误
|
||||
- **自动重试**: 用户可以手动重新点击获取验证码或登录
|
||||
- **错误提示**: 通过 Toast 显示具体错误信息
|
||||
- **状态恢复**: 失败后恢复按钮可点击状态
|
||||
|
||||
## 本地化支持
|
||||
|
||||
### 关键文本资源
|
||||
- `@"20.20.51_text_1"`: "邮箱登录"
|
||||
- `@"20.20.51_text_4"`: "请输入邮箱地址"
|
||||
- `@"20.20.51_text_7"`: "请输入验证码"
|
||||
- `@"XPLoginPhoneViewController2"`: "验证码已发送"
|
||||
- `@"XPLoginPhoneViewController1"`: "登录成功"
|
||||
|
||||
### 多语言支持
|
||||
- 简体中文 (`zh-Hant.lproj`)
|
||||
- 英文 (`en.lproj`)
|
||||
- 阿拉伯语 (`ar.lproj`)
|
||||
- 土耳其语 (`tr.lproj`)
|
||||
|
||||
## 依赖组件
|
||||
|
||||
### 外部框架
|
||||
- **MASConstraintMaker**: 自动布局
|
||||
- **ReactiveObjC**: 响应式编程(部分组件使用)
|
||||
|
||||
### 内部组件
|
||||
- **YMLocalizedString**: 本地化字符串管理
|
||||
- **DESEncrypt**: DES 加密工具
|
||||
- **AccountInfoStorage**: 账户信息存储
|
||||
- **HttpRequestHelper**: 网络请求管理
|
||||
|
||||
## 扩展和维护
|
||||
|
||||
### 新增功能建议
|
||||
1. **邮箱格式验证**: 添加正则表达式验证邮箱格式
|
||||
2. **验证码长度限制**: 限制验证码输入长度
|
||||
3. **自动填充**: 支持系统邮箱自动填充
|
||||
4. **记住邮箱**: 保存最近使用的邮箱地址
|
||||
|
||||
### 性能优化
|
||||
1. **请求去重**: 防止短时间内重复请求验证码
|
||||
2. **缓存机制**: 缓存验证码倒计时状态
|
||||
3. **网络优化**: 添加请求超时和重试机制
|
||||
|
||||
### 代码维护
|
||||
1. **常量管理**: 将硬编码字符串提取为常量
|
||||
2. **错误码统一**: 统一管理API错误码
|
||||
3. **日志记录**: 添加详细的操作日志
|
||||
|
||||
## 总结
|
||||
|
||||
邮箱验证码登录流程是一个完整的用户认证系统,包含了界面展示、验证码获取、用户登录、数据存储等完整环节。该流程具有良好的安全性、用户体验和错误处理机制,符合现代移动应用的认证标准。
|
||||
|
||||
通过本文档,开发人员可以全面了解邮箱登录的实现细节,便于后续的功能扩展和维护工作。
|
1
yana/APIs/email login flow.svg
Normal file
After Width: | Height: | Size: 42 KiB |
262
yana/APIs/oauth flow.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# OAuth/Ticket 认证系统 API 文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述了 YuMi 应用中 OAuth 认证和 Ticket 会话管理的完整流程。系统采用两阶段认证机制:
|
||||
1. **OAuth 阶段**:用户登录获取 `access_token`
|
||||
2. **Ticket 阶段**:使用 `access_token` 获取业务会话 `ticket`
|
||||
|
||||
## 认证流程架构
|
||||
|
||||
### 核心组件
|
||||
- **AccountInfoStorage**: 负责账户信息和 ticket 的本地存储
|
||||
- **HttpRequestHelper**: 网络请求管理,自动添加认证头
|
||||
- **Api+Login**: 登录相关 API 接口
|
||||
- **Api+Main**: Ticket 获取相关 API 接口
|
||||
|
||||
### 认证数据模型
|
||||
|
||||
#### AccountModel
|
||||
```objc
|
||||
@interface AccountModel : PIBaseModel
|
||||
@property (nonatomic, assign) NSString *uid; // 用户唯一标识
|
||||
@property (nonatomic, copy) NSString *jti; // JWT ID
|
||||
@property (nonatomic, copy) NSString *token_type; // Token 类型
|
||||
@property (nonatomic, copy) NSString *refresh_token; // 刷新令牌
|
||||
@property (nonatomic, copy) NSString *netEaseToken; // 网易云信令牌
|
||||
@property (nonatomic, copy) NSString *access_token; // OAuth 访问令牌
|
||||
@property (nonatomic, assign) NSNumber *expires_in; // 过期时间
|
||||
@end
|
||||
```
|
||||
|
||||
## API 接口详情
|
||||
|
||||
### 1. OAuth 登录接口
|
||||
|
||||
#### 1.1 手机验证码登录
|
||||
```objc
|
||||
+ (void)loginWithCode:(HttpRequestHelperCompletion)completion
|
||||
phone:(NSString *)phone
|
||||
code:(NSString *)code
|
||||
client_secret:(NSString *)client_secret
|
||||
version:(NSString *)version
|
||||
client_id:(NSString *)client_id
|
||||
grant_type:(NSString *)grant_type
|
||||
phoneAreaCode:(NSString *)phoneAreaCode;
|
||||
```
|
||||
|
||||
**接口路径**: `POST /oauth/token`
|
||||
|
||||
**请求参数**:
|
||||
| 参数名 | 类型 | 必填 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| phone | String | 是 | 手机号(DES加密) |
|
||||
| code | String | 是 | 验证码 |
|
||||
| client_secret | String | 是 | 客户端密钥,固定值:"uyzjdhds" |
|
||||
| version | String | 是 | 版本号,固定值:"1" |
|
||||
| client_id | String | 是 | 客户端ID,固定值:"erban-client" |
|
||||
| grant_type | String | 是 | 授权类型,验证码登录为:"sms_code" |
|
||||
| phoneAreaCode | String | 是 | 手机区号 |
|
||||
|
||||
**返回数据**: AccountModel 对象
|
||||
|
||||
#### 1.2 手机密码登录
|
||||
```objc
|
||||
+ (void)loginWithPassword:(HttpRequestHelperCompletion)completion
|
||||
phone:(NSString *)phone
|
||||
password:(NSString *)password
|
||||
client_secret:(NSString *)client_secret
|
||||
version:(NSString *)version
|
||||
client_id:(NSString *)client_id
|
||||
grant_type:(NSString *)grant_type;
|
||||
```
|
||||
|
||||
**接口路径**: `POST /oauth/token`
|
||||
|
||||
**请求参数**:
|
||||
| 参数名 | 类型 | 必填 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| phone | String | 是 | 手机号(DES加密) |
|
||||
| password | String | 是 | 密码(DES加密) |
|
||||
| client_secret | String | 是 | 客户端密钥 |
|
||||
| version | String | 是 | 版本号 |
|
||||
| client_id | String | 是 | 客户端ID |
|
||||
| grant_type | String | 是 | 授权类型,密码登录为:"password" |
|
||||
|
||||
#### 1.3 第三方登录
|
||||
```objc
|
||||
+ (void)loginWithThirdPart:(HttpRequestHelperCompletion)completion
|
||||
openid:(NSString *)openid
|
||||
unionid:(NSString *)unionid
|
||||
access_token:(NSString *)access_token
|
||||
type:(NSString *)type;
|
||||
```
|
||||
|
||||
**接口路径**: `POST /acc/third/login`
|
||||
|
||||
**请求参数**:
|
||||
| 参数名 | 类型 | 必填 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| openid | String | 是 | 第三方平台用户唯一标识 |
|
||||
| unionid | String | 是 | 第三方平台联合ID |
|
||||
| access_token | String | 是 | 第三方平台访问令牌 |
|
||||
| type | String | 是 | 第三方平台类型(1:Apple, 2:Facebook, 3:Google等) |
|
||||
|
||||
### 2. Ticket 获取接口
|
||||
|
||||
#### 2.1 获取 Ticket
|
||||
```objc
|
||||
+ (void)requestTicket:(HttpRequestHelperCompletion)completion
|
||||
access_token:(NSString *)accessToken
|
||||
issue_type:(NSString *)issueType;
|
||||
```
|
||||
|
||||
**接口路径**: `POST /oauth/ticket`
|
||||
|
||||
**请求参数**:
|
||||
| 参数名 | 类型 | 必填 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| access_token | String | 是 | OAuth 登录获取的访问令牌 |
|
||||
| issue_type | String | 是 | 签发类型,固定值:"multi" |
|
||||
|
||||
**返回数据**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"tickets": [
|
||||
{
|
||||
"ticket": "eyJhbGciOiJIUzI1NiJ9..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. HTTP 请求头配置
|
||||
|
||||
所有业务 API 请求都会自动添加以下请求头:
|
||||
|
||||
```objc
|
||||
// 在 HttpRequestHelper 中自动配置
|
||||
- (void)setupHeader {
|
||||
AFHTTPSessionManager *client = [HttpRequestHelper requestManager];
|
||||
|
||||
// 用户ID头
|
||||
if ([[AccountInfoStorage instance] getUid].length > 0) {
|
||||
[client.requestSerializer setValue:[[AccountInfoStorage instance] getUid]
|
||||
forHTTPHeaderField:@"pub_uid"];
|
||||
}
|
||||
|
||||
// Ticket 认证头
|
||||
if ([[AccountInfoStorage instance] getTicket].length > 0) {
|
||||
[client.requestSerializer setValue:[[AccountInfoStorage instance] getTicket]
|
||||
forHTTPHeaderField:@"pub_ticket"];
|
||||
}
|
||||
|
||||
// 其他公共头
|
||||
[client.requestSerializer setValue:[NSBundle uploadLanguageText]
|
||||
forHTTPHeaderField:@"Accept-Language"];
|
||||
[client.requestSerializer setValue:PI_App_Version
|
||||
forHTTPHeaderField:@"App-Version"];
|
||||
}
|
||||
```
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 完整登录流程示例
|
||||
|
||||
```objc
|
||||
// 1. 用户登录获取 access_token
|
||||
[Api loginWithCode:^(BaseModel * _Nullable data, NSInteger code, NSString * _Nullable msg) {
|
||||
if (code == 200) {
|
||||
// 保存账户信息
|
||||
AccountModel *accountModel = [AccountModel modelWithDictionary:data.data];
|
||||
[[AccountInfoStorage instance] saveAccountInfo:accountModel];
|
||||
|
||||
// 2. 使用 access_token 获取 ticket
|
||||
[Api requestTicket:^(BaseModel * _Nullable data, NSInteger code, NSString * _Nullable msg) {
|
||||
if (code == 200) {
|
||||
NSArray *tickets = [data.data valueForKey:@"tickets"];
|
||||
NSString *ticket = [tickets[0] valueForKey:@"ticket"];
|
||||
|
||||
// 保存 ticket
|
||||
[[AccountInfoStorage instance] saveTicket:ticket];
|
||||
|
||||
// 3. 登录成功,可以进行业务操作
|
||||
[self navigateToMainPage];
|
||||
}
|
||||
} access_token:accountModel.access_token issue_type:@"multi"];
|
||||
}
|
||||
} phone:encryptedPhone
|
||||
code:verificationCode
|
||||
client_secret:@"uyzjdhds"
|
||||
version:@"1"
|
||||
client_id:@"erban-client"
|
||||
grant_type:@"sms_code"
|
||||
phoneAreaCode:areaCode];
|
||||
```
|
||||
|
||||
### 自动登录流程
|
||||
|
||||
```objc
|
||||
- (void)autoLogin {
|
||||
// 检查本地是否有账户信息
|
||||
AccountModel *accountModel = [[AccountInfoStorage instance] getCurrentAccountInfo];
|
||||
if (accountModel == nil || accountModel.access_token == nil) {
|
||||
[self tokenInvalid]; // 跳转到登录页
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有有效的 ticket
|
||||
if ([[AccountInfoStorage instance] getTicket].length > 0) {
|
||||
[[self getView] autoLoginSuccess];
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 access_token 重新获取 ticket
|
||||
[Api requestTicket:^(BaseModel * _Nonnull data) {
|
||||
NSArray *tickets = [data.data valueForKey:@"tickets"];
|
||||
NSString *ticket = [tickets[0] valueForKey:@"ticket"];
|
||||
[[AccountInfoStorage instance] saveTicket:ticket];
|
||||
[[self getView] autoLoginSuccess];
|
||||
} fail:^(NSInteger code, NSString * _Nullable msg) {
|
||||
[self logout]; // ticket 获取失败,重新登录
|
||||
} access_token:accountModel.access_token issue_type:@"multi"];
|
||||
}
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
### 401 未授权错误
|
||||
当接收到 401 状态码时,系统会自动处理:
|
||||
|
||||
```objc
|
||||
// 在 HttpRequestHelper 中
|
||||
if (response && response.statusCode == 401) {
|
||||
failure(response.statusCode, YMLocalizedString(@"HttpRequestHelper7"));
|
||||
// 通常需要重新登录
|
||||
}
|
||||
```
|
||||
|
||||
### Ticket 过期处理
|
||||
- Ticket 过期时服务器返回 401 错误
|
||||
- 客户端应该使用保存的 `access_token` 重新获取 ticket
|
||||
- 如果 `access_token` 也过期,则需要用户重新登录
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **数据加密**: 敏感信息(手机号、密码)使用 DES 加密传输
|
||||
2. **本地存储**:
|
||||
- `access_token` 存储在文件系统中
|
||||
- `ticket` 存储在内存中,应用重启需重新获取
|
||||
3. **请求头**: 所有业务请求自动携带 `pub_uid` 和 `pub_ticket` 头
|
||||
4. **错误处理**: 建立完善的 401 错误重试机制
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `YuMi/Structure/MVP/Model/AccountInfoStorage.h/m` - 账户信息存储管理
|
||||
- `YuMi/Modules/YMLogin/Api/Api+Login.h/m` - 登录相关接口
|
||||
- `YuMi/Modules/YMTabbar/Api/Api+Main.h/m` - Ticket 获取接口
|
||||
- `YuMi/Network/HttpRequestHelper.h/m` - 网络请求管理
|
||||
- `YuMi/Structure/MVP/Model/AccountModel.h/m` - 账户数据模型
|
1
yana/APIs/oauth flow.svg
Normal file
After Width: | Height: | Size: 31 KiB |
@@ -1,31 +1,11 @@
|
||||
import UIKit
|
||||
import NIMSDK
|
||||
//import NIMSDK
|
||||
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> 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
|
||||
private func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
|
||||
|
||||
// 预加载用户信息缓存
|
||||
await UserInfoManager.preloadCache()
|
||||
|
||||
// NIMConfigurationManager.setupNimSDK()
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "logo.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
BIN
yana/Assets.xcassets/AppIcon.appiconset/logo.png
Normal file
After Width: | Height: | Size: 802 KiB |
6
yana/Assets.xcassets/Home/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
21
yana/Assets.xcassets/Home/Volume.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Volume@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Home/Volume.imageset/Volume@3x.png
vendored
Normal file
After Width: | Height: | Size: 13 KiB |
21
yana/Assets.xcassets/Home/add icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "发布@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Home/add icon.imageset/发布@3x.png
vendored
Normal file
After Width: | Height: | Size: 16 KiB |
21
yana/Assets.xcassets/Home/add photo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "组 8030@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Home/add photo.imageset/组 8030@3x.png
vendored
Normal file
After Width: | Height: | Size: 6.5 KiB |
BIN
yana/Assets.xcassets/Home/feed selected.imageset/3@3x.png
vendored
Normal file
After Width: | Height: | Size: 5.7 KiB |
21
yana/Assets.xcassets/Home/feed selected.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Home/feed unselected.imageset/3@3x (1).png
vendored
Normal file
After Width: | Height: | Size: 3.8 KiB |
21
yana/Assets.xcassets/Home/feed unselected.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Home/me selected.imageset/5@3x (1).png
vendored
Normal file
After Width: | Height: | Size: 5.5 KiB |
21
yana/Assets.xcassets/Home/me selected.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Home/me unselected.imageset/5@3x.png
vendored
Normal file
After Width: | Height: | Size: 3.9 KiB |
21
yana/Assets.xcassets/Home/me unselected.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
6
yana/Assets.xcassets/Login/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
21
yana/Assets.xcassets/Login/bg.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "bg@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/bg.imageset/bg@3x.png
vendored
Normal file
After Width: | Height: | Size: 1.2 MiB |
21
yana/Assets.xcassets/Login/email icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "切图 65@3x (1).png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/email icon.imageset/切图 65@3x (1).png
vendored
Normal file
After Width: | Height: | Size: 2.2 KiB |
21
yana/Assets.xcassets/Login/id icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "切图 65@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/id icon.imageset/切图 65@3x.png
vendored
Normal file
After Width: | Height: | Size: 3.1 KiB |
21
yana/Assets.xcassets/Login/logo.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "logo@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/logo.imageset/logo@3x.png
vendored
Normal file
After Width: | Height: | Size: 113 KiB |
21
yana/Assets.xcassets/Login/selected icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "勾选@3x (1).png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/selected icon.imageset/勾选@3x (1).png
vendored
Normal file
After Width: | Height: | Size: 2.2 KiB |
21
yana/Assets.xcassets/Login/top.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "top@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/top.imageset/top@3x.png
vendored
Normal file
After Width: | Height: | Size: 379 KiB |
21
yana/Assets.xcassets/Login/unselected icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "勾选@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
yana/Assets.xcassets/Login/unselected icon.imageset/勾选@3x.png
vendored
Normal file
After Width: | Height: | Size: 1.6 KiB |
@@ -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
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
switch current {
|
||||
case .development:
|
||||
return true
|
||||
case .production:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加服务器信任配置
|
||||
// 服务器信任配置
|
||||
static var serverTrustPolicies: [String: ServerTrustEvaluating] {
|
||||
#if DEBUG
|
||||
return ["beta.api.molistar.xyz": DisabledTrustEvaluator()]
|
||||
#else
|
||||
return ["api.hfighting.com": PublicKeysTrustEvaluator()]
|
||||
#endif
|
||||
switch current {
|
||||
case .development:
|
||||
return ["beta.api.molistar.xyz": DisabledTrustEvaluator()]
|
||||
case .production:
|
||||
return ["api.epartylive.com": PublicKeysTrustEvaluator()]
|
||||
}
|
||||
}
|
||||
|
||||
static var networkDebugEnabled: Bool {
|
||||
#if DEBUG
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
switch current {
|
||||
case .development:
|
||||
return true
|
||||
case .production:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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",
|
||||
|
@@ -28,6 +28,144 @@ enum UILogLevel: String, CaseIterable {
|
||||
case detailed = "详细日志"
|
||||
}
|
||||
|
||||
struct LoginTabView: View {
|
||||
let store: StoreOf<LoginFeature>
|
||||
let initStore: StoreOf<InitFeature>
|
||||
@Binding var selectedLogLevel: APILogger.LogLevel
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
// 日志级别选择器
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("日志级别:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Picker("日志级别", selection: $selectedLogLevel) {
|
||||
Text("无日志").tag(APILogger.LogLevel.none)
|
||||
Text("基础日志").tag(APILogger.LogLevel.basic)
|
||||
Text("详细日志").tag(APILogger.LogLevel.detailed)
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
Spacer()
|
||||
VStack(spacing: 20) {
|
||||
Text("eparty")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
VStack(spacing: 15) {
|
||||
TextField("账号", text: Binding(
|
||||
get: { store.account },
|
||||
set: { store.send(.updateAccount($0)) }
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.autocorrectionDisabled(true)
|
||||
SecureField("密码", text: Binding(
|
||||
get: { store.password },
|
||||
set: { store.send(.updatePassword($0)) }
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
}
|
||||
.padding(.horizontal)
|
||||
if let error = store.error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
Button(action: {
|
||||
store.send(.login)
|
||||
}) {
|
||||
HStack {
|
||||
if store.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(store.isLoading ? "登录中..." : "登录")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(store.isLoading ? Color.gray : Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(store.isLoading || store.account.isEmpty || store.password.isEmpty)
|
||||
Button(action: {
|
||||
initStore.send(.initialize)
|
||||
}) {
|
||||
HStack {
|
||||
if initStore.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(initStore.isLoading ? "测试中..." : "测试初始化")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(initStore.isLoading ? Color.gray : Color.green)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(initStore.isLoading)
|
||||
if let response = initStore.response {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("API 测试结果:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("状态: \(response.status)")
|
||||
if let message = response.message {
|
||||
Text("消息: \(message)")
|
||||
}
|
||||
if let data = response.data {
|
||||
Text("版本: \(data.version ?? "未知")")
|
||||
Text("时间戳: \(data.timestamp ?? 0)")
|
||||
if let config = data.config {
|
||||
Text("配置:")
|
||||
ForEach(Array(config.keys), id: \.self) { key in
|
||||
Text(" \(key): \(config[key] ?? "")")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
if let error = initStore.error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
let store: StoreOf<LoginFeature>
|
||||
let initStore: StoreOf<InitFeature>
|
||||
@@ -36,170 +174,22 @@ struct ContentView: View {
|
||||
@State private var selectedTab = 0
|
||||
|
||||
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)
|
||||
Text("详细日志").tag(APILogger.LogLevel.detailed)
|
||||
WithPerceptionTracking {
|
||||
TabView(selection: $selectedTab) {
|
||||
LoginTabView(store: store, initStore: initStore, selectedLogLevel: $selectedLogLevel)
|
||||
.tabItem {
|
||||
Label("登录", systemImage: "person.circle")
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(10)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 20) {
|
||||
Text("yana")
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
VStack(spacing: 15) {
|
||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||
TextField("账号", text: viewStore.binding(
|
||||
get: \.account,
|
||||
send: { LoginFeature.Action.updateAccount($0) }
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.autocorrectionDisabled(true)
|
||||
|
||||
SecureField("密码", text: viewStore.binding(
|
||||
get: \.password,
|
||||
send: { LoginFeature.Action.updatePassword($0) }
|
||||
))
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
}
|
||||
.tag(0)
|
||||
ConfigView(store: configStore)
|
||||
.tabItem {
|
||||
Label("API 测试", systemImage: "network")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||
if let error = viewStore.error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button(action: {
|
||||
viewStore.send(.login)
|
||||
}) {
|
||||
HStack {
|
||||
if viewStore.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(viewStore.isLoading ? "登录中..." : "登录")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(viewStore.isLoading ? Color.gray : Color.blue)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(viewStore.isLoading || viewStore.account.isEmpty || viewStore.password.isEmpty)
|
||||
|
||||
WithViewStore(initStore, observe: { $0 }) { initViewStore in
|
||||
Button(action: {
|
||||
initViewStore.send(.initialize)
|
||||
}) {
|
||||
HStack {
|
||||
if initViewStore.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(initViewStore.isLoading ? "测试中..." : "测试初始化")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(initViewStore.isLoading ? Color.gray : Color.green)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(initViewStore.isLoading)
|
||||
|
||||
// API 测试结果显示区域
|
||||
if let response = initViewStore.response {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("API 测试结果:")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("状态: \(response.status)")
|
||||
if let message = response.message {
|
||||
Text("消息: \(message)")
|
||||
}
|
||||
if let data = response.data {
|
||||
Text("版本: \(data.version ?? "未知")")
|
||||
Text("时间戳: \(data.timestamp ?? 0)")
|
||||
if let config = data.config {
|
||||
Text("配置:")
|
||||
ForEach(Array(config.keys), id: \.self) { key in
|
||||
Text(" \(key): \(config[key] ?? "")")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.05))
|
||||
.cornerRadius(10)
|
||||
}
|
||||
|
||||
if let error = initViewStore.error {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.tag(1)
|
||||
}
|
||||
.padding()
|
||||
.tabItem {
|
||||
Label("登录", systemImage: "person.circle")
|
||||
.onChange(of: selectedLogLevel) { newValue in
|
||||
APILogger.logLevel = newValue
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
// 新的 API 配置测试界面
|
||||
ConfigView(store: configStore)
|
||||
.tabItem {
|
||||
Label("API 测试", systemImage: "network")
|
||||
}
|
||||
.tag(1)
|
||||
}
|
||||
.onChange(of: selectedLogLevel) { newValue in
|
||||
APILogger.logLevel = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
243
yana/Features/AppSettingFeature.swift
Normal 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
|
||||
|
||||
// 新增:带userInfo、avatarURL、nickname的init
|
||||
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()
|
||||
// 向上层Feature传递登出事件(需在MainFeature处理)
|
||||
// 这里直接返回.none,由MainFeature监听AppSettingFeature.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 }
|
||||
// 头像上传后,先临时更新本地avatarURL,提升UI响应
|
||||
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,触发拉取完整 userinfo,延迟1秒
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
@@ -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)
|
||||
|
185
yana/Features/CreateFeedFeature.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
@Reducer
|
||||
struct CreateFeedFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var content: String = ""
|
||||
var processedImages: [UIImage] = []
|
||||
var errorMessage: String? = nil
|
||||
var characterCount: Int = 0
|
||||
var selectedImages: [PhotosPickerItem] = []
|
||||
var canAddMoreImages: Bool {
|
||||
processedImages.count < 9
|
||||
}
|
||||
var canPublish: Bool {
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isLoading
|
||||
}
|
||||
var isLoading: Bool = false
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case contentChanged(String)
|
||||
case publishButtonTapped
|
||||
case publishResponse(Result<PublishDynamicResponse, Error>)
|
||||
case clearError
|
||||
case dismissView
|
||||
case photosPickerItemsChanged([PhotosPickerItem])
|
||||
case processPhotosPickerItems([PhotosPickerItem])
|
||||
case removeImage(Int)
|
||||
case updateProcessedImages([UIImage])
|
||||
}
|
||||
|
||||
@Dependency(\.apiService) var apiService
|
||||
@Dependency(\.dismiss) var dismiss
|
||||
@Dependency(\.isPresented) var isPresented
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .contentChanged(let newContent):
|
||||
state.content = newContent
|
||||
state.characterCount = newContent.count
|
||||
return .none
|
||||
case .photosPickerItemsChanged(let items):
|
||||
state.selectedImages = items
|
||||
return .run { send in
|
||||
await send(.processPhotosPickerItems(items))
|
||||
}
|
||||
case .processPhotosPickerItems(let items):
|
||||
let currentImages = state.processedImages
|
||||
return .run { send in
|
||||
var newImages = currentImages
|
||||
for item in items {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||
let image = UIImage(data: data) else { continue }
|
||||
if newImages.count < 9 {
|
||||
newImages.append(image)
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
send(.updateProcessedImages(newImages))
|
||||
}
|
||||
}
|
||||
case .updateProcessedImages(let images):
|
||||
state.processedImages = images
|
||||
return .none
|
||||
case .removeImage(let index):
|
||||
guard index < state.processedImages.count else { return .none }
|
||||
state.processedImages.remove(at: index)
|
||||
if index < state.selectedImages.count {
|
||||
state.selectedImages.remove(at: index)
|
||||
}
|
||||
return .none
|
||||
case .publishButtonTapped:
|
||||
guard state.canPublish else {
|
||||
state.errorMessage = "请输入内容"
|
||||
return .none
|
||||
}
|
||||
state.isLoading = true
|
||||
state.errorMessage = nil
|
||||
let request = PublishDynamicRequest(
|
||||
content: state.content.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
images: state.processedImages
|
||||
)
|
||||
return .run { send in
|
||||
do {
|
||||
let response = try await apiService.request(request)
|
||||
await send(.publishResponse(.success(response)))
|
||||
} catch {
|
||||
await send(.publishResponse(.failure(error)))
|
||||
}
|
||||
}
|
||||
case .publishResponse(.success(let response)):
|
||||
state.isLoading = false
|
||||
if response.code == 200 {
|
||||
return .send(.dismissView)
|
||||
} else {
|
||||
state.errorMessage = response.message.isEmpty ? "发布失败" : response.message
|
||||
return .none
|
||||
}
|
||||
case .publishResponse(.failure(let error)):
|
||||
state.isLoading = false
|
||||
state.errorMessage = error.localizedDescription
|
||||
return .none
|
||||
case .clearError:
|
||||
state.errorMessage = nil
|
||||
return .none
|
||||
case .dismissView:
|
||||
// 检查是否在presentation context中
|
||||
guard isPresented else {
|
||||
// 如果不在presentation context中,不执行dismiss
|
||||
return .none
|
||||
}
|
||||
return .run { _ in
|
||||
await dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CreateFeedFeature.Action: Equatable {
|
||||
static func == (lhs: CreateFeedFeature.Action, rhs: CreateFeedFeature.Action) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case let (.contentChanged(a), .contentChanged(b)):
|
||||
return a == b
|
||||
case (.publishButtonTapped, .publishButtonTapped):
|
||||
return true
|
||||
case (.clearError, .clearError):
|
||||
return true
|
||||
case (.dismissView, .dismissView):
|
||||
return true
|
||||
case let (.removeImage(a), .removeImage(b)):
|
||||
return a == b
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 发布动态相关模型
|
||||
|
||||
struct PublishDynamicRequest: APIRequestProtocol {
|
||||
typealias Response = PublishDynamicResponse
|
||||
let endpoint: String = 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
|
||||
}
|
208
yana/Features/EMailLoginFeature.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
214
yana/Features/EditFeedFeature.swift
Normal 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] = []
|
||||
// 手动实现Equatable,selectedImages只比较数量(PhotosPickerItem不支持Equatable)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
111
yana/Features/FeedFeature.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
144
yana/Features/FeedListFeature.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
92
yana/Features/HomeFeature.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
191
yana/Features/IDLoginFeature.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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,69 +9,246 @@ 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> {
|
||||
// Reduce { state, action in
|
||||
// switch action {
|
||||
// case let .updateAccount(account):
|
||||
// state.account = account
|
||||
// return .none
|
||||
//
|
||||
// case let .updatePassword(password):
|
||||
// state.password = password
|
||||
// return .none
|
||||
//
|
||||
// case .login:
|
||||
// state.isLoading = true
|
||||
// state.error = nil
|
||||
//
|
||||
// let loginBody = [
|
||||
// "account": state.account,
|
||||
// "password": state.password
|
||||
// ]
|
||||
//
|
||||
// return .run { send in
|
||||
// do {
|
||||
// let response: LoginResponse = try await APIClientManager.shared.post(
|
||||
// path: APIConstants.Endpoints.login,
|
||||
// body: loginBody,
|
||||
// headers: APIConstants.defaultHeaders
|
||||
// )
|
||||
// await send(.loginResponse(.success(response)))
|
||||
// } catch {
|
||||
// await send(.loginResponse(.failure(error)))
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// case let .loginResponse(.success(response)):
|
||||
// state.isLoading = false
|
||||
// if response.status == "success" {
|
||||
// // TODO: 处理登录成功,保存 token 等
|
||||
// } else {
|
||||
// state.error = response.message ?? "登录失败"
|
||||
// }
|
||||
// return .none
|
||||
//
|
||||
// case let .loginResponse(.failure(error)):
|
||||
// state.isLoading = false
|
||||
// state.error = error.localizedDescription
|
||||
// return .none
|
||||
// }
|
||||
// }
|
||||
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
|
||||
|
||||
case let .updatePassword(password):
|
||||
state.password = password
|
||||
return .none
|
||||
|
||||
case .login:
|
||||
state.isLoading = true
|
||||
state.error = nil
|
||||
state.ticketError = nil
|
||||
state.loginStep = .authenticating
|
||||
|
||||
// 实现登录逻辑(使用account和password)
|
||||
return .run { [account = state.account, password = state.password] send in
|
||||
do {
|
||||
// 使用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 {
|
||||
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.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 = "登录数据格式错误"
|
||||
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:
|
||||
// IDLogin动作由子feature处理
|
||||
return .none
|
||||
|
||||
case .emailLogin:
|
||||
// EmailLogin动作由子feature处理
|
||||
return .none
|
||||
case .home(_):
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除:未使用的通知名称定义
|
||||
// extension Notification.Name {
|
||||
// static let ticketSuccess = Notification.Name("ticketSuccess")
|
||||
// }
|
||||
|
124
yana/Features/MainFeature.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
91
yana/Features/MeDynamicFeature.swift
Normal 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))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
113
yana/Features/MeFeature.swift
Normal 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))))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
281
yana/Features/RecoverPasswordFeature.swift
Normal file
@@ -0,0 +1,281 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
|
||||
@Reducer
|
||||
struct RecoverPasswordFeature {
|
||||
@ObservableState
|
||||
struct State: Equatable {
|
||||
var email: String = ""
|
||||
var verificationCode: String = ""
|
||||
var newPassword: String = ""
|
||||
var isCodeLoading: Bool = false
|
||||
var isResetLoading: Bool = false
|
||||
var isResetSuccess: Bool = false
|
||||
var errorMessage: String? = nil
|
||||
var isCodeSent: Bool = false
|
||||
|
||||
#if DEBUG
|
||||
init() {
|
||||
self.email = "exzero@126.com"
|
||||
self.verificationCode = ""
|
||||
self.newPassword = ""
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
enum Action {
|
||||
case emailChanged(String)
|
||||
case verificationCodeChanged(String)
|
||||
case newPasswordChanged(String)
|
||||
case getVerificationCodeTapped
|
||||
case getCodeResponse(Result<EmailGetCodeResponse, Error>)
|
||||
case resetPasswordTapped
|
||||
case resetPasswordResponse(Result<ResetPasswordResponse, Error>)
|
||||
case resetSuccess
|
||||
case resetState
|
||||
}
|
||||
|
||||
@Dependency(\.apiService) var apiService
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .emailChanged(let email):
|
||||
state.email = email
|
||||
state.errorMessage = nil
|
||||
return .none
|
||||
|
||||
case .verificationCodeChanged(let code):
|
||||
state.verificationCode = code
|
||||
state.errorMessage = nil
|
||||
return .none
|
||||
|
||||
case .newPasswordChanged(let password):
|
||||
state.newPassword = password
|
||||
state.errorMessage = nil
|
||||
return .none
|
||||
|
||||
case .getVerificationCodeTapped:
|
||||
guard !state.email.isEmpty else {
|
||||
state.errorMessage = 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: 配置好的API请求,如果加密失败返回nil
|
||||
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: 配置好的API请求,如果加密失败返回nil
|
||||
static func createResetPasswordRequest(email: String, code: String, newPassword: String) -> ResetPasswordRequest? {
|
||||
let encryptionKey = "1ea53d260ecf11e7b56e00163e046a26"
|
||||
|
||||
guard let encryptedEmail = DESEncrypt.encryptUseDES(email, key: encryptionKey),
|
||||
let encryptedPassword = DESEncrypt.encryptUseDES(newPassword, key: encryptionKey) else {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
110
yana/Features/SplashFeature.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
BIN
yana/Fonts/Bayon-Regular.ttf
Normal file
64
yana/Fonts/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 字体文件使用指南
|
||||
|
||||
## 字体文件位置
|
||||
请将 **Bayon-Regular.ttf** 字体文件放置在此文件夹中。
|
||||
|
||||
## 添加步骤
|
||||
|
||||
### 1. 获取字体文件
|
||||
- 从 Google Fonts 下载 Bayon 字体:https://fonts.google.com/specimen/Bayon
|
||||
- 或从设计师提供的字体文件中获取 `Bayon-Regular.ttf`
|
||||
|
||||
### 2. 添加到项目
|
||||
1. 将 `Bayon-Regular.ttf` 文件拖放到此 `Fonts` 文件夹中
|
||||
2. 在 Xcode 中,确保文件被添加到项目的 Target 中
|
||||
3. 检查 `Info.plist` 中已经配置了 `UIAppFonts` 数组
|
||||
|
||||
### 3. 验证字体是否正确加载
|
||||
在 `AppDelegate.swift` 中添加调试代码:
|
||||
```swift
|
||||
#if DEBUG
|
||||
FontManager.printAllAvailableFonts()
|
||||
// 检查 Bayon 字体是否可用
|
||||
print("Bayon 字体可用:\(FontManager.isFontAvailable(.bayonRegular))")
|
||||
#endif
|
||||
```
|
||||
|
||||
## 当前配置状态
|
||||
|
||||
### ✅ 已完成:
|
||||
- [x] Info.plist 配置完成
|
||||
- [x] FontManager 工具类创建完成
|
||||
- [x] LoginView 中 E-PARTI 文本已应用 Bayon 字体
|
||||
- [x] 字体适配与屏幕尺寸兼容
|
||||
|
||||
### ⏳ 待完成:
|
||||
- [ ] 添加 Bayon-Regular.ttf 字体文件到项目中
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 方法1: 使用 FontManager(推荐)
|
||||
```swift
|
||||
Text("E-PARTI")
|
||||
.font(FontManager.adaptedFont(.bayonRegular, designSize: 56, for: geometry.size.width))
|
||||
```
|
||||
|
||||
### 方法2: 使用 View Extension
|
||||
```swift
|
||||
Text("E-PARTI")
|
||||
.adaptedCustomFont(.bayonRegular, designSize: 56)
|
||||
```
|
||||
|
||||
### 方法3: 直接指定大小
|
||||
```swift
|
||||
Text("E-PARTI")
|
||||
.customFont(.bayonRegular, size: 56)
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
如果字体未生效,请检查:
|
||||
1. 字体文件是否正确添加到项目 Target 中
|
||||
2. Info.plist 中的字体文件名是否正确
|
||||
3. 字体文件名与代码中使用的名称是否一致
|
||||
4. 运行调试代码确认字体是否被系统识别
|
@@ -9,5 +9,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>
|
||||
|
63
yana/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="23727" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="23721"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="s0d-6b-0kx">
|
||||
<objects>
|
||||
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="bg" translatesAutoresizingMaskIntoConstraints="NO" id="Mom-Je-A43">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
</imageView>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="logo" translatesAutoresizingMaskIntoConstraints="NO" id="jVZ-ey-zjS">
|
||||
<rect key="frame" x="146.66666666666666" y="200" width="100" height="100"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="100" id="61A-Xv-MlD"/>
|
||||
<constraint firstAttribute="height" constant="100" id="NWJ-mJ-K2O"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="E-Parti" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="GrU-nK-tAY">
|
||||
<rect key="frame" x="138" y="332" width="117" height="48"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="40"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="jVZ-ey-zjS" firstAttribute="centerX" secondItem="Mom-Je-A43" secondAttribute="centerX" id="Atv-LB-aNW"/>
|
||||
<constraint firstItem="Mom-Je-A43" firstAttribute="leading" secondItem="vDu-zF-Fre" secondAttribute="leading" id="fQ2-yj-nze"/>
|
||||
<constraint firstItem="Mom-Je-A43" firstAttribute="top" secondItem="5EZ-qb-Rvc" secondAttribute="top" id="gVA-6N-OG4"/>
|
||||
<constraint firstItem="GrU-nK-tAY" firstAttribute="centerY" secondItem="Mom-Je-A43" secondAttribute="centerY" constant="-70" id="j81-qa-9vS"/>
|
||||
<constraint firstAttribute="bottom" secondItem="Mom-Je-A43" secondAttribute="bottom" id="lBn-me-aJu"/>
|
||||
<constraint firstItem="Mom-Je-A43" firstAttribute="trailing" secondItem="vDu-zF-Fre" secondAttribute="trailing" id="lka-KN-fEy"/>
|
||||
<constraint firstItem="jVZ-ey-zjS" firstAttribute="top" secondItem="Mom-Je-A43" secondAttribute="top" constant="200" id="nCq-oK-mTB"/>
|
||||
<constraint firstItem="GrU-nK-tAY" firstAttribute="centerX" secondItem="Mom-Je-A43" secondAttribute="centerX" id="sZE-bZ-0Xj"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-208.3969465648855" y="-13.380281690140846"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="bg" width="375" height="812"/>
|
||||
<image name="logo" width="100" height="100"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
@@ -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 环境输出(默认 false,Debug 全部输出)
|
||||
/// - onlyRelease: 是否仅在 Release 环境输出(已修复逻辑)
|
||||
public func log(_ level: LogLevel, _ message: @autoclosure () -> String, onlyRelease: Bool = false) {
|
||||
#if DEBUG
|
||||
if onlyRelease { return }
|
||||
print("[\(level)] \(message())")
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
@@ -1,35 +0,0 @@
|
||||
import NIMSDK
|
||||
import NECoreKit
|
||||
import NECoreIM2Kit
|
||||
import NEChatKit
|
||||
import NEChatUIKit
|
||||
|
||||
struct NIMConfigurationManager {
|
||||
|
||||
static func setupNimSDK() {
|
||||
let option = configureNIMSDKOption()
|
||||
setupSDK(with: option)
|
||||
setupChatSDK(with: option)
|
||||
}
|
||||
|
||||
static func setupSDK(with option: NIMSDKOption) {
|
||||
NIMSDK.shared().register(with: option)
|
||||
NIMSDKConfig.shared().shouldConsiderRevokedMessageUnreadCount = true
|
||||
NIMSDKConfig.shared().shouldSyncStickTopSessionInfos = true
|
||||
}
|
||||
|
||||
static func setupChatSDK(with option: NIMSDKOption) {
|
||||
let v2Option = V2NIMSDKOption()
|
||||
v2Option.enableV2CloudConversation = false
|
||||
// TODO: 修复 IMKitClient API 调用
|
||||
// IMKitClient.shared.setupIM2(option, v2Option)
|
||||
print("⚠️ NIM SDK 配置暂时被注释,需要修复 IMKitClient API")
|
||||
}
|
||||
|
||||
static func configureNIMSDKOption() -> NIMSDKOption {
|
||||
let option = NIMSDKOption()
|
||||
option.appKey = "79bc37000f4018a2a24ea9dc6ca08d32"
|
||||
option.apnsCername = "pikoDevelopPush"
|
||||
return option
|
||||
}
|
||||
}
|
@@ -1,127 +0,0 @@
|
||||
import Foundation
|
||||
import NIMSDK
|
||||
|
||||
// MARK: - 网络状态通知
|
||||
extension Notification.Name {
|
||||
static let NIMNetworkStateChanged = Notification.Name("NIMNetworkStateChangedNotification")
|
||||
static let NIMTokenExpired = Notification.Name("NIMTokenExpiredNotification")
|
||||
}
|
||||
|
||||
@objc
|
||||
@objcMembers
|
||||
final class NIMSessionManager: NSObject {
|
||||
|
||||
static let shared = NIMSessionManager()
|
||||
|
||||
// MARK: - 登录管理
|
||||
func autoLogin(account: String, token: String, completion: @escaping (Error?) -> Void) {
|
||||
NIMSDK.shared().v2LoginService.add(self)
|
||||
let data = NIMAutoLoginData()
|
||||
data.account = account
|
||||
data.token = token
|
||||
data.forcedMode = false
|
||||
NIMSDK.shared().loginManager.autoLogin(data)
|
||||
}
|
||||
|
||||
func login(account: String, token: String, completion: @escaping (Error?) -> Void) {
|
||||
NIMSDK.shared().loginManager.login(account, token: token) { error in
|
||||
if error == nil {
|
||||
self.registerObservers()
|
||||
}
|
||||
completion(error)
|
||||
}
|
||||
}
|
||||
|
||||
func logout() {
|
||||
NIMSDK.shared().loginManager.logout { _ in
|
||||
self.removeObservers()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 消息监听
|
||||
private func registerObservers() {
|
||||
// 在 autoLogin 方法中
|
||||
// NIMSDK.shared().v2LoginService.add(self as! V2NIMLoginServiceDelegate)
|
||||
|
||||
// 在 registerObservers 方法中
|
||||
// NIMSDK.shared().v2LoginService.add(self as! V2NIMLoginServiceDelegate)
|
||||
|
||||
// 在 removeObservers 方法中
|
||||
// NIMSDK.shared().v2LoginService.remove(self as! V2NIMLoginServiceDelegate)
|
||||
NIMSDK.shared().chatManager.add(self)
|
||||
NIMSDK.shared().loginManager.add(self)
|
||||
}
|
||||
|
||||
private func removeObservers() {
|
||||
NIMSDK.shared().v2LoginService.remove(self)
|
||||
NIMSDK.shared().chatManager.remove(self)
|
||||
NIMSDK.shared().loginManager.remove(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NIMChatManagerDelegate
|
||||
extension NIMSessionManager: NIMChatManagerDelegate {
|
||||
func onRecvMessages(_ messages: [NIMMessage]) {
|
||||
NotificationCenter.default.post(
|
||||
name: .NIMDidReceiveMessage,
|
||||
object: messages
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NIMLoginManagerDelegate
|
||||
extension NIMSessionManager: NIMLoginManagerDelegate {
|
||||
func onLogin(_ step: NIMLoginStep) {
|
||||
NotificationCenter.default.post(
|
||||
name: .NIMLoginStateChanged,
|
||||
object: step
|
||||
)
|
||||
}
|
||||
|
||||
func onAutoLoginFailed(_ error: Error) {
|
||||
if (error as NSError).code == 302 {
|
||||
NotificationCenter.default.post(name: .NIMTokenExpired, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 通知定义
|
||||
extension Notification.Name {
|
||||
static let NIMDidReceiveMessage = Notification.Name("NIMDidReceiveMessageNotification")
|
||||
static let NIMLoginStateChanged = Notification.Name("NIMLoginStateChangedNotification")
|
||||
}
|
||||
|
||||
// MARK: - NIMV2LoginServiceDelegate
|
||||
extension NIMSessionManager: V2NIMLoginListener {
|
||||
func onLoginStatus(_ status: V2NIMLoginStatus) {
|
||||
|
||||
}
|
||||
|
||||
func onLoginFailed(_ error: V2NIMError) {
|
||||
|
||||
}
|
||||
|
||||
func onKickedOffline(_ detail: V2NIMKickedOfflineDetail) {
|
||||
|
||||
}
|
||||
|
||||
func onLoginClientChanged(
|
||||
_ change: V2NIMLoginClientChange,
|
||||
clients: [V2NIMLoginClient]?
|
||||
) {
|
||||
|
||||
}
|
||||
// @objc func onLoginProcess(step: NIMV2LoginStep) {
|
||||
// NotificationCenter.default.post(
|
||||
// name: .NIMV2LoginStateChanged,
|
||||
// object: step
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// @objc func onKickOut(result: NIMKickOutResult) {
|
||||
// NotificationCenter.default.post(
|
||||
// name: .NIMKickOutNotification,
|
||||
// object: result
|
||||
// )
|
||||
// }
|
||||
}
|
132
yana/Resources/en.lproj/Localizable.strings
Normal 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";
|
128
yana/Resources/zh-Hans.lproj/Localizable.strings
Normal 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" = "退出账户";
|
227
yana/Utils/APILoading/APILoadingEffectView.swift
Normal file
@@ -0,0 +1,227 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - API Loading Effect View
|
||||
|
||||
/// 全局 API 加载效果视图
|
||||
///
|
||||
/// 该视图显示在屏幕最顶层,包含:
|
||||
/// - Loading 动画(88x88,60% 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
|
137
yana/Utils/APILoading/APILoadingManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
73
yana/Utils/APILoading/APILoadingModels.swift
Normal 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
@@ -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
|
||||
}
|
||||
}
|
50
yana/Utils/Extensions/Color+Hex.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
83
yana/Utils/Extensions/String+HashTest.swift
Normal 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())
|
||||
}
|
||||
|
||||
*/
|
39
yana/Utils/Extensions/String+MD5.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
import CommonCrypto
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - String Hash Extensions
|
||||
extension String {
|
||||
/// 计算字符串的SHA256哈希值(推荐使用)
|
||||
/// - Returns: SHA256哈希值的小写十六进制字符串
|
||||
@available(iOS 13.0, *)
|
||||
func sha256() -> String {
|
||||
let data = Data(self.utf8)
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.compactMap { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// 计算字符串的MD5哈希值(已弃用,仅用于兼容性)
|
||||
///
|
||||
/// ⚠️ 警告:MD5在iOS 13.0后已被弃用,因为它在加密学上是不安全的
|
||||
/// 建议使用 sha256() 方法替代
|
||||
///
|
||||
/// - Returns: MD5哈希值的小写十六进制字符串
|
||||
func md5() -> String {
|
||||
if #available(iOS 13.0, *) {
|
||||
// iOS 13+ 使用 CryptoKit 的 Insecure.MD5
|
||||
let data = Data(self.utf8)
|
||||
let digest = Insecure.MD5.hash(data: data)
|
||||
return digest.compactMap { String(format: "%02x", $0) }.joined()
|
||||
} else {
|
||||
// iOS 13 以下使用 CommonCrypto
|
||||
let data = Data(self.utf8)
|
||||
let hash = data.withUnsafeBytes { bytes -> [UInt8] in
|
||||
var hash = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
|
||||
CC_MD5(bytes.baseAddress, CC_LONG(data.count), &hash)
|
||||
return hash
|
||||
}
|
||||
return hash.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
}
|
21
yana/Utils/Extensions/View+Placeholder.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - View Extension for Placeholder
|
||||
extension View {
|
||||
/// 为TextField和SecureField添加占位符功能
|
||||
/// - Parameters:
|
||||
/// - shouldShow: 是否显示占位符
|
||||
/// - alignment: 占位符对齐方式
|
||||
/// - placeholder: 占位符视图构建器
|
||||
/// - Returns: 带有占位符的视图
|
||||
func placeholder<Content: View>(
|
||||
when shouldShow: Bool,
|
||||
alignment: Alignment = .leading,
|
||||
@ViewBuilder placeholder: () -> Content) -> some View {
|
||||
|
||||
ZStack(alignment: alignment) {
|
||||
placeholder().opacity(shouldShow ? 1 : 0)
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
110
yana/Utils/FontManager.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 字体管理工具类
|
||||
/// 统一管理项目中使用的自定义字体
|
||||
struct FontManager {
|
||||
|
||||
// MARK: - 自定义字体名称
|
||||
enum CustomFont: String, CaseIterable {
|
||||
case bayonRegular = "Bayon-Regular"
|
||||
|
||||
/// 字体的显示名称
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .bayonRegular:
|
||||
return "Bayon Regular"
|
||||
}
|
||||
}
|
||||
|
||||
/// 字体文件名(不包含扩展名)
|
||||
var fileName: String {
|
||||
return self.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 字体创建方法
|
||||
|
||||
/// 创建自定义字体
|
||||
/// - Parameters:
|
||||
/// - customFont: 自定义字体类型
|
||||
/// - size: 字体大小
|
||||
/// - Returns: Font 对象
|
||||
static func font(_ customFont: CustomFont, size: CGFloat) -> Font {
|
||||
return Font.custom(customFont.rawValue, size: size)
|
||||
}
|
||||
|
||||
/// 创建适配屏幕的自定义字体
|
||||
/// - Parameters:
|
||||
/// - customFont: 自定义字体类型
|
||||
/// - designSize: 设计稿中的字体大小
|
||||
/// - screenWidth: 当前屏幕宽度
|
||||
/// - Returns: Font 对象
|
||||
static func adaptedFont(_ customFont: CustomFont, designSize: CGFloat, for screenWidth: CGFloat) -> Font {
|
||||
let adaptedSize = ScreenAdapter.fontSize(designSize, for: screenWidth)
|
||||
return Font.custom(customFont.rawValue, size: adaptedSize)
|
||||
}
|
||||
|
||||
/// 检查字体是否可用
|
||||
/// - Parameter customFont: 自定义字体类型
|
||||
/// - Returns: 字体是否可用
|
||||
static func isFontAvailable(_ customFont: CustomFont) -> Bool {
|
||||
let fontNames = UIFont.familyNames
|
||||
.flatMap { UIFont.fontNames(forFamilyName: $0) }
|
||||
|
||||
return fontNames.contains(customFont.rawValue)
|
||||
}
|
||||
|
||||
/// 获取所有可用的字体列表(调试用)
|
||||
/// - Returns: 所有可用字体名称的数组
|
||||
static func getAllAvailableFonts() -> [String] {
|
||||
return UIFont.familyNames
|
||||
.flatMap { family in
|
||||
UIFont.fontNames(forFamilyName: family)
|
||||
.map { _ in "\(family): \(String(describing: font))" }
|
||||
}
|
||||
.sorted()
|
||||
}
|
||||
|
||||
/// 打印所有可用字体(调试用)
|
||||
static func printAllAvailableFonts() {
|
||||
debugInfoSync("=== 所有可用字体 ===")
|
||||
for font in getAllAvailableFonts() {
|
||||
debugInfoSync(font)
|
||||
}
|
||||
debugInfoSync("==================")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI View Extension
|
||||
extension View {
|
||||
/// 应用自定义字体
|
||||
/// - Parameters:
|
||||
/// - customFont: 自定义字体类型
|
||||
/// - size: 字体大小
|
||||
/// - Returns: 应用了自定义字体的视图
|
||||
func customFont(_ customFont: FontManager.CustomFont, size: CGFloat) -> some View {
|
||||
self.font(FontManager.font(customFont, size: size))
|
||||
}
|
||||
|
||||
/// 应用适配屏幕的自定义字体
|
||||
/// - Parameters:
|
||||
/// - customFont: 自定义字体类型
|
||||
/// - designSize: 设计稿中的字体大小
|
||||
/// - Returns: 应用了适配字体的视图修饰器
|
||||
func adaptedCustomFont(_ customFont: FontManager.CustomFont, designSize: CGFloat) -> some View {
|
||||
self.modifier(AdaptedCustomFontModifier(customFont: customFont, designSize: designSize))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewModifier
|
||||
struct AdaptedCustomFontModifier: ViewModifier {
|
||||
let customFont: FontManager.CustomFont
|
||||
let designSize: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
GeometryReader { geometry in
|
||||
content
|
||||
.font(FontManager.adaptedFont(customFont, designSize: designSize, for: geometry.size.width))
|
||||
}
|
||||
}
|
||||
}
|