Compare commits
20 Commits
d4bef537d9
...
c072a7e73d
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c072a7e73d | ||
![]() |
6cc4b11e93 | ||
![]() |
cb325724dc | ||
![]() |
71c40e465d | ||
![]() |
f30026821a | ||
![]() |
25fec8a2e6 | ||
![]() |
3a74547684 | ||
![]() |
bb49b00a59 | ||
![]() |
772543243f | ||
![]() |
8b09653c4c | ||
![]() |
3a68270ca9 | ||
![]() |
0fe3b6cb7a | ||
![]() |
8362142c49 | ||
![]() |
ed3e7100c3 | ||
![]() |
fd6e44c6f9 | ||
![]() |
2a02553015 | ||
![]() |
4eb01bde7c | ||
![]() |
60b3f824be | ||
![]() |
c8ff40cac1 | ||
![]() |
6c363ea884 |
@@ -4,33 +4,34 @@ globs:
|
|||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
# CONTEXT
|
# CONTEXT
|
||||||
|
|
||||||
I wish to receive advice using the latest tools and seek step-by-step guidance to fully understand the implementation process.
|
I wish to receive advice using the latest tools and seek step-by-step guidance to fully understand the implementation process.
|
||||||
|
|
||||||
# OBJECTIVE
|
## OBJECTIVE
|
||||||
As an expert AI programming assistant, your task is to provide me with clear and readable SwiftUI code. You should:
|
|
||||||
- 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
|
As an expert AI programming assistant, your task is to provide me with clear, readable, and effective code. You should:
|
||||||
- Keep answers concise and direct, minimizing unnecessary wording.
|
|
||||||
- Emphasize code readability over performance optimization.
|
|
||||||
- Maintain a professional and supportive tone, ensuring clarity of content.
|
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
# AUDIENCE
|
## STYLE
|
||||||
The target audience is me, a native Chinese developer eager to learn Swift 6 and Xcode 15.9, seeking guidance and advice on utilizing the latest technologies.
|
|
||||||
|
|
||||||
# RESPONSE FORMAT
|
- Keep answers concise and direct, minimizing unnecessary wording.
|
||||||
- **Utilize the Chain-of-Thought (CoT) method to reason and respond, explaining your thought process step by step.**
|
- Emphasize code readability over performance optimization.
|
||||||
- The reply should include:
|
- 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.
|
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:
|
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.
|
- Include all necessary imports and properly name key components.
|
||||||
- Fully implement all requested features, leaving no to-dos, placeholders, or omissions.
|
- Fully implement all requested features, leaving no to-dos, placeholders, or omissions.
|
||||||
3. **Concise Response**: Minimize unnecessary verbosity, focusing only on essential information.
|
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.
|
- 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.
|
||||||
|
79
README.md
79
README.md
@@ -2,37 +2,50 @@
|
|||||||
|
|
||||||
## 项目简介
|
## 项目简介
|
||||||
|
|
||||||
Yana 是一个基于 iOS 平台的即时通讯应用,使用 Swift 语言开发,集成了网易云信 SDK 实现即时通讯功能。
|
Yana 是一个基于 iOS 平台的即时通讯应用,使用 Swift 语言开发,集成了网易云信 SDK 实现即时通讯功能,并采用 The Composable Architecture (TCA) 架构设计。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- 开发语言:Swift
|
- **开发语言**:Swift (主要),Objective-C (部分组件)
|
||||||
- 最低支持版本:iOS 15.6
|
- **最低支持版本**:iOS 16
|
||||||
- 主要框架:
|
- **架构模式**:The Composable Architecture (TCA) - 1.20.2
|
||||||
- NIMSDK_LITE:网易云信即时通讯 SDK
|
- **UI 框架**:SwiftUI
|
||||||
|
- **依赖管理**:
|
||||||
|
- CocoaPods
|
||||||
|
- Swift Package Manager
|
||||||
|
- **主要框架**:
|
||||||
|
- NIMSDK_LITE:网易云信即时通讯 SDK (10.6.1)
|
||||||
- NEChatKit:聊天核心组件
|
- NEChatKit:聊天核心组件
|
||||||
- NEChatUIKit:会话(聊天)UI 组件
|
- NEChatUIKit:会话(聊天)UI 组件
|
||||||
- NEContactUIKit:通讯录 UI 组件
|
- NEContactUIKit:通讯录 UI 组件
|
||||||
- NELocalConversationUIKit:本地会话列表 UI 组件
|
- NELocalConversationUIKit:本地会话列表 UI 组件
|
||||||
- Alamofire:网络请求框架
|
- Alamofire:网络请求框架
|
||||||
|
- ComposableArchitecture:状态管理 (v1.20.2+)
|
||||||
|
- CasePaths:枚举模式匹配
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
yana/
|
yana/
|
||||||
├── AppDelegate.swift # 应用程序代理
|
├── yana/ # 主应用源代码
|
||||||
├── yanaApp.swift # SwiftUI 应用入口
|
│ ├── Info.plist
|
||||||
├── ContentView.swift # 主视图
|
│ ├── yana-Bridging-Header.h # Objective-C 集成桥接头文件
|
||||||
├── Managers/ # 管理器类
|
│ ├── AppDelegate.swift # 应用程序代理
|
||||||
├── Models/ # 数据模型
|
│ ├── yanaApp.swift # SwiftUI 应用入口
|
||||||
├── Configs/ # 配置文件
|
│ ├── ContentView.swift # 主视图
|
||||||
└── Assets.xcassets/ # 资源文件
|
│ ├── Managers/ # 管理器类
|
||||||
|
│ ├── Models/ # 数据模型
|
||||||
|
│ ├── Configs/ # 配置文件
|
||||||
|
│ ├── APIs/ # API 相关文件
|
||||||
|
│ └── Assets.xcassets/ # 资源文件
|
||||||
|
├── yanaAPITests/ # API 测试目标
|
||||||
|
└── Pods/ # CocoaPods 依赖
|
||||||
```
|
```
|
||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
- Xcode 13.0 或更高版本
|
- Xcode 13.0 或更高版本
|
||||||
- iOS 15.6 或更高版本
|
- iOS 16 或更高版本
|
||||||
- CocoaPods 包管理器
|
- CocoaPods 包管理器
|
||||||
|
|
||||||
## 安装步骤
|
## 安装步骤
|
||||||
@@ -49,10 +62,24 @@ yana/
|
|||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- 即时通讯
|
- **用户认证**:
|
||||||
- 会话管理
|
- 邮箱登录流程(带验证码)
|
||||||
- 通讯录管理
|
- 多种认证方式
|
||||||
- 本地会话列表
|
- **即时通讯**
|
||||||
|
- **会话管理**
|
||||||
|
- **通讯录管理**
|
||||||
|
- **本地会话列表**
|
||||||
|
- **云存储集成**
|
||||||
|
|
||||||
|
## UI 组件
|
||||||
|
|
||||||
|
项目包含多种自定义 UI 组件:
|
||||||
|
- 自定义登录按钮
|
||||||
|
- 底部标签导航
|
||||||
|
- API 调用加载效果
|
||||||
|
- Web 视图集成
|
||||||
|
- 图片预览功能
|
||||||
|
- 屏幕适配工具
|
||||||
|
|
||||||
## API 使用
|
## API 使用
|
||||||
|
|
||||||
@@ -75,21 +102,27 @@ let response = try await apiService.request(request)
|
|||||||
|
|
||||||
- 项目使用 CocoaPods 管理依赖
|
- 项目使用 CocoaPods 管理依赖
|
||||||
- 需要配置网易云信相关密钥
|
- 需要配置网易云信相关密钥
|
||||||
- 最低支持 iOS 15.6 版本
|
- 最低支持 iOS 16 版本
|
||||||
|
- 仅支持 iPhone 设备(不支持 iPad、Mac Catalyst 或 Vision Pro)
|
||||||
|
|
||||||
## 开发规范
|
## 开发规范
|
||||||
|
|
||||||
- 遵循 Swift 官方编码规范
|
- 遵循 Swift 官方编码规范
|
||||||
- 使用 SwiftUI 构建用户界面
|
- 使用 SwiftUI 构建用户界面
|
||||||
- 采用 MVVM 架构模式
|
- 采用 TCA 架构模式
|
||||||
|
- 支持多语言(包含中文本地化)
|
||||||
|
|
||||||
## 依赖版本
|
## 测试
|
||||||
|
|
||||||
- NIMSDK 相关组件版本:10.6.1
|
项目包含专门的 API 测试目标 "yanaAPITests",用于对主应用的 API 功能进行单元测试。
|
||||||
- Alamofire:最新版本
|
|
||||||
|
## 开发团队
|
||||||
|
|
||||||
|
项目由团队 "EKM7RAGNA6" 开发,测试目标的包标识符为 "com.stupidmonkey.yanaAPITests"。
|
||||||
|
|
||||||
## 构建配置
|
## 构建配置
|
||||||
|
|
||||||
- 项目使用动态框架
|
- 项目使用动态框架
|
||||||
- 支持 iOS 13.0 及以上版本
|
- 支持 iOS 16 及以上版本
|
||||||
|
- Swift 版本:6.0
|
||||||
- 已配置框架冲突处理脚本
|
- 已配置框架冲突处理脚本
|
@@ -583,14 +583,16 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = EKM7RAGNA6;
|
DEVELOPMENT_TEAM = EKM7RAGNA6;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yanaAPITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.junpeiqi.eparty;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
@@ -610,7 +612,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = EKM7RAGNA6;
|
DEVELOPMENT_TEAM = EKM7RAGNA6;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yanaAPITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yanaAPITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
@@ -21,10 +21,15 @@ enum APIEndpoint: String, CaseIterable {
|
|||||||
case emailGetCode = "/email/getCode" // 新增:邮箱验证码获取端点
|
case emailGetCode = "/email/getCode" // 新增:邮箱验证码获取端点
|
||||||
case latestDynamics = "/dynamic/square/latestDynamics" // 新增:获取最新动态列表端点
|
case latestDynamics = "/dynamic/square/latestDynamics" // 新增:获取最新动态列表端点
|
||||||
case tcToken = "/tencent/cos/getToken" // 新增:腾讯云 COS Token 获取端点
|
case tcToken = "/tencent/cos/getToken" // 新增:腾讯云 COS Token 获取端点
|
||||||
|
case publishFeed = "/dynamic/square/publish" // 发布动态
|
||||||
|
case getUserInfo = "/user/get" // 新增:获取用户信息端点
|
||||||
|
case getMyDynamic = "/dynamic/getMyDynamic"
|
||||||
|
|
||||||
// Web 页面路径
|
// Web 页面路径
|
||||||
case userAgreement = "/modules/rule/protocol.html"
|
case userAgreement = "/modules/rule/protocol.html"
|
||||||
case privacyPolicy = "/modules/rule/privacy-wap.html"
|
case privacyPolicy = "/modules/rule/privacy-wap.html"
|
||||||
|
|
||||||
|
|
||||||
var path: String {
|
var path: String {
|
||||||
return self.rawValue
|
return self.rawValue
|
||||||
}
|
}
|
||||||
|
@@ -722,3 +722,124 @@ struct TcTokenData: Codable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 userInfo = 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 cachedUserInfo = await getUserInfo() {
|
||||||
|
debugInfoSync("📱 APP启动:使用现有用户信息缓存")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动获取用户信息
|
||||||
|
debugInfoSync("🔄 APP启动:自动获取用户信息")
|
||||||
|
return await refreshCurrentUserInfo(apiService: apiService)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -4,7 +4,7 @@ import ComposableArchitecture
|
|||||||
// MARK: - 响应数据模型
|
// MARK: - 响应数据模型
|
||||||
|
|
||||||
/// 最新动态响应结构
|
/// 最新动态响应结构
|
||||||
struct MomentsLatestResponse: Codable, Equatable {
|
struct MomentsLatestResponse: Codable, Equatable, Sendable {
|
||||||
let code: Int
|
let code: Int
|
||||||
let message: String
|
let message: String
|
||||||
let data: MomentsListData?
|
let data: MomentsListData?
|
||||||
@@ -12,18 +12,17 @@ struct MomentsLatestResponse: Codable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 动态列表数据
|
/// 动态列表数据
|
||||||
struct MomentsListData: Codable, Equatable {
|
struct MomentsListData: Codable, Equatable, Sendable {
|
||||||
let dynamicList: [MomentsInfo]
|
let dynamicList: [MomentsInfo]
|
||||||
let nextDynamicId: Int
|
let nextDynamicId: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 动态信息结构
|
/// 动态信息结构
|
||||||
struct MomentsInfo: Codable, Equatable {
|
public struct MomentsInfo: Codable, Equatable, Sendable {
|
||||||
let dynamicId: Int
|
let dynamicId: Int
|
||||||
let uid: Int
|
let uid: Int
|
||||||
let nick: String
|
let nick: String
|
||||||
let avatar: String
|
let avatar: String
|
||||||
let gender: Int
|
|
||||||
let type: Int
|
let type: Int
|
||||||
let content: String
|
let content: String
|
||||||
let likeCount: Int
|
let likeCount: Int
|
||||||
@@ -31,52 +30,47 @@ struct MomentsInfo: Codable, Equatable {
|
|||||||
let commentCount: Int
|
let commentCount: Int
|
||||||
let publishTime: Int
|
let publishTime: Int
|
||||||
let worldId: Int
|
let worldId: Int
|
||||||
let squareTop: Int
|
|
||||||
let topicTop: Int
|
|
||||||
let newUser: Bool
|
|
||||||
let defUser: Int
|
|
||||||
let status: Int
|
let status: Int
|
||||||
let scene: String
|
// data.md 里部分字段可选
|
||||||
|
let playCount: Int?
|
||||||
let dynamicResList: [MomentsPicture]?
|
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 userVipInfoVO: UserVipInfo?
|
||||||
|
|
||||||
// 头饰相关 - 全部可选
|
|
||||||
let headwearPic: String?
|
let headwearPic: String?
|
||||||
let headwearEffect: String?
|
let headwearEffect: String?
|
||||||
let headwearType: Int?
|
let headwearType: Int?
|
||||||
let headwearName: String?
|
let headwearName: String?
|
||||||
let headwearId: Int?
|
let headwearId: Int?
|
||||||
|
|
||||||
// 等级相关 - 全部可选
|
|
||||||
let experLevelPic: String?
|
let experLevelPic: String?
|
||||||
let charmLevelPic: String?
|
let charmLevelPic: String?
|
||||||
|
|
||||||
// 其他可选字段
|
|
||||||
let isCustomWord: Bool?
|
let isCustomWord: Bool?
|
||||||
let labelList: [String]?
|
let labelList: [String]?
|
||||||
|
// 计算属性
|
||||||
// 计算属性:将Int转换为Bool
|
var isSquareTop: Bool { (squareTop ?? 0) != 0 }
|
||||||
var isSquareTop: Bool { squareTop != 0 }
|
var isTopicTop: Bool { (topicTop ?? 0) != 0 }
|
||||||
var isTopicTop: Bool { topicTop != 0 }
|
|
||||||
|
|
||||||
// 计算属性:格式化时间戳
|
|
||||||
var formattedPublishTime: Date {
|
var formattedPublishTime: Date {
|
||||||
Date(timeIntervalSince1970: TimeInterval(publishTime) / 1000.0)
|
Date(timeIntervalSince1970: TimeInterval(publishTime) / 1000.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 动态图片信息
|
/// 动态图片信息
|
||||||
struct MomentsPicture: Codable, Equatable {
|
struct MomentsPicture: Codable, Equatable, Sendable {
|
||||||
let id: Int
|
let id: Int?
|
||||||
let resUrl: String
|
let resUrl: String?
|
||||||
let format: String
|
let format: String?
|
||||||
let width: Int
|
let width: Int?
|
||||||
let height: Int
|
let height: Int?
|
||||||
let resDuration: Int? // 可选字段,因为有些图片没有这个字段
|
let resDuration: Int? // 可选字段,因为有些图片没有这个字段
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用户VIP信息 - 完整版本,所有字段都是可选的
|
/// 用户VIP信息 - 完整版本,所有字段都是可选的
|
||||||
struct UserVipInfo: Codable, Equatable {
|
struct UserVipInfo: Codable, Equatable, Sendable {
|
||||||
let vipLevel: Int?
|
let vipLevel: Int?
|
||||||
let vipName: String?
|
let vipName: String?
|
||||||
let vipIcon: String?
|
let vipIcon: String?
|
||||||
@@ -158,3 +152,132 @@ struct LatestDynamicsRequest: APIRequestProtocol {
|
|||||||
var shouldShowLoading: Bool { true }
|
var shouldShowLoading: Bool { true }
|
||||||
var shouldShowError: 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 }
|
||||||
|
}
|
||||||
|
@@ -146,29 +146,209 @@ struct IDLoginData: Codable, Equatable {
|
|||||||
|
|
||||||
// MARK: - User Info Model
|
// MARK: - User Info Model
|
||||||
struct UserInfo: Codable, Equatable {
|
struct UserInfo: Codable, Equatable {
|
||||||
let userId: String?
|
let uid: Int?
|
||||||
let username: String?
|
let userId: String? // 兼容旧字段
|
||||||
let nickname: String?
|
let nick: String?
|
||||||
|
let nickname: String? // 兼容旧字段
|
||||||
let avatar: String?
|
let avatar: String?
|
||||||
let email: String?
|
let region: String?
|
||||||
let phone: String?
|
let regionDesc: String?
|
||||||
let status: String?
|
let gender: Int?
|
||||||
let createTime: String?
|
let birth: Int64?
|
||||||
let updateTime: String?
|
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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case uid
|
||||||
case userId = "user_id"
|
case userId = "user_id"
|
||||||
case username
|
case nick
|
||||||
case nickname
|
case nickname
|
||||||
case avatar
|
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 email
|
||||||
case phone
|
case phone
|
||||||
case status
|
case updateTime
|
||||||
case createTime = "create_time"
|
|
||||||
case updateTime = "update_time"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// MARK: - Login Helper
|
||||||
struct LoginHelper {
|
struct LoginHelper {
|
||||||
|
|
||||||
@@ -413,3 +593,66 @@ extension LoginHelper {
|
|||||||
return EmailLoginRequest(email: encryptedEmail, code: 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,92 +1,330 @@
|
|||||||
## 📝 给继任者的详细工作交接说明
|
📦 Response Data:
|
||||||
|
{
|
||||||
亲爱的继任者,我刚刚为这个 **yana iOS 项目**完成了一个完整的图片缓存优化工作。以下是关键信息:
|
"code" : 200,
|
||||||
|
"message" : "success",
|
||||||
### 🎯 已完成的核心工作
|
"data" : [
|
||||||
|
{
|
||||||
1. **解决了重大性能问题**:
|
"isLike" : false,
|
||||||
- **问题**:FeedView 中图片每次滚动都重新加载,用户体验极差
|
"uid" : 3184,
|
||||||
- **原因**:AsyncImage 缓存不足,没有预加载机制,cell 重用时图片丢失
|
"playCount" : 0,
|
||||||
|
"worldId" : -1,
|
||||||
2. **创建了企业级图片缓存系统**:
|
"likeCount" : 0,
|
||||||
- **文件**:`yana/Utils/ImageCacheManager.swift`
|
"type" : 0,
|
||||||
- **功能**:三层缓存(内存+磁盘+网络)+ 智能预加载 + 任务去重
|
"dynamicId" : 267,
|
||||||
|
"nick" : "hansome",
|
||||||
3. **优化了 FeedView 架构**:
|
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||||
- **文件**:`yana/Views/FeedView.swift`
|
"commentCount" : 0,
|
||||||
- **改进**:使用 `CachedAsyncImage` 替代 `AsyncImage`,添加预加载机制
|
"publishTime" : 1753182147000,
|
||||||
|
"status" : 0,
|
||||||
### ✅ 技术架构详情
|
"content" : "我"
|
||||||
|
},
|
||||||
#### **ImageCacheManager 核心特性**:
|
{
|
||||||
- **内存缓存**:NSCache,50MB 限制,100张图片
|
"isLike" : false,
|
||||||
- **磁盘缓存**:Documents/ImageCache,100MB 限制,SHA256 文件名
|
"uid" : 3184,
|
||||||
- **预加载**:当前位置前后2个动态的所有图片
|
"playCount" : 0,
|
||||||
- **任务去重**:同一图片多次请求共享下载任务
|
"dynamicResList" : [
|
||||||
|
{
|
||||||
#### **CachedAsyncImage 组件**:
|
"height" : 3024,
|
||||||
- **缓存优先级**:内存 → 磁盘 → 网络
|
"id" : 443,
|
||||||
- **异步加载**:不阻塞主线程
|
"width" : 4032,
|
||||||
- **SwiftUI 兼容**:完全兼容现有 AsyncImage 语法
|
"resUrl" : "https:\/\/image.molistar.xyz\/images\/C32EB0F8-CBF5-4F4B-8114-C3C7E1AF192F.jpg",
|
||||||
|
"format" : "jpeg"
|
||||||
#### **FeedView 优化**:
|
}
|
||||||
- **OptimizedDynamicCardView**:使用缓存图片组件
|
],
|
||||||
- **OptimizedImageGrid**:优化的图片网格
|
"worldId" : -1,
|
||||||
- **智能预加载**:onAppear 时触发相邻内容预加载
|
"likeCount" : 0,
|
||||||
|
"type" : 2,
|
||||||
### 🔧 重要的技术细节
|
"dynamicId" : 266,
|
||||||
|
"nick" : "hansome",
|
||||||
1. **哈希冲突解决**:
|
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||||
- 项目中已有 `String+MD5.swift` 文件
|
"commentCount" : 0,
|
||||||
- 使用现有的 `sha256()` 和 `md5()` 方法,避免重复声明
|
"publishTime" : 1753181890000,
|
||||||
|
"status" : 0,
|
||||||
2. **兼容性处理**:
|
"content" : ""
|
||||||
- iOS 13+:使用 CryptoKit 的 SHA256
|
},
|
||||||
- iOS 13以下:使用 CommonCrypto 的 MD5
|
{
|
||||||
|
"isLike" : false,
|
||||||
3. **Bridging Header 配置**:
|
"uid" : 3184,
|
||||||
- 已添加 `#import <CommonCrypto/CommonCrypto.h>`
|
"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,
|
||||||
1. **API 功能已完成**:
|
"type" : 2,
|
||||||
- 动态内容 API 集成完毕(DynamicsModels.swift + FeedFeature.swift)
|
"dynamicId" : 265,
|
||||||
- 数据解析问题已解决(类型匹配修复)
|
"nick" : "hansome",
|
||||||
- TCA 架构状态管理正常工作
|
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||||
|
"commentCount" : 0,
|
||||||
2. **当前状态**:
|
"publishTime" : 1753181143000,
|
||||||
- ✅ 编译成功
|
"status" : 0,
|
||||||
- ✅ API 数据正常显示
|
"content" : "大"
|
||||||
- ✅ 图片缓存系统就绪
|
},
|
||||||
- ✅ 性能优化完成
|
{
|
||||||
|
"isLike" : false,
|
||||||
### 🔍 可能的后续工作
|
"uid" : 3184,
|
||||||
|
"playCount" : 0,
|
||||||
用户可能需要:
|
"dynamicResList" : [
|
||||||
1. **功能扩展**:点赞、评论、分享等交互功能
|
{
|
||||||
2. **UI 优化**:更丰富的动画效果、主题切换
|
"height" : 3024,
|
||||||
3. **性能监控**:添加缓存命中率统计、内存使用监控
|
"id" : 440,
|
||||||
4. **错误处理**:网络异常时的重试机制优化
|
"width" : 4032,
|
||||||
|
"resUrl" : "https:\/\/https:\/\/image.molistar.xyz\/images\/DF8E655B-2F63-4B34-90B3-13C8A812245C.jpg",
|
||||||
### 💡 重要提醒
|
"format" : "jpeg"
|
||||||
|
},
|
||||||
- **用户是中文开发者**:需要中文回复,使用 Chain-of-Thought 思考过程
|
{
|
||||||
- **项目基于 iOS 15.6**:注意兼容性要求
|
"height" : 1792,
|
||||||
- **TCA 架构**:遵循项目现有的 TCA 模式
|
"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
|
||||||
|
}
|
||||||
|
=====================================
|
@@ -2,7 +2,9 @@ import UIKit
|
|||||||
//import NIMSDK
|
//import NIMSDK
|
||||||
|
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
|
private func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) async -> Bool {
|
||||||
|
|
||||||
|
// isPerceptionCheckingEnabled = false
|
||||||
|
|
||||||
// 执行数据迁移(从 UserDefaults 到 Keychain)
|
// 执行数据迁移(从 UserDefaults 到 Keychain)
|
||||||
DataMigrationManager.performStartupMigration()
|
DataMigrationManager.performStartupMigration()
|
||||||
|
123
yana/Features/AppSettingFeature.swift
Normal file
123
yana/Features/AppSettingFeature.swift
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Action: Equatable {
|
||||||
|
case onAppear
|
||||||
|
case editNicknameTapped
|
||||||
|
case logoutTapped
|
||||||
|
|
||||||
|
// 用户信息相关
|
||||||
|
case loadUserInfo
|
||||||
|
case userInfoResponse(Result<UserInfo, APIError>)
|
||||||
|
|
||||||
|
// WebView 导航
|
||||||
|
case personalInfoPermissionsTapped
|
||||||
|
case helpTapped
|
||||||
|
case clearCacheTapped
|
||||||
|
case checkUpdatesTapped
|
||||||
|
case aboutUsTapped
|
||||||
|
|
||||||
|
// WebView 关闭
|
||||||
|
case userAgreementDismissed
|
||||||
|
case privacyPolicyDismissed
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dependency(\.apiService) var apiService
|
||||||
|
|
||||||
|
var body: some ReducerOf<Self> {
|
||||||
|
Reduce { state, action in
|
||||||
|
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 .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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -145,7 +145,7 @@ extension CreateFeedFeature.Action: Equatable {
|
|||||||
|
|
||||||
struct PublishDynamicRequest: APIRequestProtocol {
|
struct PublishDynamicRequest: APIRequestProtocol {
|
||||||
typealias Response = PublishDynamicResponse
|
typealias Response = PublishDynamicResponse
|
||||||
let endpoint: String = "/dynamic/square/publish"
|
let endpoint: String = APIEndpoint.publishFeed.path
|
||||||
let method: HTTPMethod = .POST
|
let method: HTTPMethod = .POST
|
||||||
let includeBaseParameters: Bool = true
|
let includeBaseParameters: Bool = true
|
||||||
let queryParameters: [String: String]? = nil
|
let queryParameters: [String: String]? = nil
|
||||||
|
@@ -160,10 +160,20 @@ struct EMailLoginFeature {
|
|||||||
case .loginResponse(.success(let accountModel)):
|
case .loginResponse(.success(let accountModel)):
|
||||||
state.isLoading = false
|
state.isLoading = false
|
||||||
state.loginStep = .completed
|
state.loginStep = .completed
|
||||||
// Effect 保存AccountModel并发送通知
|
// Effect 保存AccountModel并获取用户信息
|
||||||
return .run { _ in
|
return .run { _ in
|
||||||
await UserInfoManager.saveAccountModel(accountModel)
|
await UserInfoManager.saveAccountModel(accountModel)
|
||||||
// 移除:NotificationCenter.default.post(name: .ticketSuccess, object: nil)
|
|
||||||
|
// 新增:登录成功后自动获取用户信息
|
||||||
|
debugInfoSync("🔄 邮箱登录成功,开始获取用户信息")
|
||||||
|
if let _ = await UserInfoManager.fetchUserInfoFromServer(
|
||||||
|
uid: accountModel.uid,
|
||||||
|
apiService: apiService
|
||||||
|
) {
|
||||||
|
debugInfoSync("✅ 用户信息获取成功")
|
||||||
|
} else {
|
||||||
|
debugErrorSync("❌ 用户信息获取失败,但不影响登录流程")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case .loginResponse(.failure(let error)):
|
case .loginResponse(.failure(let error)):
|
||||||
|
214
yana/Features/EditFeedFeature.swift
Normal file
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,33 +1,123 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ComposableArchitecture
|
import ComposableArchitecture
|
||||||
|
|
||||||
struct FeedListFeature: Reducer {
|
@Reducer
|
||||||
|
struct FeedListFeature {
|
||||||
|
@Dependency(\.apiService) var apiService
|
||||||
struct State: Equatable {
|
struct State: Equatable {
|
||||||
|
var isFirstLoad: Bool = true
|
||||||
var feeds: [Feed] = [] // 预留 feed 内容
|
var feeds: [Feed] = [] // 预留 feed 内容
|
||||||
var isLoading: Bool = false
|
var isLoading: Bool = false
|
||||||
var error: String? = nil
|
var error: String? = nil
|
||||||
var isEditFeedPresented: Bool = false // 新增:控制 EditFeedView 弹窗
|
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 {
|
enum Action: Equatable {
|
||||||
case onAppear
|
case onAppear
|
||||||
case reload
|
case reload
|
||||||
case loadMore
|
case loadMore
|
||||||
|
case loadMoreResponse(TaskResult<MomentsLatestResponse>)
|
||||||
case editFeedButtonTapped // 新增:点击 add 按钮
|
case editFeedButtonTapped // 新增:点击 add 按钮
|
||||||
case editFeedDismissed // 新增:关闭编辑页
|
case editFeedDismissed // 新增:关闭编辑页
|
||||||
|
// 新增:动态内容相关
|
||||||
|
case fetchFeeds
|
||||||
|
case fetchFeedsResponse(TaskResult<MomentsLatestResponse>)
|
||||||
// 预留后续 Action
|
// 预留后续 Action
|
||||||
}
|
}
|
||||||
|
|
||||||
func reduce(into state: inout State, action: Action) -> Effect<Action> {
|
func reduce(into state: inout State, action: Action) -> Effect<Action> {
|
||||||
switch action {
|
switch action {
|
||||||
case .onAppear:
|
case .onAppear:
|
||||||
// 预留数据加载逻辑
|
guard state.isFirstLoad else { return .none }
|
||||||
return .none
|
state.isFirstLoad = false
|
||||||
|
return .send(.fetchFeeds)
|
||||||
case .reload:
|
case .reload:
|
||||||
// 预留刷新逻辑
|
// 下拉刷新,重置状态并请求第一页
|
||||||
return .none
|
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:
|
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
|
return .none
|
||||||
case .editFeedButtonTapped:
|
case .editFeedButtonTapped:
|
||||||
state.isEditFeedPresented = true
|
state.isEditFeedPresented = true
|
||||||
|
@@ -1,30 +1,19 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ComposableArchitecture
|
import ComposableArchitecture
|
||||||
|
|
||||||
@Reducer
|
struct HomeFeature: Reducer {
|
||||||
struct HomeFeature {
|
|
||||||
enum Route: Equatable {
|
enum Route: Equatable {
|
||||||
case createFeed
|
case createFeed
|
||||||
}
|
}
|
||||||
|
|
||||||
@ObservableState
|
struct State: Equatable {
|
||||||
struct State: Equatable, Sendable {
|
|
||||||
var isInitialized = false
|
var isInitialized = false
|
||||||
var userInfo: UserInfo?
|
var userInfo: UserInfo?
|
||||||
var accountModel: AccountModel?
|
var accountModel: AccountModel?
|
||||||
var error: String?
|
var error: String?
|
||||||
|
|
||||||
// 设置页面相关状态
|
|
||||||
var isSettingPresented = false
|
|
||||||
var settingState = SettingFeature.State()
|
|
||||||
|
|
||||||
// 新增:Feed 状态
|
|
||||||
var feedState = FeedFeature.State()
|
var feedState = FeedFeature.State()
|
||||||
|
var meDynamic = MeDynamicFeature.State(uid: 0)
|
||||||
// 新增:登出状态
|
|
||||||
var isLoggedOut = false
|
var isLoggedOut = false
|
||||||
|
|
||||||
// 新增:路由状态
|
|
||||||
var route: Route? = nil
|
var route: Route? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,91 +26,67 @@ struct HomeFeature {
|
|||||||
case accountModelLoaded(AccountModel?)
|
case accountModelLoaded(AccountModel?)
|
||||||
case logoutTapped
|
case logoutTapped
|
||||||
case logout
|
case logout
|
||||||
|
|
||||||
// 设置页面相关actions
|
|
||||||
case settingDismissed
|
|
||||||
case setting(SettingFeature.Action)
|
|
||||||
|
|
||||||
// 新增:Feed actions
|
|
||||||
case feed(FeedFeature.Action)
|
case feed(FeedFeature.Action)
|
||||||
|
case meDynamic(MeDynamicFeature.Action)
|
||||||
// 新增:登出完成
|
|
||||||
case logoutCompleted
|
case logoutCompleted
|
||||||
|
|
||||||
// 新增:路由 actions
|
|
||||||
case showCreateFeed
|
case showCreateFeed
|
||||||
case createFeedDismissed
|
case createFeedDismissed
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Reducer<State, Action> {
|
var body: some ReducerOf<Self> {
|
||||||
// Reducer<State, Action>.combine([
|
Scope(state: \.feedState, action: \.feed) {
|
||||||
// Reducer { state, action in
|
FeedFeature()
|
||||||
// switch action {
|
}
|
||||||
// case .onAppear:
|
Scope(state: \.meDynamic, action: \.meDynamic) {
|
||||||
// guard !state.isInitialized else {
|
MeDynamicFeature()
|
||||||
// return Effect.none
|
}
|
||||||
// }
|
Reduce { state, action in
|
||||||
// state.isInitialized = true
|
switch action {
|
||||||
// return .concatenate(
|
case .onAppear:
|
||||||
// .send(.loadUserInfo),
|
guard !state.isInitialized else { return .none }
|
||||||
// .send(.loadAccountModel)
|
state.isInitialized = true
|
||||||
// )
|
return .concatenate(
|
||||||
// case .loadUserInfo:
|
.send(.loadUserInfo),
|
||||||
// return .run { send in
|
.send(.loadAccountModel)
|
||||||
// let userInfo = await UserInfoManager.getUserInfo()
|
)
|
||||||
// await send(.userInfoLoaded(userInfo))
|
case .loadUserInfo:
|
||||||
// }
|
return .run { send in
|
||||||
// case let .userInfoLoaded(userInfo):
|
let userInfo = await UserInfoManager.getUserInfo()
|
||||||
// state.userInfo = userInfo
|
await send(.userInfoLoaded(userInfo))
|
||||||
// return Effect.none
|
}
|
||||||
// case .loadAccountModel:
|
case let .userInfoLoaded(userInfo):
|
||||||
// return .run { send in
|
state.userInfo = userInfo
|
||||||
// let accountModel = await UserInfoManager.getAccountModel()
|
state.meDynamic.uid = userInfo?.uid ?? 0
|
||||||
// await send(.accountModelLoaded(accountModel))
|
return .none
|
||||||
// }
|
case .loadAccountModel:
|
||||||
// case let .accountModelLoaded(accountModel):
|
return .run { send in
|
||||||
// state.accountModel = accountModel
|
let accountModel = await UserInfoManager.getAccountModel()
|
||||||
// return Effect.none
|
await send(.accountModelLoaded(accountModel))
|
||||||
// case .logoutTapped:
|
}
|
||||||
// return .send(.logout)
|
case let .accountModelLoaded(accountModel):
|
||||||
// case .logout:
|
state.accountModel = accountModel
|
||||||
// return .run { send in
|
return .none
|
||||||
// await UserInfoManager.clearAllAuthenticationData()
|
case .logoutTapped:
|
||||||
// await send(.logoutCompleted)
|
return .send(.logout)
|
||||||
// }
|
case .logout:
|
||||||
// case .logoutCompleted:
|
return .run { send in
|
||||||
// state.isLoggedOut = true
|
await UserInfoManager.clearAllAuthenticationData()
|
||||||
// return Effect.none
|
await send(.logoutCompleted)
|
||||||
// case .settingDismissed:
|
}
|
||||||
// state.isSettingPresented = false
|
case .logoutCompleted:
|
||||||
// return Effect.none
|
state.isLoggedOut = true
|
||||||
// case .setting:
|
return .none
|
||||||
// return Effect.none
|
case .feed:
|
||||||
// case .showCreateFeed:
|
return .none
|
||||||
// state.route = .createFeed
|
case .meDynamic:
|
||||||
// return Effect.none
|
return .none
|
||||||
// case .createFeedDismissed:
|
case .showCreateFeed:
|
||||||
// state.route = nil
|
state.route = .createFeed
|
||||||
// return Effect.none
|
return .none
|
||||||
// case .feed:
|
case .createFeedDismissed:
|
||||||
// return Effect.none
|
state.route = nil
|
||||||
// }
|
return .none
|
||||||
// },
|
}
|
||||||
// Scope(
|
}
|
||||||
// state: \State.settingState,
|
|
||||||
// action: /Action.setting,
|
|
||||||
// child: SettingFeature()
|
|
||||||
// ),
|
|
||||||
// Scope(
|
|
||||||
// state: \State.feedState,
|
|
||||||
// action: /Action.feed,
|
|
||||||
// child: FeedFeature()
|
|
||||||
// )
|
|
||||||
// ])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除:未使用的通知名称定义
|
|
||||||
// extension Notification.Name {
|
|
||||||
// static let homeLogout = Notification.Name("homeLogout")
|
|
||||||
// }
|
|
||||||
|
@@ -135,15 +135,24 @@ struct IDLoginFeature {
|
|||||||
state.loginStep = .completed
|
state.loginStep = .completed
|
||||||
debugInfoSync("✅ ID 登录完整流程成功")
|
debugInfoSync("✅ ID 登录完整流程成功")
|
||||||
debugInfoSync("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
|
debugInfoSync("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
|
||||||
// --- 并发安全修正:彻底避免 Effect 闭包捕获 state/accountModel ---
|
|
||||||
if let ticket = response.ticket, let oldAccountModel = state.accountModel {
|
if let ticket = response.ticket, let oldAccountModel = state.accountModel {
|
||||||
// 用 withTicket 生成新 struct,闭包只捕获 newAccountModel
|
|
||||||
let newAccountModel = oldAccountModel.withTicket(ticket)
|
let newAccountModel = oldAccountModel.withTicket(ticket)
|
||||||
state.accountModel = newAccountModel
|
state.accountModel = newAccountModel
|
||||||
// 只捕获 newAccountModel,绝不捕获 state
|
|
||||||
return .run { _ in
|
return .run { _ in
|
||||||
// 这里不能捕获 state/accountModel,否则 Swift 并发会报错
|
|
||||||
await UserInfoManager.saveAccountModel(newAccountModel)
|
await UserInfoManager.saveAccountModel(newAccountModel)
|
||||||
|
|
||||||
|
// 新增:登录成功后自动获取用户信息
|
||||||
|
debugInfoSync("🔄 登录成功,开始获取用户信息")
|
||||||
|
if let _ = await UserInfoManager.fetchUserInfoFromServer(
|
||||||
|
uid: newAccountModel.uid,
|
||||||
|
apiService: apiService
|
||||||
|
) {
|
||||||
|
debugInfoSync("✅ 用户信息获取成功")
|
||||||
|
} else {
|
||||||
|
debugErrorSync("❌ 用户信息获取失败,但不影响登录流程")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if response.ticket == nil {
|
} else if response.ticket == nil {
|
||||||
state.ticketError = "Ticket 为空"
|
state.ticketError = "Ticket 为空"
|
||||||
|
@@ -10,26 +10,102 @@ struct MainFeature: Reducer {
|
|||||||
struct State: Equatable {
|
struct State: Equatable {
|
||||||
var selectedTab: Tab = .feed
|
var selectedTab: Tab = .feed
|
||||||
var feedList: FeedListFeature.State = .init()
|
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, Equatable {
|
||||||
|
case test
|
||||||
|
case appSetting
|
||||||
}
|
}
|
||||||
|
|
||||||
@CasePathable
|
@CasePathable
|
||||||
enum Action: Equatable {
|
enum Action: Equatable {
|
||||||
|
case onAppear
|
||||||
case selectTab(Tab)
|
case selectTab(Tab)
|
||||||
case feedList(FeedListFeature.Action)
|
case feedList(FeedListFeature.Action)
|
||||||
|
case me(MeFeature.Action)
|
||||||
|
case accountModelLoaded(AccountModel?)
|
||||||
|
// 新增:导航相关
|
||||||
|
case navigationPathChanged([Destination])
|
||||||
|
case testButtonTapped
|
||||||
|
case appSettingButtonTapped
|
||||||
|
case appSettingAction(AppSettingFeature.Action)
|
||||||
|
// 新增:登出
|
||||||
|
case logout
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some ReducerOf<Self> {
|
var body: some ReducerOf<Self> {
|
||||||
Scope(state: \.feedList, action: \.feedList) {
|
Scope(state: \.feedList, action: \.feedList) {
|
||||||
FeedListFeature()
|
FeedListFeature()
|
||||||
}
|
}
|
||||||
|
Scope(state: \.me, action: \.me) {
|
||||||
|
MeFeature()
|
||||||
|
}
|
||||||
Reduce { state, action in
|
Reduce { state, action in
|
||||||
switch action {
|
switch action {
|
||||||
|
case .onAppear:
|
||||||
|
return .run { send in
|
||||||
|
let accountModel = await UserInfoManager.getAccountModel()
|
||||||
|
await send(.accountModelLoaded(accountModel))
|
||||||
|
}
|
||||||
case .selectTab(let tab):
|
case .selectTab(let tab):
|
||||||
state.selectedTab = 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
|
return .none
|
||||||
case .feedList:
|
case .feedList:
|
||||||
return .none
|
return .none
|
||||||
|
case let .accountModelLoaded(accountModel):
|
||||||
|
state.accountModel = accountModel
|
||||||
|
return .none
|
||||||
|
case .me(.settingButtonTapped):
|
||||||
|
// 触发 push 到设置页
|
||||||
|
state.appSettingState = AppSettingFeature.State()
|
||||||
|
state.navigationPath.append(.appSetting)
|
||||||
|
return .none
|
||||||
|
case .me:
|
||||||
|
return .none
|
||||||
|
case .navigationPathChanged(let newPath):
|
||||||
|
// pop 回来时清空 settingState
|
||||||
|
if !newPath.contains(.appSetting) {
|
||||||
|
state.appSettingState = nil
|
||||||
|
}
|
||||||
|
state.navigationPath = newPath
|
||||||
|
return .none
|
||||||
|
case .testButtonTapped:
|
||||||
|
state.navigationPath.append(.test)
|
||||||
|
return .none
|
||||||
|
case .appSettingButtonTapped:
|
||||||
|
state.appSettingState = AppSettingFeature.State()
|
||||||
|
state.navigationPath.append(.appSetting)
|
||||||
|
return .none
|
||||||
|
case .appSettingAction(.logoutTapped):
|
||||||
|
// 监听到登出,设置登出标志
|
||||||
|
state.isLoggedOut = true
|
||||||
|
return .none
|
||||||
|
case .appSettingAction:
|
||||||
|
return .none
|
||||||
|
case .logout:
|
||||||
|
// 由上层(SplashView/SplashFeature)监听,切换到登录页
|
||||||
|
return .none
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 设置页作用域
|
||||||
|
.ifLet(\ .appSettingState, action: \.appSettingAction) {
|
||||||
|
AppSettingFeature()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
91
yana/Features/MeDynamicFeature.swift
Normal file
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
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 = try 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))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,75 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ComposableArchitecture
|
|
||||||
|
|
||||||
@Reducer
|
|
||||||
struct SettingFeature {
|
|
||||||
@ObservableState
|
|
||||||
struct State: Equatable {
|
|
||||||
var userInfo: UserInfo?
|
|
||||||
var accountModel: AccountModel?
|
|
||||||
var isLoading = false
|
|
||||||
var error: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Action: Equatable {
|
|
||||||
case onAppear
|
|
||||||
case loadUserInfo
|
|
||||||
case userInfoLoaded(UserInfo?)
|
|
||||||
case loadAccountModel
|
|
||||||
case accountModelLoaded(AccountModel?)
|
|
||||||
case logoutTapped
|
|
||||||
case logout
|
|
||||||
case dismissTapped
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some ReducerOf<Self> {
|
|
||||||
Reduce { state, action in
|
|
||||||
switch action {
|
|
||||||
case .onAppear:
|
|
||||||
return .concatenate(
|
|
||||||
.send(.loadUserInfo),
|
|
||||||
.send(.loadAccountModel)
|
|
||||||
)
|
|
||||||
|
|
||||||
case .loadUserInfo:
|
|
||||||
return .run { send in
|
|
||||||
let userInfo = await UserInfoManager.getUserInfo()
|
|
||||||
await send(.userInfoLoaded(userInfo))
|
|
||||||
}
|
|
||||||
|
|
||||||
case let .userInfoLoaded(userInfo):
|
|
||||||
state.userInfo = userInfo
|
|
||||||
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:
|
|
||||||
state.isLoading = true
|
|
||||||
return .run { _ in
|
|
||||||
await UserInfoManager.clearAllAuthenticationData()
|
|
||||||
}
|
|
||||||
|
|
||||||
case .dismissTapped:
|
|
||||||
// 移除:NotificationCenter.default.post(name: .settingsDismiss, object: nil)
|
|
||||||
// 直接通过父级 action 关闭设置页面
|
|
||||||
return .none
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除:未使用的通知名称定义
|
|
||||||
// extension Notification.Name {
|
|
||||||
// static let settingsDismiss = Notification.Name("settingsDismiss")
|
|
||||||
// }
|
|
@@ -26,11 +26,17 @@ struct SplashFeature {
|
|||||||
case checkAuthentication
|
case checkAuthentication
|
||||||
case authenticationChecked(UserInfoManager.AuthenticationStatus)
|
case authenticationChecked(UserInfoManager.AuthenticationStatus)
|
||||||
|
|
||||||
|
// 新增:用户信息获取 actions
|
||||||
|
case fetchUserInfo
|
||||||
|
case userInfoFetched(Bool)
|
||||||
|
|
||||||
// 新增:导航 actions
|
// 新增:导航 actions
|
||||||
case navigateToLogin
|
case navigateToLogin
|
||||||
case navigateToMain
|
case navigateToMain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Dependency(\.apiService) var apiService // 新增:API服务依赖
|
||||||
|
|
||||||
var body: some ReducerOf<Self> {
|
var body: some ReducerOf<Self> {
|
||||||
Reduce { state, action in
|
Reduce { state, action in
|
||||||
switch action {
|
switch action {
|
||||||
@@ -65,15 +71,31 @@ struct SplashFeature {
|
|||||||
state.isCheckingAuthentication = false
|
state.isCheckingAuthentication = false
|
||||||
state.authenticationStatus = status
|
state.authenticationStatus = status
|
||||||
|
|
||||||
// 根据认证状态决定导航目标
|
// 根据认证状态决定下一步操作
|
||||||
if status.canAutoLogin {
|
if status.canAutoLogin {
|
||||||
debugInfoSync("🎉 自动登录成功,进入主页")
|
debugInfoSync("🎉 自动登录成功,开始获取用户信息")
|
||||||
return .send(.navigateToMain)
|
// 新增:认证成功后自动获取用户信息
|
||||||
|
return .send(.fetchUserInfo)
|
||||||
} else {
|
} else {
|
||||||
debugInfoSync("🔑 需要手动登录")
|
debugInfoSync("🔑 需要手动登录")
|
||||||
return .send(.navigateToLogin)
|
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:
|
case .navigateToLogin:
|
||||||
state.navigationDestination = .login
|
state.navigationDestination = .login
|
||||||
return .none
|
return .none
|
||||||
|
@@ -2,11 +2,10 @@
|
|||||||
Localizable.strings
|
Localizable.strings
|
||||||
yana
|
yana
|
||||||
|
|
||||||
Created on 2024.
|
English localization file (auto-aligned)
|
||||||
英文本地化文件
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// MARK: - 登录界面
|
// MARK: - Login Screen
|
||||||
"login.id_login" = "ID Login";
|
"login.id_login" = "ID Login";
|
||||||
"login.email_login" = "Email Login";
|
"login.email_login" = "Email Login";
|
||||||
"login.app_title" = "E-PARTI";
|
"login.app_title" = "E-PARTI";
|
||||||
@@ -14,32 +13,32 @@
|
|||||||
"login.agreement" = "User Service Agreement";
|
"login.agreement" = "User Service Agreement";
|
||||||
"login.policy" = "Privacy Policy";
|
"login.policy" = "Privacy Policy";
|
||||||
|
|
||||||
// MARK: - 通用按钮
|
// MARK: - Common Buttons
|
||||||
"common.login" = "Login";
|
"common.login" = "Login";
|
||||||
"common.register" = "Register";
|
"common.register" = "Register";
|
||||||
"common.cancel" = "Cancel";
|
"common.cancel" = "Cancel";
|
||||||
"common.confirm" = "Confirm";
|
"common.confirm" = "Confirm";
|
||||||
"common.ok" = "OK";
|
"common.ok" = "OK";
|
||||||
|
|
||||||
// MARK: - 错误信息
|
// MARK: - Error Messages
|
||||||
"error.network" = "Network Error";
|
"error.network" = "Network Error";
|
||||||
"error.invalid_input" = "Invalid Input";
|
"error.invalid_input" = "Invalid Input";
|
||||||
"error.login_failed" = "Login Failed";
|
"error.login_failed" = "Login Failed";
|
||||||
|
|
||||||
// MARK: - 占位符文本
|
// MARK: - Placeholders
|
||||||
"placeholder.email" = "Enter your email";
|
"placeholder.email" = "Enter your email";
|
||||||
"placeholder.password" = "Enter your password";
|
"placeholder.password" = "Enter your password";
|
||||||
"placeholder.username" = "Enter your username";
|
"placeholder.username" = "Enter your username";
|
||||||
"placeholder.enter_id" = "Please enter ID";
|
"placeholder.enter_id" = "Please enter ID";
|
||||||
"placeholder.enter_password" = "Please enter password";
|
"placeholder.enter_password" = "Please enter password";
|
||||||
|
|
||||||
// MARK: - ID登录页面
|
// MARK: - ID Login Page
|
||||||
"id_login.title" = "ID Login";
|
"id_login.title" = "ID Login";
|
||||||
"id_login.forgot_password" = "Forgot Password?";
|
"id_login.forgot_password" = "Forgot Password?";
|
||||||
"id_login.login_button" = "Login";
|
"id_login.login_button" = "Login";
|
||||||
"id_login.logging_in" = "Logging in...";
|
"id_login.logging_in" = "Logging in...";
|
||||||
|
|
||||||
// MARK: - 邮箱登录页面
|
// MARK: - Email Login Page
|
||||||
"email_login.title" = "Email Login";
|
"email_login.title" = "Email Login";
|
||||||
"email_login.email_required" = "Please enter email";
|
"email_login.email_required" = "Please enter email";
|
||||||
"email_login.invalid_email" = "Please enter a valid email address";
|
"email_login.invalid_email" = "Please enter a valid email address";
|
||||||
@@ -52,13 +51,13 @@
|
|||||||
"placeholder.enter_email" = "Please enter email";
|
"placeholder.enter_email" = "Please enter email";
|
||||||
"placeholder.enter_verification_code" = "Please enter verification code";
|
"placeholder.enter_verification_code" = "Please enter verification code";
|
||||||
|
|
||||||
// MARK: - 验证和错误信息
|
// MARK: - Validation and Error Messages
|
||||||
"validation.id_required" = "Please enter your ID";
|
"validation.id_required" = "Please enter your ID";
|
||||||
"validation.password_required" = "Please enter your password";
|
"validation.password_required" = "Please enter your password";
|
||||||
"error.encryption_failed" = "Encryption failed, please try again";
|
"error.encryption_failed" = "Encryption failed, please try again";
|
||||||
"error.login_failed" = "Login failed, please check your credentials";
|
"error.login_failed" = "Login failed, please check your credentials";
|
||||||
|
|
||||||
// MARK: - 密码恢复页面
|
// MARK: - Password Recovery Page
|
||||||
"recover_password.title" = "Recover Password";
|
"recover_password.title" = "Recover Password";
|
||||||
"recover_password.placeholder_email" = "Please enter email";
|
"recover_password.placeholder_email" = "Please enter email";
|
||||||
"recover_password.placeholder_verification_code" = "Please enter verification code";
|
"recover_password.placeholder_verification_code" = "Please enter verification code";
|
||||||
@@ -74,5 +73,60 @@
|
|||||||
"recover_password.reset_success" = "Password reset successfully";
|
"recover_password.reset_success" = "Password reset successfully";
|
||||||
"recover_password.resetting" = "Resetting...";
|
"recover_password.resetting" = "Resetting...";
|
||||||
|
|
||||||
// MARK: - 主页
|
// MARK: - Home
|
||||||
"home.title" = "Enjoy your Life Time";
|
"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";
|
@@ -76,3 +76,53 @@
|
|||||||
|
|
||||||
// MARK: - 主页
|
// MARK: - 主页
|
||||||
"home.title" = "享受您的生活时光";
|
"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" = "退出账户";
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ComposableArchitecture
|
import QCloudCOSXML
|
||||||
|
|
||||||
// MARK: - 腾讯云 COS 管理器
|
// MARK: - 腾讯云 COS 管理器
|
||||||
|
|
||||||
@@ -15,6 +15,26 @@ class COSManager: ObservableObject {
|
|||||||
|
|
||||||
private init() {}
|
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 管理
|
// MARK: - Token 管理
|
||||||
|
|
||||||
/// 当前缓存的 Token 信息
|
/// 当前缓存的 Token 信息
|
||||||
@@ -102,13 +122,97 @@ class COSManager: ObservableObject {
|
|||||||
|
|
||||||
/// 获取当前 Token 状态信息
|
/// 获取当前 Token 状态信息
|
||||||
func getTokenStatus() -> String {
|
func getTokenStatus() -> String {
|
||||||
if let cached = cachedToken, let expiration = tokenExpirationDate {
|
if let _ = cachedToken, let expiration = tokenExpirationDate {
|
||||||
let isExpired = Date() >= expiration
|
let isExpired = Date() >= expiration
|
||||||
return "Token 状态: \(isExpired ? "已过期" : "有效"), 过期时间: \(expiration)"
|
return "Token 状态: \(isExpired ? "已过期" : "有效"), 过期时间: \(expiration)"
|
||||||
} else {
|
} else {
|
||||||
return "Token 状态: 未缓存"
|
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: - 调试扩展
|
// MARK: - 调试扩展
|
||||||
|
@@ -23,4 +23,28 @@ extension Color {
|
|||||||
let blue = Double(hex & 0xFF) / 255.0
|
let blue = Double(hex & 0xFF) / 255.0
|
||||||
self.init(red: red, green: green, blue: blue, opacity: alpha)
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
258
yana/Views/AppSettingView.swift
Normal file
258
yana/Views/AppSettingView.swift
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ComposableArchitecture
|
||||||
|
|
||||||
|
struct AppSettingView: View {
|
||||||
|
let store: StoreOf<AppSettingFeature>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||||
|
ZStack {
|
||||||
|
Color(red: 22/255, green: 17/255, blue: 44/255).ignoresSafeArea()
|
||||||
|
// VStack(spacing: 0) {
|
||||||
|
// 主要内容
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// 头像区域
|
||||||
|
avatarSection(viewStore: viewStore)
|
||||||
|
|
||||||
|
// 昵称设置项
|
||||||
|
nicknameSection(viewStore: viewStore)
|
||||||
|
|
||||||
|
// 其他设置项
|
||||||
|
settingsSection(viewStore: viewStore)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 底部大按钮
|
||||||
|
logoutButton(viewStore: viewStore)
|
||||||
|
}
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
.navigationTitle(NSLocalizedString("appSetting.title", comment: "Edit"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button(action: {}) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 24, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
viewStore.send(.onAppear)
|
||||||
|
}
|
||||||
|
.webView(
|
||||||
|
isPresented: userAgreementBinding(viewStore: viewStore),
|
||||||
|
url: APIConfiguration.webURL(for: .userAgreement)
|
||||||
|
)
|
||||||
|
.webView(
|
||||||
|
isPresented: privacyPolicyBinding(viewStore: viewStore),
|
||||||
|
url: APIConfiguration.webURL(for: .privacyPolicy)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - 头像区域
|
||||||
|
private func avatarSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
avatarImageView(viewStore: viewStore)
|
||||||
|
cameraButton
|
||||||
|
}
|
||||||
|
.padding(.top, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 头像图片视图
|
||||||
|
@ViewBuilder
|
||||||
|
private func avatarImageView(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
|
if viewStore.isLoadingUserInfo {
|
||||||
|
loadingAvatarView
|
||||||
|
} else if let avatarURLString = viewStore.avatarURL, !avatarURLString.isEmpty, let avatarURL = URL(string: avatarURLString) {
|
||||||
|
networkAvatarView(url: avatarURL)
|
||||||
|
} else {
|
||||||
|
defaultAvatarView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 加载状态头像
|
||||||
|
private var loadingAvatarView: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
.overlay(
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.scaleEffect(1.2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 网络头像
|
||||||
|
private func networkAvatarView(url: URL) -> some View {
|
||||||
|
CachedAsyncImage(url: url.absoluteString) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
defaultAvatarView
|
||||||
|
}
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 默认头像
|
||||||
|
private var defaultAvatarView: some View {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
.overlay(
|
||||||
|
Image(systemName: "person.fill")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 相机按钮
|
||||||
|
private var cameraButton: some View {
|
||||||
|
Button(action: {}) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Color.purple).frame(width: 36, height: 36)
|
||||||
|
Image(systemName: "camera.fill")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.offset(x: 8, y: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 昵称设置项
|
||||||
|
private func nicknameSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text(NSLocalizedString("appSetting.nickname", comment: "Nickname"))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
Text(viewStore.nickname)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.onTapGesture {
|
||||||
|
viewStore.send(.editNicknameTapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().background(Color.gray.opacity(0.3))
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 设置项区域
|
||||||
|
private func settingsSection(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
personalInfoPermissionsRow(viewStore: viewStore)
|
||||||
|
helpRow(viewStore: viewStore)
|
||||||
|
clearCacheRow(viewStore: viewStore)
|
||||||
|
checkUpdatesRow(viewStore: viewStore)
|
||||||
|
aboutUsRow(viewStore: viewStore)
|
||||||
|
}
|
||||||
|
.background(Color.clear)
|
||||||
|
.padding(.horizontal, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 个人信息权限行
|
||||||
|
private func personalInfoPermissionsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
|
settingRow(
|
||||||
|
title: NSLocalizedString("appSetting.personalInfoPermissions", comment: "Personal Information and Permissions"),
|
||||||
|
action: { viewStore.send(.personalInfoPermissionsTapped) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 帮助行
|
||||||
|
private func helpRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
|
settingRow(
|
||||||
|
title: NSLocalizedString("appSetting.help", comment: "Help"),
|
||||||
|
action: { viewStore.send(.helpTapped) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 清除缓存行
|
||||||
|
private func clearCacheRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
|
settingRow(
|
||||||
|
title: NSLocalizedString("appSetting.clearCache", comment: "Clear Cache"),
|
||||||
|
action: { viewStore.send(.clearCacheTapped) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 检查更新行
|
||||||
|
private func checkUpdatesRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
|
settingRow(
|
||||||
|
title: NSLocalizedString("appSetting.checkUpdates", comment: "Check for Updates"),
|
||||||
|
action: { viewStore.send(.checkUpdatesTapped) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 关于我们行
|
||||||
|
private func aboutUsRow(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
|
settingRow(
|
||||||
|
title: NSLocalizedString("appSetting.aboutUs", comment: "About Us"),
|
||||||
|
action: { viewStore.send(.aboutUsTapped) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 设置项行
|
||||||
|
private func settingRow(title: String, action: @escaping () -> Void) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.onTapGesture {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().background(Color.gray.opacity(0.3))
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 退出登录按钮
|
||||||
|
private func logoutButton(viewStore: ViewStoreOf<AppSettingFeature>) -> some View {
|
||||||
|
Button(action: {
|
||||||
|
viewStore.send(.logoutTapped)
|
||||||
|
}) {
|
||||||
|
Text(NSLocalizedString("appSetting.logoutAccount", comment: "Log out of account"))
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.background(Color.white.opacity(0.08))
|
||||||
|
.cornerRadius(28)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 用户协议绑定
|
||||||
|
private func userAgreementBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
|
||||||
|
viewStore.binding(
|
||||||
|
get: \.showUserAgreement,
|
||||||
|
send: AppSettingFeature.Action.userAgreementDismissed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 隐私政策绑定
|
||||||
|
private func privacyPolicyBinding(viewStore: ViewStoreOf<AppSettingFeature>) -> Binding<Bool> {
|
||||||
|
viewStore.binding(
|
||||||
|
get: \.showPrivacyPolicy,
|
||||||
|
send: AppSettingFeature.Action.privacyPolicyDismissed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
294
yana/Views/Components/OptimizedDynamicCardView.swift
Normal file
294
yana/Views/Components/OptimizedDynamicCardView.swift
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ComposableArchitecture
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - 优化的动态卡片组件
|
||||||
|
struct OptimizedDynamicCardView: View {
|
||||||
|
let moment: MomentsInfo
|
||||||
|
let allMoments: [MomentsInfo]
|
||||||
|
let currentIndex: Int
|
||||||
|
|
||||||
|
// 预览相关状态
|
||||||
|
@State private var showPreview = false
|
||||||
|
@State private var previewImageUrls: [String] = []
|
||||||
|
@State private var previewIndex: Int = 0
|
||||||
|
|
||||||
|
init(moment: MomentsInfo, allMoments: [MomentsInfo], currentIndex: Int) {
|
||||||
|
self.moment = moment
|
||||||
|
self.allMoments = allMoments
|
||||||
|
self.currentIndex = currentIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// 用户信息
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
// 头像
|
||||||
|
CachedAsyncImage(url: moment.avatar) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.overlay(
|
||||||
|
Text(String(moment.nick.prefix(1)))
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(moment.nick)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Text("ID: \(moment.uid)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
// 时间(原VIP位置)
|
||||||
|
Text(formatDisplayTime(moment.publishTime))
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.white.opacity(0.15))
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态内容
|
||||||
|
if !moment.content.isEmpty {
|
||||||
|
Text(moment.content)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.9))
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.padding(.leading, 40 + 8) // 与用户名左边对齐
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化的图片网格
|
||||||
|
if let images = moment.dynamicResList, !images.isEmpty {
|
||||||
|
OptimizedImageGrid(images: images) { tappedIndex in
|
||||||
|
previewImageUrls = images.map { $0.resUrl ?? "" }
|
||||||
|
previewIndex = tappedIndex
|
||||||
|
showPreview = true
|
||||||
|
}
|
||||||
|
.padding(.bottom, images.count == 2 ? 16 : 0) // 两张图片时增加底部间距
|
||||||
|
}
|
||||||
|
|
||||||
|
// 互动按钮
|
||||||
|
HStack(spacing: 20) {
|
||||||
|
// Like 按钮左对齐
|
||||||
|
Button(action: {}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
Text("\(moment.likeCount)")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
}
|
||||||
|
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(
|
||||||
|
Color.white.opacity(0.1)
|
||||||
|
.cornerRadius(12)
|
||||||
|
)
|
||||||
|
.onAppear {
|
||||||
|
preloadNearbyImages()
|
||||||
|
}
|
||||||
|
// 图片预览弹窗
|
||||||
|
.fullScreenCover(isPresented: $showPreview) {
|
||||||
|
ImagePreviewPager(images: previewImageUrls, currentIndex: $previewIndex) {
|
||||||
|
showPreview = false
|
||||||
|
previewImageUrls = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(_ timestamp: Int) -> String {
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
let interval = now.timeIntervalSince(date)
|
||||||
|
|
||||||
|
if interval < 60 {
|
||||||
|
return "刚刚"
|
||||||
|
} else if interval < 3600 {
|
||||||
|
return "\(Int(interval / 60))分钟前"
|
||||||
|
} else if interval < 86400 {
|
||||||
|
return "\(Int(interval / 3600))小时前"
|
||||||
|
} else {
|
||||||
|
formatter.dateFormat = "MM-dd HH:mm"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:时间显示逻辑
|
||||||
|
private func formatDisplayTime(_ timestamp: Int) -> String {
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
let now = Date()
|
||||||
|
let interval = now.timeIntervalSince(date)
|
||||||
|
let calendar = Calendar.current
|
||||||
|
if calendar.isDateInToday(date) {
|
||||||
|
if interval < 60 {
|
||||||
|
return "刚刚"
|
||||||
|
} else if interval < 3600 {
|
||||||
|
return "\(Int(interval / 60))分钟前"
|
||||||
|
} else {
|
||||||
|
return "\(Int(interval / 3600))小时前"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formatter.dateFormat = "MM/dd"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preloadNearbyImages() {
|
||||||
|
var urlsToPreload: [String] = []
|
||||||
|
let preloadRange = max(0, currentIndex - 2)...min(allMoments.count - 1, currentIndex + 2)
|
||||||
|
for index in preloadRange {
|
||||||
|
let moment = allMoments[index]
|
||||||
|
urlsToPreload.append(moment.avatar)
|
||||||
|
if let images = moment.dynamicResList {
|
||||||
|
urlsToPreload.append(contentsOf: images.compactMap { $0.resUrl })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除批量下载UIImage逻辑,直接用URL数组
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 优化的图片网格
|
||||||
|
struct OptimizedImageGrid: View {
|
||||||
|
let images: [MomentsPicture]
|
||||||
|
let onImageTap: (Int) -> Void
|
||||||
|
|
||||||
|
init(images: [MomentsPicture], onImageTap: @escaping (Int) -> Void) {
|
||||||
|
self.images = images
|
||||||
|
self.onImageTap = onImageTap
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let availableWidth = max(geometry.size.width, 1)
|
||||||
|
let spacing: CGFloat = 8
|
||||||
|
if availableWidth < 10 {
|
||||||
|
Color.clear.frame(height: 1)
|
||||||
|
} else {
|
||||||
|
switch images.count {
|
||||||
|
case 1:
|
||||||
|
let imageSize: CGFloat = min(availableWidth * 0.6, 200)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
SquareImageView(image: images[0], size: imageSize) {
|
||||||
|
onImageTap(0)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
let imageSize: CGFloat = (availableWidth - spacing) / 2
|
||||||
|
HStack(spacing: spacing) {
|
||||||
|
SquareImageView(image: images[0], size: imageSize) {
|
||||||
|
onImageTap(0)
|
||||||
|
}
|
||||||
|
SquareImageView(image: images[1], size: imageSize) {
|
||||||
|
onImageTap(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||||
|
HStack(spacing: spacing) {
|
||||||
|
ForEach(Array(images.prefix(3).enumerated()), id: \ .element.id) { idx, image in
|
||||||
|
SquareImageView(image: image, size: imageSize) {
|
||||||
|
onImageTap(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||||
|
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
|
||||||
|
LazyVGrid(columns: columns, spacing: spacing) {
|
||||||
|
ForEach(Array(images.prefix(9).enumerated()), id: \ .element.id) { idx, image in
|
||||||
|
SquareImageView(image: image, size: imageSize) {
|
||||||
|
onImageTap(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: calculateGridHeight())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateGridHeight() -> CGFloat {
|
||||||
|
switch images.count {
|
||||||
|
case 1:
|
||||||
|
return 200
|
||||||
|
case 2:
|
||||||
|
return 120
|
||||||
|
case 3:
|
||||||
|
return 100
|
||||||
|
case 4...6:
|
||||||
|
return 216
|
||||||
|
default:
|
||||||
|
return 340
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 正方形图片视图组件
|
||||||
|
struct SquareImageView: View {
|
||||||
|
let image: MomentsPicture
|
||||||
|
let size: CGFloat
|
||||||
|
let onTap: (() -> Void)?
|
||||||
|
|
||||||
|
init(image: MomentsPicture, size: CGFloat, onTap: (() -> Void)? = nil) {
|
||||||
|
self.image = image
|
||||||
|
self.size = size
|
||||||
|
self.onTap = onTap
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
let safeSize = size.isFinite && size > 0 ? size : 100
|
||||||
|
Group {
|
||||||
|
if let onTap = onTap {
|
||||||
|
Button(action: onTap) {
|
||||||
|
imageContent
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
} else {
|
||||||
|
imageContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: safeSize, height: safeSize)
|
||||||
|
.clipped()
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var imageContent: some View {
|
||||||
|
CachedAsyncImage(url: image.resUrl ?? "") { imageView in
|
||||||
|
imageView
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.overlay(
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
yana/Views/Components/View+isHidden.swift
Normal file
12
yana/Views/Components/View+isHidden.swift
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func isHidden(_ hidden: Bool) -> some View {
|
||||||
|
if hidden {
|
||||||
|
self.opacity(0).allowsHitTesting(false)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -25,7 +25,7 @@ struct CreateFeedView: View {
|
|||||||
.frame(height: 200) // 高度固定为200
|
.frame(height: 200) // 高度固定为200
|
||||||
|
|
||||||
if store.content.isEmpty {
|
if store.content.isEmpty {
|
||||||
Text("Enter Content")
|
Text(NSLocalizedString("createFeed.enterContent", comment: "Enter Content"))
|
||||||
.foregroundColor(.white.opacity(0.5))
|
.foregroundColor(.white.opacity(0.5))
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
@@ -79,7 +79,7 @@ struct CreateFeedView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
Text("处理图片中...")
|
Text(NSLocalizedString("createFeed.processingImages", comment: "Processing images..."))
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundColor(.white.opacity(0.8))
|
.foregroundColor(.white.opacity(0.8))
|
||||||
}
|
}
|
||||||
@@ -111,11 +111,11 @@ struct CreateFeedView: View {
|
|||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
.scaleEffect(0.8)
|
.scaleEffect(0.8)
|
||||||
Text("发布中...")
|
Text(NSLocalizedString("createFeed.publishing", comment: "Publishing..."))
|
||||||
.font(.system(size: 16, weight: .medium))
|
.font(.system(size: 16, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
} else {
|
} else {
|
||||||
Text("发布")
|
Text(NSLocalizedString("createFeed.publish", comment: "Publish"))
|
||||||
.font(.system(size: 16, weight: .medium))
|
.font(.system(size: 16, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ struct CreateFeedView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("图文发布")
|
.navigationTitle(NSLocalizedString("createFeed.title", comment: "Image & Text Publish"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbarBackground(.hidden, for: .navigationBar)
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
@@ -168,66 +168,66 @@ struct CreateFeedView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - iOS 16+ 图片选择网格组件
|
// MARK: - iOS 16+ 图片选择网格组件
|
||||||
struct ModernImageSelectionGrid: View {
|
//struct ModernImageSelectionGrid: View {
|
||||||
let images: [UIImage]
|
// let images: [UIImage]
|
||||||
let selectedItems: [PhotosPickerItem]
|
// let selectedItems: [PhotosPickerItem]
|
||||||
let canAddMore: Bool
|
// let canAddMore: Bool
|
||||||
let onItemsChanged: ([PhotosPickerItem]) -> Void
|
// let onItemsChanged: ([PhotosPickerItem]) -> Void
|
||||||
let onRemoveImage: (Int) -> Void
|
// let onRemoveImage: (Int) -> Void
|
||||||
|
//
|
||||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
|
// private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
|
||||||
|
//
|
||||||
var body: some View {
|
// var body: some View {
|
||||||
WithPerceptionTracking {
|
// WithPerceptionTracking {
|
||||||
LazyVGrid(columns: columns, spacing: 8) {
|
// LazyVGrid(columns: columns, spacing: 8) {
|
||||||
// 显示已选择的图片
|
// // 显示已选择的图片
|
||||||
ForEach(Array(images.enumerated()), id: \.offset) { index, image in
|
// ForEach(Array(images.enumerated()), id: \.offset) { index, image in
|
||||||
ZStack(alignment: .topTrailing) {
|
// ZStack(alignment: .topTrailing) {
|
||||||
Image(uiImage: image)
|
// Image(uiImage: image)
|
||||||
.resizable()
|
// .resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
// .aspectRatio(contentMode: .fill)
|
||||||
.frame(height: 100)
|
// .frame(height: 100)
|
||||||
.clipped()
|
// .clipped()
|
||||||
.cornerRadius(8)
|
// .cornerRadius(8)
|
||||||
|
//
|
||||||
// 删除按钮
|
// // 删除按钮
|
||||||
Button(action: {
|
// Button(action: {
|
||||||
onRemoveImage(index)
|
// onRemoveImage(index)
|
||||||
}) {
|
// }) {
|
||||||
Image(systemName: "xmark.circle.fill")
|
// Image(systemName: "xmark.circle.fill")
|
||||||
.font(.system(size: 20))
|
// .font(.system(size: 20))
|
||||||
.foregroundColor(.white)
|
// .foregroundColor(.white)
|
||||||
.background(Color.black.opacity(0.6))
|
// .background(Color.black.opacity(0.6))
|
||||||
.clipShape(Circle())
|
// .clipShape(Circle())
|
||||||
}
|
// }
|
||||||
.padding(4)
|
// .padding(4)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// 添加图片按钮
|
// // 添加图片按钮
|
||||||
if canAddMore {
|
// if canAddMore {
|
||||||
PhotosPicker(
|
// PhotosPicker(
|
||||||
selection: .init(
|
// selection: .init(
|
||||||
get: { selectedItems },
|
// get: { selectedItems },
|
||||||
set: onItemsChanged
|
// set: onItemsChanged
|
||||||
),
|
// ),
|
||||||
maxSelectionCount: 9,
|
// maxSelectionCount: 9,
|
||||||
matching: .images
|
// matching: .images
|
||||||
) {
|
// ) {
|
||||||
RoundedRectangle(cornerRadius: 8)
|
// RoundedRectangle(cornerRadius: 8)
|
||||||
.fill(Color.white.opacity(0.1))
|
// .fill(Color.white.opacity(0.1))
|
||||||
.frame(height: 100)
|
// .frame(height: 100)
|
||||||
.overlay(
|
// .overlay(
|
||||||
Image(systemName: "plus")
|
// Image(systemName: "plus")
|
||||||
.font(.system(size: 40))
|
// .font(.system(size: 40))
|
||||||
.foregroundColor(.white.opacity(0.6))
|
// .foregroundColor(.white.opacity(0.6))
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
// MARK: - 预览
|
// MARK: - 预览
|
||||||
//#Preview {
|
//#Preview {
|
||||||
|
@@ -1,19 +1,299 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ComposableArchitecture
|
||||||
|
import PhotosUI
|
||||||
|
//import ImagePreviewPager
|
||||||
|
|
||||||
struct EditFeedView: View {
|
struct EditFeedView: View {
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
let store: StoreOf<EditFeedFeature>
|
||||||
|
@State private var isKeyboardVisible = false
|
||||||
|
private let maxCount = 500
|
||||||
|
|
||||||
|
private func hideKeyboard() {
|
||||||
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
WithPerceptionTracking {
|
||||||
Text("编辑动态")
|
GeometryReader { geometry in
|
||||||
.font(.title)
|
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||||
.bold()
|
WithPerceptionTracking {
|
||||||
Text("这里是 EditFeedView 占位内容")
|
ZStack {
|
||||||
.foregroundColor(.gray)
|
backgroundView
|
||||||
Spacer()
|
mainContent(geometry: geometry, viewStore: viewStore)
|
||||||
|
if viewStore.isUploadingImages {
|
||||||
|
uploadingImagesOverlay(progress: viewStore.imageUploadProgress)
|
||||||
|
} else if viewStore.isLoading {
|
||||||
|
loadingOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
if isKeyboardVisible {
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
isKeyboardVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
isKeyboardVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewStore.errorMessage) { error in
|
||||||
|
if error != nil {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||||
|
viewStore.send(.clearError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: viewStore.shouldDismiss) { shouldDismiss in
|
||||||
|
if shouldDismiss {
|
||||||
|
onDismiss()
|
||||||
|
NotificationCenter.default.post(name: Notification.Name("reloadFeedList"), object: nil)
|
||||||
|
viewStore.send(.clearDismissFlag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundView: some View {
|
||||||
|
Color(hexString: "0C0527")
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mainContent(geometry: GeometryProxy, viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerView(geometry: geometry, viewStore: viewStore)
|
||||||
|
textInputArea(viewStore: viewStore)
|
||||||
|
// 新增:图片输入区域
|
||||||
|
ModernImageSelectionGrid(
|
||||||
|
images: viewStore.processedImages,
|
||||||
|
selectedItems: viewStore.selectedImages,
|
||||||
|
canAddMore: viewStore.canAddMoreImages,
|
||||||
|
onItemsChanged: { items in
|
||||||
|
viewStore.send(.photosPickerItemsChanged(items))
|
||||||
|
},
|
||||||
|
onRemoveImage: { index in
|
||||||
|
viewStore.send(.removeImage(index))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
Spacer()
|
||||||
|
if !isKeyboardVisible {
|
||||||
|
publishButtonBottom(viewStore: viewStore, geometry: geometry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func headerView(geometry: GeometryProxy, viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(NSLocalizedString("editFeed.title", comment: "Image & Text Edit"))
|
||||||
|
.font(.system(size: 20, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Spacer()
|
||||||
|
if isKeyboardVisible {
|
||||||
|
WithPerceptionTracking {
|
||||||
|
Button(action: {
|
||||||
|
hideKeyboard()
|
||||||
|
viewStore.send(.publishButtonTapped)
|
||||||
|
}) {
|
||||||
|
Text(NSLocalizedString("editFeed.publish", comment: "Publish"))
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(hexString: "A14AC6"),
|
||||||
|
Color(hexString: "3B1EEB")
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
.cornerRadius(16)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.disabled(!viewStore.canPublish)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, geometry.safeAreaInsets.top + 16)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func textInputArea(viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.fill(Color(hexString: "1C143A"))
|
||||||
|
TextEditor(text: Binding(
|
||||||
|
get: { viewStore.content },
|
||||||
|
set: { viewStore.send(.contentChanged($0)) }
|
||||||
|
))
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.padding(16)
|
||||||
|
.frame(height: 160)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.background(.clear)
|
||||||
|
.cornerRadius(20)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
if viewStore.content.isEmpty {
|
||||||
|
Text(NSLocalizedString("editFeed.enterContent", comment: "Enter Content"))
|
||||||
|
.foregroundColor(Color.white.opacity(0.4))
|
||||||
|
.padding(20)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
}
|
||||||
|
WithPerceptionTracking {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text("\(viewStore.content.count)/\(maxCount)")
|
||||||
|
.foregroundColor(Color.white.opacity(0.4))
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 160)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publishButtonBottom(viewStore: ViewStoreOf<EditFeedFeature>, geometry: GeometryProxy) -> some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
Button(action: {
|
||||||
|
hideKeyboard()
|
||||||
|
viewStore.send(.publishButtonTapped)
|
||||||
|
}) {
|
||||||
|
Text(NSLocalizedString("editFeed.publish", comment: "Publish"))
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color(hexString: "A14AC6"), Color(hexString: "3B1EEB")],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
.cornerRadius(28)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.bottom, geometry.safeAreaInsets.bottom + 24)
|
||||||
|
.disabled(!viewStore.canPublish || viewStore.isUploadingImages || viewStore.isLoading)
|
||||||
|
.opacity(viewStore.canPublish ? 1.0 : 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadingOverlay: some View {
|
||||||
|
Group {
|
||||||
|
Color.black.opacity(0.3)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.scaleEffect(1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:图片上传进度遮罩
|
||||||
|
private func uploadingImagesOverlay(progress: Double) -> some View {
|
||||||
|
Group {
|
||||||
|
Color.black.opacity(0.3)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView(value: progress)
|
||||||
|
.progressViewStyle(LinearProgressViewStyle(tint: .white))
|
||||||
|
.frame(width: 180)
|
||||||
|
Text("正在上传图片...\(Int(progress * 100))%")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
//#Preview {
|
||||||
EditFeedView()
|
// EditFeedView()
|
||||||
|
//}
|
||||||
|
|
||||||
|
// MARK: - 九宫格图片选择组件
|
||||||
|
struct ModernImageSelectionGrid: View {
|
||||||
|
let images: [UIImage]
|
||||||
|
let selectedItems: [PhotosPickerItem]
|
||||||
|
let canAddMore: Bool
|
||||||
|
let onItemsChanged: ([PhotosPickerItem]) -> Void
|
||||||
|
let onRemoveImage: (Int) -> Void
|
||||||
|
private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
|
||||||
|
@State private var showPreview = false
|
||||||
|
@State private var previewIndex = 0
|
||||||
|
var body: some View {
|
||||||
|
let totalSpacing: CGFloat = 8 * 2
|
||||||
|
let totalWidth = UIScreen.main.bounds.width - 24 * 2 - totalSpacing
|
||||||
|
let gridItemSize: CGFloat = totalWidth / 3
|
||||||
|
WithPerceptionTracking {
|
||||||
|
LazyVGrid(columns: columns, spacing: 8) {
|
||||||
|
ForEach(Array(images.enumerated()), id: \.offset) { index, image in
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill) // aspectFill
|
||||||
|
.frame(width: gridItemSize, height: gridItemSize)
|
||||||
|
.clipped()
|
||||||
|
.cornerRadius(12)
|
||||||
|
.onTapGesture {
|
||||||
|
previewIndex = index
|
||||||
|
showPreview = true
|
||||||
|
}
|
||||||
|
Button(action: {
|
||||||
|
onRemoveImage(index)
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 20))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.background(Color.black.opacity(0.6))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if canAddMore {
|
||||||
|
PhotosPicker(
|
||||||
|
selection: .init(
|
||||||
|
get: { selectedItems },
|
||||||
|
set: { items in DispatchQueue.main.async { onItemsChanged(items) } }
|
||||||
|
),
|
||||||
|
maxSelectionCount: 9 - images.count,
|
||||||
|
matching: .images
|
||||||
|
) {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(hexString: "1C143A"))
|
||||||
|
.frame(width: gridItemSize, height: gridItemSize)
|
||||||
|
.overlay(
|
||||||
|
Image("add photo")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.opacity(0.6)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullScreenCover(isPresented: $showPreview) {
|
||||||
|
ImagePreviewPager(images: images, currentIndex: $previewIndex, onClose: { showPreview = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposableArchitecture
|
import ComposableArchitecture
|
||||||
|
//import OptimizedDynamicCardView // 导入新组件
|
||||||
|
|
||||||
struct FeedListView: View {
|
struct FeedListView: View {
|
||||||
let store: StoreOf<FeedListFeature>
|
let store: StoreOf<FeedListFeature>
|
||||||
@@ -20,7 +21,7 @@ struct FeedListView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text("Enjoy your Life Time")
|
Text(NSLocalizedString("feedList.title", comment: "Enjoy your Life Time"))
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
@@ -40,12 +41,58 @@ struct FeedListView: View {
|
|||||||
.font(.system(size: 60))
|
.font(.system(size: 60))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.padding(.top, 40)
|
.padding(.top, 40)
|
||||||
Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.")
|
Text(NSLocalizedString("feedList.slogan", comment: "The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable."))
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 16))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(.white.opacity(0.9))
|
.foregroundColor(.white.opacity(0.9))
|
||||||
.padding(.horizontal, 30)
|
.padding(.horizontal, 30)
|
||||||
.padding(.bottom, 30)
|
.padding(.bottom, 30)
|
||||||
|
// 新增:动态内容列表
|
||||||
|
if viewStore.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.padding(.top, 20)
|
||||||
|
} else if let error = viewStore.error {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 20)
|
||||||
|
} else if viewStore.moments.isEmpty {
|
||||||
|
Text(NSLocalizedString("feedList.empty", comment: "暂无动态"))
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
.padding(.top, 20)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 16) {
|
||||||
|
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
|
||||||
|
OptimizedDynamicCardView(moment: moment, allMoments: viewStore.moments, currentIndex: index)
|
||||||
|
// 上拉加载更多触发点
|
||||||
|
if index == viewStore.moments.count - 1, viewStore.hasMore, !viewStore.isLoadingMore {
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 1)
|
||||||
|
.onAppear {
|
||||||
|
viewStore.send(.loadMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 加载更多指示器
|
||||||
|
if viewStore.isLoadingMore {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewStore.send(.reload)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .top)
|
.frame(maxWidth: .infinity, alignment: .top)
|
||||||
@@ -54,11 +101,23 @@ struct FeedListView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
viewStore.send(.onAppear)
|
viewStore.send(.onAppear)
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("reloadFeedList"))) { _ in
|
||||||
|
viewStore.send(.reload)
|
||||||
|
}
|
||||||
.sheet(isPresented: viewStore.binding(
|
.sheet(isPresented: viewStore.binding(
|
||||||
get: \.isEditFeedPresented,
|
get: \.isEditFeedPresented,
|
||||||
send: { $0 ? .editFeedButtonTapped : .editFeedDismissed }
|
send: { $0 ? .editFeedButtonTapped : .editFeedDismissed }
|
||||||
)) {
|
)) {
|
||||||
EditFeedView()
|
EditFeedView(
|
||||||
|
onDismiss: {
|
||||||
|
viewStore.send(.editFeedDismissed)
|
||||||
|
},
|
||||||
|
store: Store(
|
||||||
|
initialState: EditFeedFeature.State()
|
||||||
|
) {
|
||||||
|
EditFeedFeature()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -8,12 +8,12 @@ struct FeedTopBarView: View {
|
|||||||
WithPerceptionTracking {
|
WithPerceptionTracking {
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("Enjoy your Life Time")
|
Text(NSLocalizedString("feed.title", comment: "Enjoy your Life Time"))
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {
|
Button(action: {
|
||||||
onShowCreateFeed() // 只调用回调
|
// showEditFeed = true // 显示编辑界面
|
||||||
}) {
|
}) {
|
||||||
Image("add icon")
|
Image("add icon")
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
@@ -34,11 +34,11 @@ struct FeedMomentsListView: View {
|
|||||||
Image(systemName: "heart.text.square")
|
Image(systemName: "heart.text.square")
|
||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
.foregroundColor(.white.opacity(0.6))
|
.foregroundColor(.white.opacity(0.6))
|
||||||
Text("暂无动态内容")
|
Text(NSLocalizedString("feed.empty", comment: "No moments yet"))
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 16))
|
||||||
.foregroundColor(.white.opacity(0.8))
|
.foregroundColor(.white.opacity(0.8))
|
||||||
if let error = store.error {
|
if let error = store.error {
|
||||||
Text("错误: \(error)")
|
Text(String(format: NSLocalizedString("feed.error", comment: "Error: %@"), error))
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.red.opacity(0.8))
|
.foregroundColor(.red.opacity(0.8))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -50,7 +50,7 @@ struct FeedMomentsListView: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
store.send(.retryLoad)
|
store.send(.retryLoad)
|
||||||
}) {
|
}) {
|
||||||
Text("重试")
|
Text(NSLocalizedString("feed.retry", comment: "Retry"))
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -85,7 +85,7 @@ struct FeedMomentsListView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
Text("加载更多...")
|
Text(NSLocalizedString("feed.loadingMore", comment: "Loading more..."))
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundColor(.white.opacity(0.8))
|
.foregroundColor(.white.opacity(0.8))
|
||||||
}
|
}
|
||||||
@@ -102,6 +102,7 @@ struct FeedMomentsListView: View {
|
|||||||
struct FeedView: View {
|
struct FeedView: View {
|
||||||
let store: StoreOf<FeedFeature>
|
let store: StoreOf<FeedFeature>
|
||||||
let onShowCreateFeed: () -> Void
|
let onShowCreateFeed: () -> Void
|
||||||
|
@State private var showEditFeed = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WithPerceptionTracking {
|
WithPerceptionTracking {
|
||||||
@@ -154,465 +155,477 @@ struct FeedView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
store.send(.onAppear)
|
store.send(.onAppear)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showEditFeed) {
|
||||||
|
EditFeedView(
|
||||||
|
onDismiss: {
|
||||||
|
showEditFeed = false
|
||||||
|
},
|
||||||
|
store: Store(
|
||||||
|
initialState: EditFeedFeature.State()
|
||||||
|
) {
|
||||||
|
EditFeedFeature()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 优化的动态卡片组件
|
// MARK: - 优化的动态卡片组件
|
||||||
struct OptimizedDynamicCardView: View {
|
//struct OptimizedDynamicCardView: View {
|
||||||
let moment: MomentsInfo
|
// let moment: MomentsInfo
|
||||||
let allMoments: [MomentsInfo]
|
// let allMoments: [MomentsInfo]
|
||||||
let currentIndex: Int
|
// let currentIndex: Int
|
||||||
|
//
|
||||||
var body: some View {
|
// var body: some View {
|
||||||
WithPerceptionTracking{
|
// WithPerceptionTracking{
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
// VStack(alignment: .leading, spacing: 12) {
|
||||||
// 用户信息
|
// // 用户信息
|
||||||
HStack {
|
// HStack {
|
||||||
// 使用缓存的头像
|
// // 使用缓存的头像
|
||||||
CachedAsyncImage(url: moment.avatar) { image in
|
// CachedAsyncImage(url: moment.avatar) { image in
|
||||||
image
|
// image
|
||||||
.resizable()
|
// .resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
// .aspectRatio(contentMode: .fill)
|
||||||
} placeholder: {
|
// } placeholder: {
|
||||||
Circle()
|
// Circle()
|
||||||
.fill(Color.gray.opacity(0.3))
|
// .fill(Color.gray.opacity(0.3))
|
||||||
.overlay(
|
// .overlay(
|
||||||
Text(String(moment.nick.prefix(1)))
|
// Text(String(moment.nick.prefix(1)))
|
||||||
.font(.system(size: 16, weight: .medium))
|
// .font(.system(size: 16, weight: .medium))
|
||||||
.foregroundColor(.white)
|
// .foregroundColor(.white)
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
.frame(width: 40, height: 40)
|
// .frame(width: 40, height: 40)
|
||||||
.clipShape(Circle())
|
// .clipShape(Circle())
|
||||||
|
//
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
// VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(moment.nick)
|
// Text(moment.nick)
|
||||||
.font(.system(size: 16, weight: .medium))
|
// .font(.system(size: 16, weight: .medium))
|
||||||
.foregroundColor(.white)
|
// .foregroundColor(.white)
|
||||||
|
//
|
||||||
Text(formatTime(moment.publishTime))
|
// Text(formatTime(moment.publishTime))
|
||||||
.font(.system(size: 12))
|
// .font(.system(size: 12))
|
||||||
.foregroundColor(.white.opacity(0.6))
|
// .foregroundColor(.white.opacity(0.6))
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
Spacer()
|
// Spacer()
|
||||||
|
//
|
||||||
// VIP 标识
|
// // VIP 标识
|
||||||
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
// if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
||||||
Text("VIP\(vipLevel)")
|
// Text("VIP\(vipLevel)")
|
||||||
.font(.system(size: 10, weight: .bold))
|
// .font(.system(size: 10, weight: .bold))
|
||||||
.foregroundColor(.yellow)
|
// .foregroundColor(.yellow)
|
||||||
.padding(.horizontal, 6)
|
// .padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
// .padding(.vertical, 2)
|
||||||
.background(Color.yellow.opacity(0.2))
|
// .background(Color.yellow.opacity(0.2))
|
||||||
.cornerRadius(4)
|
// .cornerRadius(4)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// 动态内容
|
// // 动态内容
|
||||||
if !moment.content.isEmpty {
|
// if !moment.content.isEmpty {
|
||||||
Text(moment.content)
|
// Text(moment.content)
|
||||||
.font(.system(size: 14))
|
// .font(.system(size: 14))
|
||||||
.foregroundColor(.white.opacity(0.9))
|
// .foregroundColor(.white.opacity(0.9))
|
||||||
.multilineTextAlignment(.leading)
|
// .multilineTextAlignment(.leading)
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// 优化的图片网格
|
// // 优化的图片网格
|
||||||
if let images = moment.dynamicResList, !images.isEmpty {
|
// if let images = moment.dynamicResList, !images.isEmpty {
|
||||||
OptimizedImageGrid(images: images)
|
// OptimizedImageGrid(images: images)
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// 互动按钮
|
// // 互动按钮
|
||||||
HStack(spacing: 20) {
|
// HStack(spacing: 20) {
|
||||||
Button(action: {}) {
|
// Button(action: {}) {
|
||||||
HStack(spacing: 4) {
|
// HStack(spacing: 4) {
|
||||||
Image(systemName: "message")
|
// Image(systemName: "message")
|
||||||
.font(.system(size: 16))
|
// .font(.system(size: 16))
|
||||||
Text("\(moment.commentCount)")
|
// Text("\(moment.commentCount)")
|
||||||
.font(.system(size: 14))
|
// .font(.system(size: 14))
|
||||||
}
|
// }
|
||||||
.foregroundColor(.white.opacity(0.8))
|
// .foregroundColor(.white.opacity(0.8))
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
Button(action: {}) {
|
// Button(action: {}) {
|
||||||
HStack(spacing: 4) {
|
// HStack(spacing: 4) {
|
||||||
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
// Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||||
.font(.system(size: 16))
|
// .font(.system(size: 16))
|
||||||
Text("\(moment.likeCount)")
|
// Text("\(moment.likeCount)")
|
||||||
.font(.system(size: 14))
|
// .font(.system(size: 14))
|
||||||
}
|
// }
|
||||||
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
// .foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
Spacer()
|
// Spacer()
|
||||||
}
|
// }
|
||||||
.padding(.top, 8)
|
// .padding(.top, 8)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
.padding(16)
|
// .padding(16)
|
||||||
.background(
|
// .background(
|
||||||
Color.white.opacity(0.1)
|
// Color.white.opacity(0.1)
|
||||||
.cornerRadius(12)
|
// .cornerRadius(12)
|
||||||
)
|
// )
|
||||||
.onAppear {
|
// .onAppear {
|
||||||
// 预加载相邻的图片
|
// // 预加载相邻的图片
|
||||||
preloadNearbyImages()
|
// preloadNearbyImages()
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
private func formatTime(_ timestamp: Int) -> String {
|
// private func formatTime(_ timestamp: Int) -> String {
|
||||||
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
// let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
||||||
let formatter = DateFormatter()
|
// let formatter = DateFormatter()
|
||||||
formatter.locale = Locale(identifier: "zh_CN")
|
// formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
//
|
||||||
let now = Date()
|
// let now = Date()
|
||||||
let interval = now.timeIntervalSince(date)
|
// let interval = now.timeIntervalSince(date)
|
||||||
|
//
|
||||||
if interval < 60 {
|
// if interval < 60 {
|
||||||
return "刚刚"
|
// return "刚刚"
|
||||||
} else if interval < 3600 {
|
// } else if interval < 3600 {
|
||||||
return "\(Int(interval / 60))分钟前"
|
// return "\(Int(interval / 60))分钟前"
|
||||||
} else if interval < 86400 {
|
// } else if interval < 86400 {
|
||||||
return "\(Int(interval / 3600))小时前"
|
// return "\(Int(interval / 3600))小时前"
|
||||||
} else {
|
// } else {
|
||||||
formatter.dateFormat = "MM-dd HH:mm"
|
// formatter.dateFormat = "MM-dd HH:mm"
|
||||||
return formatter.string(from: date)
|
// return formatter.string(from: date)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
private func preloadNearbyImages() {
|
// private func preloadNearbyImages() {
|
||||||
var urlsToPreload: [String] = []
|
// var urlsToPreload: [String] = []
|
||||||
|
//
|
||||||
// 预加载前后2个动态的图片
|
// // 预加载前后2个动态的图片
|
||||||
let preloadRange = max(0, currentIndex - 2)...min(allMoments.count - 1, currentIndex + 2)
|
// let preloadRange = max(0, currentIndex - 2)...min(allMoments.count - 1, currentIndex + 2)
|
||||||
|
//
|
||||||
for index in preloadRange {
|
// for index in preloadRange {
|
||||||
let moment = allMoments[index]
|
// let moment = allMoments[index]
|
||||||
|
//
|
||||||
// 添加头像
|
// // 添加头像
|
||||||
urlsToPreload.append(moment.avatar)
|
// urlsToPreload.append(moment.avatar)
|
||||||
|
//
|
||||||
// 添加动态图片
|
// // 添加动态图片
|
||||||
if let images = moment.dynamicResList {
|
// if let images = moment.dynamicResList {
|
||||||
urlsToPreload.append(contentsOf: images.map { $0.resUrl })
|
// urlsToPreload.append(contentsOf: images.map { $0.resUrl })
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// 异步预加载
|
// // 异步预加载
|
||||||
ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
|
// ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
// MARK: - 优化的图片网格
|
// MARK: - 优化的图片网格
|
||||||
struct OptimizedImageGrid: View {
|
//struct OptimizedImageGrid: View {
|
||||||
let images: [MomentsPicture]
|
// let images: [MomentsPicture]
|
||||||
|
//
|
||||||
var body: some View {
|
// var body: some View {
|
||||||
GeometryReader { geometry in
|
// GeometryReader { geometry in
|
||||||
let availableWidth = max(geometry.size.width, 1) // 防止为0或负数
|
// let availableWidth = max(geometry.size.width, 1) // 防止为0或负数
|
||||||
let spacing: CGFloat = 8
|
// let spacing: CGFloat = 8
|
||||||
|
//
|
||||||
// 保护:如果availableWidth不合理,直接返回空视图
|
// // 保护:如果availableWidth不合理,直接返回空视图
|
||||||
if availableWidth < 10 {
|
// if availableWidth < 10 {
|
||||||
Color.clear.frame(height: 1)
|
// Color.clear.frame(height: 1)
|
||||||
} else {
|
// } else {
|
||||||
switch images.count {
|
// switch images.count {
|
||||||
case 1:
|
// case 1:
|
||||||
// 单张图片:大正方形居中显示
|
// // 单张图片:大正方形居中显示
|
||||||
let imageSize: CGFloat = min(availableWidth * 0.6, 200)
|
// let imageSize: CGFloat = min(availableWidth * 0.6, 200)
|
||||||
HStack {
|
// HStack {
|
||||||
Spacer()
|
// Spacer()
|
||||||
SquareImageView(image: images[0], size: imageSize)
|
// SquareImageView(image: images[0], size: imageSize)
|
||||||
Spacer()
|
// Spacer()
|
||||||
}
|
// }
|
||||||
case 2:
|
// case 2:
|
||||||
// 两张图片:并排显示
|
// // 两张图片:并排显示
|
||||||
let imageSize: CGFloat = (availableWidth - spacing) / 2
|
// let imageSize: CGFloat = (availableWidth - spacing) / 2
|
||||||
HStack(spacing: spacing) {
|
// HStack(spacing: spacing) {
|
||||||
SquareImageView(image: images[0], size: imageSize)
|
// SquareImageView(image: images[0], size: imageSize)
|
||||||
SquareImageView(image: images[1], size: imageSize)
|
// SquareImageView(image: images[1], size: imageSize)
|
||||||
}
|
// }
|
||||||
case 3:
|
// case 3:
|
||||||
// 三张图片:水平排列
|
// // 三张图片:水平排列
|
||||||
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
// let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||||
HStack(spacing: spacing) {
|
// HStack(spacing: spacing) {
|
||||||
ForEach(images.prefix(3), id: \.id) { image in
|
// ForEach(images.prefix(3), id: \.id) { image in
|
||||||
SquareImageView(image: image, size: imageSize)
|
// SquareImageView(image: image, size: imageSize)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
default:
|
// default:
|
||||||
// 四张及以上:九宫格布局(最多9张)
|
// // 四张及以上:九宫格布局(最多9张)
|
||||||
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
// let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||||
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
|
// let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
|
||||||
LazyVGrid(columns: columns, spacing: spacing) {
|
// LazyVGrid(columns: columns, spacing: spacing) {
|
||||||
ForEach(images.prefix(9), id: \.id) { image in
|
// ForEach(images.prefix(9), id: \.id) { image in
|
||||||
SquareImageView(image: image, size: imageSize)
|
// SquareImageView(image: image, size: imageSize)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
.frame(height: calculateGridHeight())
|
// .frame(height: calculateGridHeight())
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
private func calculateGridHeight() -> CGFloat {
|
// private func calculateGridHeight() -> CGFloat {
|
||||||
switch images.count {
|
// switch images.count {
|
||||||
case 1:
|
// case 1:
|
||||||
return 200 // 单张图片的最大高度
|
// return 200 // 单张图片的最大高度
|
||||||
case 2:
|
// case 2:
|
||||||
return 120 // 两张图片并排的高度
|
// return 120 // 两张图片并排的高度
|
||||||
case 3:
|
// case 3:
|
||||||
return 100 // 三张图片水平排列的高度
|
// return 100 // 三张图片水平排列的高度
|
||||||
case 4...6:
|
// case 4...6:
|
||||||
return 216 // 九宫格2行的高度 (实际图片大小 * 2 + 间距)
|
// return 216 // 九宫格2行的高度 (实际图片大小 * 2 + 间距)
|
||||||
default:
|
// default:
|
||||||
return 340 // 九宫格3行的高度 (实际图片大小 * 3 + 间距 + 额外空间)
|
// return 340 // 九宫格3行的高度 (实际图片大小 * 3 + 间距 + 额外空间)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
// MARK: - 正方形图片视图组件
|
// MARK: - 正方形图片视图组件
|
||||||
struct SquareImageView: View {
|
//struct SquareImageView: View {
|
||||||
let image: MomentsPicture
|
// let image: MomentsPicture
|
||||||
let size: CGFloat
|
// let size: CGFloat
|
||||||
|
//
|
||||||
var body: some View {
|
// var body: some View {
|
||||||
let safeSize = size.isFinite && size > 0 ? size : 100 // 防止非有限或负数
|
// let safeSize = size.isFinite && size > 0 ? size : 100 // 防止非有限或负数
|
||||||
CachedAsyncImage(url: image.resUrl) { imageView in
|
// CachedAsyncImage(url: image.resUrl) { imageView in
|
||||||
imageView
|
// imageView
|
||||||
.resizable()
|
// .resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
// .aspectRatio(contentMode: .fill)
|
||||||
} placeholder: {
|
// } placeholder: {
|
||||||
Rectangle()
|
// Rectangle()
|
||||||
.fill(Color.gray.opacity(0.3))
|
// .fill(Color.gray.opacity(0.3))
|
||||||
.overlay(
|
// .overlay(
|
||||||
ProgressView()
|
// ProgressView()
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
|
// .progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
|
||||||
.scaleEffect(0.8)
|
// .scaleEffect(0.8)
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
.frame(width: safeSize, height: safeSize)
|
// .frame(width: safeSize, height: safeSize)
|
||||||
.clipped()
|
// .clipped()
|
||||||
.cornerRadius(8)
|
// .cornerRadius(8)
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
// MARK: - 旧的真实动态卡片组件(保留备用)
|
// MARK: - 旧的真实动态卡片组件(保留备用)
|
||||||
struct RealDynamicCardView: View {
|
//struct RealDynamicCardView: View {
|
||||||
let moment: MomentsInfo
|
// let moment: MomentsInfo
|
||||||
|
//
|
||||||
var body: some View {
|
// var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
// VStack(alignment: .leading, spacing: 12) {
|
||||||
// 用户信息
|
// // 用户信息
|
||||||
HStack {
|
// HStack {
|
||||||
AsyncImage(url: URL(string: moment.avatar)) { image in
|
// AsyncImage(url: URL(string: moment.avatar)) { image in
|
||||||
image
|
// image
|
||||||
.resizable()
|
// .resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
// .aspectRatio(contentMode: .fill)
|
||||||
} placeholder: {
|
// } placeholder: {
|
||||||
Circle()
|
// Circle()
|
||||||
.fill(Color.gray.opacity(0.3))
|
// .fill(Color.gray.opacity(0.3))
|
||||||
.overlay(
|
// .overlay(
|
||||||
Text(String(moment.nick.prefix(1)))
|
// Text(String(moment.nick.prefix(1)))
|
||||||
.font(.system(size: 16, weight: .medium))
|
// .font(.system(size: 16, weight: .medium))
|
||||||
.foregroundColor(.white)
|
// .foregroundColor(.white)
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
.frame(width: 40, height: 40)
|
// .frame(width: 40, height: 40)
|
||||||
.clipShape(Circle())
|
// .clipShape(Circle())
|
||||||
|
//
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
// VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(moment.nick)
|
// Text(moment.nick)
|
||||||
.font(.system(size: 16, weight: .medium))
|
// .font(.system(size: 16, weight: .medium))
|
||||||
.foregroundColor(.white)
|
// .foregroundColor(.white)
|
||||||
|
//
|
||||||
Text(formatTime(moment.publishTime))
|
// Text(formatTime(moment.publishTime))
|
||||||
.font(.system(size: 12))
|
// .font(.system(size: 12))
|
||||||
.foregroundColor(.white.opacity(0.6))
|
// .foregroundColor(.white.opacity(0.6))
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
Spacer()
|
// Spacer()
|
||||||
|
//
|
||||||
// VIP 标识
|
// // VIP 标识
|
||||||
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
// if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
||||||
Text("VIP\(vipLevel)")
|
// Text("VIP\(vipLevel)")
|
||||||
.font(.system(size: 10, weight: .bold))
|
// .font(.system(size: 10, weight: .bold))
|
||||||
.foregroundColor(.yellow)
|
// .foregroundColor(.yellow)
|
||||||
.padding(.horizontal, 6)
|
// .padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
// .padding(.vertical, 2)
|
||||||
.background(Color.yellow.opacity(0.2))
|
// .background(Color.yellow.opacity(0.2))
|
||||||
.cornerRadius(4)
|
// .cornerRadius(4)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// 动态内容
|
// // 动态内容
|
||||||
if !moment.content.isEmpty {
|
// if !moment.content.isEmpty {
|
||||||
Text(moment.content)
|
// Text(moment.content)
|
||||||
.font(.system(size: 14))
|
// .font(.system(size: 14))
|
||||||
.foregroundColor(.white.opacity(0.9))
|
// .foregroundColor(.white.opacity(0.9))
|
||||||
.multilineTextAlignment(.leading)
|
// .multilineTextAlignment(.leading)
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// 图片网格
|
// // 图片网格
|
||||||
if let images = moment.dynamicResList, !images.isEmpty {
|
// if let images = moment.dynamicResList, !images.isEmpty {
|
||||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) {
|
// LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) {
|
||||||
ForEach(images.prefix(9), id: \.id) { image in
|
// ForEach(images.prefix(9), id: \.id) { image in
|
||||||
AsyncImage(url: URL(string: image.resUrl)) { imageView in
|
// AsyncImage(url: URL(string: image.resUrl)) { imageView in
|
||||||
imageView
|
// imageView
|
||||||
.resizable()
|
// .resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
// .aspectRatio(contentMode: .fill)
|
||||||
} placeholder: {
|
// } placeholder: {
|
||||||
Rectangle()
|
// Rectangle()
|
||||||
.fill(Color.gray.opacity(0.3))
|
// .fill(Color.gray.opacity(0.3))
|
||||||
.overlay(
|
// .overlay(
|
||||||
ProgressView()
|
// ProgressView()
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
|
// .progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
.frame(height: 100)
|
// .frame(height: 100)
|
||||||
.clipped()
|
// .clipped()
|
||||||
.cornerRadius(8)
|
// .cornerRadius(8)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// 互动按钮
|
// // 互动按钮
|
||||||
HStack(spacing: 20) {
|
// HStack(spacing: 20) {
|
||||||
Button(action: {}) {
|
// Button(action: {}) {
|
||||||
HStack(spacing: 4) {
|
// HStack(spacing: 4) {
|
||||||
Image(systemName: "message")
|
// Image(systemName: "message")
|
||||||
.font(.system(size: 16))
|
// .font(.system(size: 16))
|
||||||
Text("\(moment.commentCount)")
|
// Text("\(moment.commentCount)")
|
||||||
.font(.system(size: 14))
|
// .font(.system(size: 14))
|
||||||
}
|
// }
|
||||||
.foregroundColor(.white.opacity(0.8))
|
// .foregroundColor(.white.opacity(0.8))
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
Button(action: {}) {
|
// Button(action: {}) {
|
||||||
HStack(spacing: 4) {
|
// HStack(spacing: 4) {
|
||||||
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
// Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||||
.font(.system(size: 16))
|
// .font(.system(size: 16))
|
||||||
Text("\(moment.likeCount)")
|
// Text("\(moment.likeCount)")
|
||||||
.font(.system(size: 14))
|
// .font(.system(size: 14))
|
||||||
}
|
// }
|
||||||
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
// .foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
Spacer()
|
// Spacer()
|
||||||
}
|
// }
|
||||||
.padding(.top, 8)
|
// .padding(.top, 8)
|
||||||
}
|
// }
|
||||||
.padding(16)
|
// .padding(16)
|
||||||
.background(
|
// .background(
|
||||||
Color.white.opacity(0.1)
|
// Color.white.opacity(0.1)
|
||||||
.cornerRadius(12)
|
// .cornerRadius(12)
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
private func formatTime(_ timestamp: Int) -> String {
|
// private func formatTime(_ timestamp: Int) -> String {
|
||||||
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
// let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
||||||
let formatter = DateFormatter()
|
// let formatter = DateFormatter()
|
||||||
formatter.locale = Locale(identifier: "zh_CN")
|
// formatter.locale = Locale(identifier: "zh_CN")
|
||||||
|
//
|
||||||
let now = Date()
|
// let now = Date()
|
||||||
let interval = now.timeIntervalSince(date)
|
// let interval = now.timeIntervalSince(date)
|
||||||
|
//
|
||||||
if interval < 60 {
|
// if interval < 60 {
|
||||||
return "刚刚"
|
// return "刚刚"
|
||||||
} else if interval < 3600 {
|
// } else if interval < 3600 {
|
||||||
return "\(Int(interval / 60))分钟前"
|
// return "\(Int(interval / 60))分钟前"
|
||||||
} else if interval < 86400 {
|
// } else if interval < 86400 {
|
||||||
return "\(Int(interval / 3600))小时前"
|
// return "\(Int(interval / 3600))小时前"
|
||||||
} else {
|
// } else {
|
||||||
formatter.dateFormat = "MM-dd HH:mm"
|
// formatter.dateFormat = "MM-dd HH:mm"
|
||||||
return formatter.string(from: date)
|
// return formatter.string(from: date)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
// MARK: - 旧的模拟卡片组件(保留备用)
|
// MARK: - 旧的模拟卡片组件(保留备用)
|
||||||
struct DynamicCardView: View {
|
//struct DynamicCardView: View {
|
||||||
let index: Int
|
// let index: Int
|
||||||
|
//
|
||||||
var body: some View {
|
// var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
// VStack(alignment: .leading, spacing: 12) {
|
||||||
// 用户信息
|
// // 用户信息
|
||||||
HStack {
|
// HStack {
|
||||||
Circle()
|
// Circle()
|
||||||
.fill(Color.gray.opacity(0.3))
|
// .fill(Color.gray.opacity(0.3))
|
||||||
.frame(width: 40, height: 40)
|
// .frame(width: 40, height: 40)
|
||||||
.overlay(
|
// .overlay(
|
||||||
Text("U\(index + 1)")
|
// Text("U\(index + 1)")
|
||||||
.font(.system(size: 16, weight: .medium))
|
// .font(.system(size: 16, weight: .medium))
|
||||||
.foregroundColor(.white)
|
// .foregroundColor(.white)
|
||||||
)
|
// )
|
||||||
|
//
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
// VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("用户\(index + 1)")
|
// Text("用户\(index + 1)")
|
||||||
.font(.system(size: 16, weight: .medium))
|
// .font(.system(size: 16, weight: .medium))
|
||||||
.foregroundColor(.white)
|
// .foregroundColor(.white)
|
||||||
|
//
|
||||||
Text("2小时前")
|
// Text("2小时前")
|
||||||
.font(.system(size: 12))
|
// .font(.system(size: 12))
|
||||||
.foregroundColor(.white.opacity(0.6))
|
// .foregroundColor(.white.opacity(0.6))
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
Spacer()
|
// Spacer()
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// 动态内容
|
// // 动态内容
|
||||||
Text("今天是美好的一天,分享一些生活中的小确幸。希望大家都能珍惜每一个当下的时刻。")
|
// Text("今天是美好的一天,分享一些生活中的小确幸。希望大家都能珍惜每一个当下的时刻。")
|
||||||
.font(.system(size: 14))
|
// .font(.system(size: 14))
|
||||||
.foregroundColor(.white.opacity(0.9))
|
// .foregroundColor(.white.opacity(0.9))
|
||||||
.multilineTextAlignment(.leading)
|
// .multilineTextAlignment(.leading)
|
||||||
|
//
|
||||||
// 图片网格
|
// // 图片网格
|
||||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
|
// LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
|
||||||
ForEach(0..<3) { imageIndex in
|
// ForEach(0..<3) { imageIndex in
|
||||||
Rectangle()
|
// Rectangle()
|
||||||
.fill(Color.gray.opacity(0.3))
|
// .fill(Color.gray.opacity(0.3))
|
||||||
.aspectRatio(1, contentMode: .fit)
|
// .aspectRatio(1, contentMode: .fit)
|
||||||
.overlay(
|
// .overlay(
|
||||||
Image(systemName: "photo")
|
// Image(systemName: "photo")
|
||||||
.foregroundColor(.white.opacity(0.6))
|
// .foregroundColor(.white.opacity(0.6))
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// 互动按钮
|
// // 互动按钮
|
||||||
HStack(spacing: 20) {
|
// HStack(spacing: 20) {
|
||||||
Button(action: {}) {
|
// Button(action: {}) {
|
||||||
HStack(spacing: 4) {
|
// HStack(spacing: 4) {
|
||||||
Image(systemName: "message")
|
// Image(systemName: "message")
|
||||||
.font(.system(size: 16))
|
// .font(.system(size: 16))
|
||||||
Text("354")
|
// Text("354")
|
||||||
.font(.system(size: 14))
|
// .font(.system(size: 14))
|
||||||
}
|
// }
|
||||||
.foregroundColor(.white.opacity(0.8))
|
// .foregroundColor(.white.opacity(0.8))
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
Button(action: {}) {
|
// Button(action: {}) {
|
||||||
HStack(spacing: 4) {
|
// HStack(spacing: 4) {
|
||||||
Image(systemName: "heart")
|
// Image(systemName: "heart")
|
||||||
.font(.system(size: 16))
|
// .font(.system(size: 16))
|
||||||
Text("354")
|
// Text("354")
|
||||||
.font(.system(size: 14))
|
// .font(.system(size: 14))
|
||||||
}
|
// }
|
||||||
.foregroundColor(.white.opacity(0.8))
|
// .foregroundColor(.white.opacity(0.8))
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
Spacer()
|
// Spacer()
|
||||||
}
|
// }
|
||||||
.padding(.top, 8)
|
// .padding(.top, 8)
|
||||||
}
|
// }
|
||||||
.padding(16)
|
// .padding(16)
|
||||||
.background(
|
// .background(
|
||||||
Color.white.opacity(0.1)
|
// Color.white.opacity(0.1)
|
||||||
.cornerRadius(12)
|
// .cornerRadius(12)
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
//#Preview {
|
//#Preview {
|
||||||
// FeedView(
|
// FeedView(
|
||||||
|
@@ -34,8 +34,15 @@ struct HomeView: View {
|
|||||||
)
|
)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
case .me:
|
case .me:
|
||||||
MeView(onLogout: onLogout)
|
Spacer()
|
||||||
.transition(.opacity)
|
// MeView(
|
||||||
|
// meDynamicStore: store.scope(
|
||||||
|
// state: \.meDynamic,
|
||||||
|
// action: \.meDynamic
|
||||||
|
// ),
|
||||||
|
// onLogout: onLogout
|
||||||
|
// )
|
||||||
|
// .transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -51,12 +58,6 @@ struct HomeView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
store.send(.onAppear)
|
store.send(.onAppear)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: Binding(
|
|
||||||
get: { store.withState(\.isSettingPresented) },
|
|
||||||
set: { _ in store.send(.settingDismissed) }
|
|
||||||
)) {
|
|
||||||
SettingView(store: store.scope(state: \.settingState, action: \.setting))
|
|
||||||
}
|
|
||||||
.navigationDestination(isPresented: Binding(
|
.navigationDestination(isPresented: Binding(
|
||||||
get: { store.withState(\.route) == .createFeed },
|
get: { store.withState(\.route) == .createFeed },
|
||||||
set: { isPresented in
|
set: { isPresented in
|
||||||
|
87
yana/Views/ImagePreviewPager.swift
Normal file
87
yana/Views/ImagePreviewPager.swift
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum ImagePreviewSource: Identifiable, Equatable {
|
||||||
|
case local(UIImage)
|
||||||
|
case remote(String)
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .local(let img):
|
||||||
|
return String(describing: img.hashValue)
|
||||||
|
case .remote(let url):
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static func == (lhs: ImagePreviewSource, rhs: ImagePreviewSource) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case let (.local(l), .local(r)):
|
||||||
|
return l.pngData() == r.pngData()
|
||||||
|
case let (.remote(l), .remote(r)):
|
||||||
|
return l == r
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ImagePreviewPager: View {
|
||||||
|
let images: [ImagePreviewSource]
|
||||||
|
@Binding var currentIndex: Int
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
// 本地图片构造器
|
||||||
|
init(images: [UIImage], currentIndex: Binding<Int>, onClose: @escaping () -> Void) {
|
||||||
|
self.images = images.map { .local($0) }
|
||||||
|
self._currentIndex = currentIndex
|
||||||
|
self.onClose = onClose
|
||||||
|
}
|
||||||
|
// 远程图片构造器
|
||||||
|
init(images: [String], currentIndex: Binding<Int>, onClose: @escaping () -> Void) {
|
||||||
|
self.images = images.map { .remote($0) }
|
||||||
|
self._currentIndex = currentIndex
|
||||||
|
self.onClose = onClose
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Color.black.ignoresSafeArea()
|
||||||
|
TabView(selection: $currentIndex) {
|
||||||
|
ForEach(Array(images.enumerated()), id: \ .element.id) { idx, source in
|
||||||
|
Group {
|
||||||
|
switch source {
|
||||||
|
case .local(let img):
|
||||||
|
Image(uiImage: img)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black)
|
||||||
|
case .remote(let urlStr):
|
||||||
|
CachedAsyncImage(url: urlStr) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.black)
|
||||||
|
} placeholder: {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.overlay(
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white.opacity(0.6)))
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tag(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
|
||||||
|
Button(action: { onClose() }) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,13 +1,28 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposableArchitecture
|
import ComposableArchitecture
|
||||||
//import Components // 如果 BottomTabView 在 Components 命名空间,否则移除
|
|
||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
let store: StoreOf<MainFeature>
|
let store: StoreOf<MainFeature>
|
||||||
|
var onLogout: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||||
NavigationStack {
|
InternalMainView(store: store)
|
||||||
|
.onChange(of: viewStore.isLoggedOut) { isLoggedOut in
|
||||||
|
if isLoggedOut {
|
||||||
|
onLogout?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原 MainView 内容,重命名为 InternalMainView
|
||||||
|
struct InternalMainView: View {
|
||||||
|
let store: StoreOf<MainFeature>
|
||||||
|
var body: some View {
|
||||||
|
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||||
|
NavigationStack(path: viewStore.binding(get: \.navigationPath, send: MainFeature.Action.navigationPathChanged)) {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack {
|
ZStack {
|
||||||
// 背景图片
|
// 背景图片
|
||||||
@@ -19,17 +34,18 @@ struct MainView: View {
|
|||||||
.ignoresSafeArea(.all)
|
.ignoresSafeArea(.all)
|
||||||
// 主内容
|
// 主内容
|
||||||
ZStack {
|
ZStack {
|
||||||
switch viewStore.selectedTab {
|
FeedListView(store: store.scope(
|
||||||
case .feed:
|
state: \.feedList,
|
||||||
FeedListView(store: store.scope(
|
action: \.feedList
|
||||||
state: \.feedList,
|
))
|
||||||
action: \.feedList
|
.isHidden(viewStore.selectedTab != .feed)
|
||||||
))
|
MeView(
|
||||||
.transition(.opacity)
|
store: store.scope(
|
||||||
case .other:
|
state: \.me,
|
||||||
MeView(onLogout: {}) // 这里可根据需要传递实际登出回调
|
action: \.me
|
||||||
.transition(.opacity)
|
)
|
||||||
}
|
)
|
||||||
|
.isHidden(viewStore.selectedTab != .other)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
// 底部导航栏
|
// 底部导航栏
|
||||||
@@ -43,7 +59,39 @@ struct MainView: View {
|
|||||||
.padding(.bottom, geometry.safeAreaInsets.bottom + 60)
|
.padding(.bottom, geometry.safeAreaInsets.bottom + 60)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.navigationDestination(for: MainFeature.Destination.self) { destination in
|
||||||
|
switch destination {
|
||||||
|
case .test:
|
||||||
|
TestPushView()
|
||||||
|
case .appSetting:
|
||||||
|
IfLetStore(
|
||||||
|
self.store.scope(
|
||||||
|
state: \.appSettingState,
|
||||||
|
action: MainFeature.Action.appSettingAction
|
||||||
|
),
|
||||||
|
then: { appSettingStore in
|
||||||
|
WithPerceptionTracking {
|
||||||
|
AppSettingView(store: appSettingStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
viewStore.send(.onAppear)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TestPushView: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.blue.ignoresSafeArea()
|
||||||
|
Text("Test Push View")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,137 +1,124 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ComposableArchitecture
|
||||||
|
|
||||||
struct MeView: View {
|
struct MeView: View {
|
||||||
@State private var showLogoutConfirmation = false
|
let store: StoreOf<MeFeature>
|
||||||
let onLogout: () -> Void // 新增:登出回调
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ScrollView {
|
ZStack {
|
||||||
VStack(spacing: 20) {
|
Image("bg")
|
||||||
// 顶部标题
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.clipped()
|
||||||
|
.ignoresSafeArea(.all)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// 顶部栏,右上角设置按钮
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("我的")
|
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||||
.font(.system(size: 22, weight: .semibold))
|
Button(action: {
|
||||||
.foregroundColor(.white)
|
viewStore.send(.settingButtonTapped)
|
||||||
Spacer()
|
}) {
|
||||||
}
|
Image(systemName: "gearshape")
|
||||||
.padding(.top, geometry.safeAreaInsets.top + 20)
|
.font(.system(size: 22, weight: .medium))
|
||||||
|
|
||||||
// 用户头像区域
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Circle()
|
|
||||||
.fill(Color.white.opacity(0.2))
|
|
||||||
.frame(width: 80, height: 80)
|
|
||||||
.overlay(
|
|
||||||
Image(systemName: "person.fill")
|
|
||||||
.font(.system(size: 40))
|
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
)
|
}
|
||||||
|
.padding(.trailing, 16)
|
||||||
Text("用户昵称")
|
.padding(.top, 8)
|
||||||
.font(.system(size: 18, weight: .medium))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Text("ID: 123456789")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(.white.opacity(0.7))
|
|
||||||
}
|
|
||||||
.padding(.top, 30)
|
|
||||||
|
|
||||||
// 功能菜单
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
MenuItemView(icon: "gearshape", title: "设置", action: {})
|
|
||||||
MenuItemView(icon: "person.circle", title: "个人信息", action: {})
|
|
||||||
MenuItemView(icon: "heart", title: "我的收藏", action: {})
|
|
||||||
MenuItemView(icon: "clock", title: "浏览历史", action: {})
|
|
||||||
MenuItemView(icon: "questionmark.circle", title: "帮助与反馈", action: {})
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.top, 40)
|
|
||||||
|
|
||||||
// 退出登录按钮
|
|
||||||
Button(action: {
|
|
||||||
showLogoutConfirmation = true
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
|
||||||
.font(.system(size: 16))
|
|
||||||
Text("退出登录")
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
}
|
}
|
||||||
.foregroundColor(.red)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.frame(height: 50)
|
|
||||||
.background(
|
|
||||||
Color.white.opacity(0.1)
|
|
||||||
.cornerRadius(12)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
// 用户信息区域
|
||||||
.padding(.top, 30)
|
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||||
|
if viewStore.isLoadingUserInfo {
|
||||||
// 底部安全区域 - 为底部导航栏和安全区域留出空间
|
ProgressView()
|
||||||
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
.frame(height: 130)
|
||||||
|
} else if let error = viewStore.userInfoError {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.frame(height: 130)
|
||||||
|
} else if let userInfo = viewStore.userInfo {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if let avatarUrl = userInfo.avatar, !avatarUrl.isEmpty {
|
||||||
|
AsyncImage(url: URL(string: avatarUrl)) { image in
|
||||||
|
image.resizable().aspectRatio(contentMode: .fill).clipShape(Circle())
|
||||||
|
} placeholder: {
|
||||||
|
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.frame(width: 90, height: 90)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
|
||||||
|
.frame(width: 90, height: 90)
|
||||||
|
}
|
||||||
|
Text(userInfo.nick ?? "用户昵称")
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
Text("ID: \(userInfo.uid ?? 0)")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.frame(height: 130)
|
||||||
|
} else {
|
||||||
|
Spacer().frame(height: 130)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 动态内容区域
|
||||||
|
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||||
|
if viewStore.isLoadingMoments && viewStore.moments.isEmpty {
|
||||||
|
ProgressView("加载中...")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if let error = viewStore.momentsError {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(.yellow)
|
||||||
|
Text(error)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
Button("重试") {
|
||||||
|
viewStore.send(.onAppear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if viewStore.moments.isEmpty {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "tray")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
Text("暂无动态")
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 12) {
|
||||||
|
ForEach(Array(viewStore.moments.enumerated()), id: \ .element.dynamicId) { index, moment in
|
||||||
|
OptimizedDynamicCardView(moment: moment, allMoments: viewStore.moments, currentIndex: index)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
}
|
||||||
|
if viewStore.hasMore {
|
||||||
|
ProgressView()
|
||||||
|
.onAppear {
|
||||||
|
viewStore.send(.loadMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewStore.send(.refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ignoresSafeArea(.container, edges: .top)
|
.onAppear {
|
||||||
.alert("确认退出", isPresented: $showLogoutConfirmation) {
|
ViewStore(self.store, observe: { $0 }).send(.onAppear)
|
||||||
Button("取消", role: .cancel) { }
|
|
||||||
Button("退出", role: .destructive) {
|
|
||||||
Task { await performLogout() }
|
|
||||||
}
|
|
||||||
} message: {
|
|
||||||
Text("确定要退出登录吗?")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 退出登录方法
|
|
||||||
private func performLogout() async {
|
|
||||||
debugInfoSync("🔓 开始执行退出登录...")
|
|
||||||
// 清除所有认证数据(包括 keychain 中的内容)
|
|
||||||
await UserInfoManager.clearAllAuthenticationData()
|
|
||||||
// 调用登出回调,通知父级切换视图
|
|
||||||
onLogout()
|
|
||||||
debugInfoSync("✅ 退出登录完成")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 菜单项组件
|
|
||||||
struct MenuItemView: View {
|
|
||||||
let icon: String
|
|
||||||
let title: String
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
HStack(spacing: 16) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.font(.system(size: 20))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(width: 24)
|
|
||||||
|
|
||||||
Text(title)
|
|
||||||
.font(.system(size: 16))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
.foregroundColor(.white.opacity(0.6))
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.frame(height: 56)
|
|
||||||
.background(
|
|
||||||
Color.white.opacity(0.1)
|
|
||||||
.cornerRadius(12)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(PlainButtonStyle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
|
||||||
MeView(onLogout: {})
|
|
||||||
}
|
}
|
||||||
|
@@ -1,199 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import ComposableArchitecture
|
|
||||||
|
|
||||||
struct SettingView: View {
|
|
||||||
let store: StoreOf<SettingFeature>
|
|
||||||
@ObservedObject private var localizationManager = LocalizationManager.shared
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
WithPerceptionTracking {
|
|
||||||
GeometryReader { geometry in
|
|
||||||
ZStack {
|
|
||||||
// 背景图片 - 使用"bg"图片,全屏显示
|
|
||||||
Image("bg")
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.ignoresSafeArea(.all)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Navigation Bar
|
|
||||||
HStack {
|
|
||||||
// 返回按钮
|
|
||||||
Button(action: {
|
|
||||||
store.send(.dismissTapped)
|
|
||||||
}) {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.title2)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
.padding(.leading, 16)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
Text("设置")
|
|
||||||
.font(.custom("PingFang SC-Semibold", size: 16))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// 占位符,保持标题居中
|
|
||||||
Color.clear
|
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
}
|
|
||||||
.padding(.top, 8)
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
// 内容区域
|
|
||||||
ScrollView {
|
|
||||||
VStack(spacing: 24) {
|
|
||||||
// 用户信息卡片
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
// 头像区域
|
|
||||||
Image(systemName: "person.circle.fill")
|
|
||||||
.font(.system(size: 60))
|
|
||||||
.foregroundColor(.white.opacity(0.8))
|
|
||||||
|
|
||||||
// 用户信息
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
if let userInfo = store.userInfo, let userName = userInfo.username {
|
|
||||||
Text(userName)
|
|
||||||
.font(.title2.weight(.semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
} else {
|
|
||||||
Text("用户")
|
|
||||||
.font(.title2.weight(.semibold))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示用户ID
|
|
||||||
if let userInfo = store.userInfo, let userId = userInfo.userId {
|
|
||||||
Text("ID: \(userId)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.white.opacity(0.8))
|
|
||||||
} else if let accountModel = store.accountModel, let uid = accountModel.uid {
|
|
||||||
Text("UID: \(uid)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.white.opacity(0.8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 24)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.background(Color.black.opacity(0.3))
|
|
||||||
.cornerRadius(16)
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.top, 32)
|
|
||||||
|
|
||||||
// 设置选项列表
|
|
||||||
VStack(spacing: 12) {
|
|
||||||
// 语言设置
|
|
||||||
SettingRowView(
|
|
||||||
icon: "globe",
|
|
||||||
title: "语言设置",
|
|
||||||
action: {
|
|
||||||
// TODO: 实现语言设置
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 关于我们
|
|
||||||
SettingRowView(
|
|
||||||
icon: "info.circle",
|
|
||||||
title: "关于我们",
|
|
||||||
action: {
|
|
||||||
// TODO: 实现关于页面
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 版本信息
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "app.badge")
|
|
||||||
.foregroundColor(.white.opacity(0.8))
|
|
||||||
.frame(width: 24)
|
|
||||||
|
|
||||||
Text("版本信息")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text("1.0.0")
|
|
||||||
.foregroundColor(.white.opacity(0.6))
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.vertical, 16)
|
|
||||||
.background(Color.black.opacity(0.2))
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
|
|
||||||
Spacer(minLength: 50)
|
|
||||||
|
|
||||||
// 退出登录按钮
|
|
||||||
Button(action: {
|
|
||||||
store.send(.logoutTapped)
|
|
||||||
}) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "arrow.right.square")
|
|
||||||
Text("退出登录")
|
|
||||||
}
|
|
||||||
.font(.body.weight(.medium))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 16)
|
|
||||||
.background(Color.red.opacity(0.7))
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.bottom, 50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
store.send(.onAppear)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Setting Row View
|
|
||||||
struct SettingRowView: View {
|
|
||||||
let icon: String
|
|
||||||
let title: String
|
|
||||||
let action: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: action) {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundColor(.white.opacity(0.8))
|
|
||||||
.frame(width: 24)
|
|
||||||
|
|
||||||
Text(title)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.foregroundColor(.white.opacity(0.6))
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.vertical, 16)
|
|
||||||
.background(Color.black.opacity(0.2))
|
|
||||||
.cornerRadius(12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#Preview {
|
|
||||||
// SettingView(
|
|
||||||
// store: Store(
|
|
||||||
// initialState: SettingFeature.State()
|
|
||||||
// ) {
|
|
||||||
// SettingFeature()
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
//}
|
|
@@ -30,6 +30,9 @@ struct SplashView: View {
|
|||||||
initialState: MainFeature.State()
|
initialState: MainFeature.State()
|
||||||
) {
|
) {
|
||||||
MainFeature()
|
MainFeature()
|
||||||
|
},
|
||||||
|
onLogout: {
|
||||||
|
store.send(.navigateToLogin)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -64,7 +67,7 @@ struct SplashView: View {
|
|||||||
.frame(width: 100, height: 100)
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
// 应用标题 - 白色,40pt字体
|
// 应用标题 - 白色,40pt字体
|
||||||
Text("E-Parti")
|
Text(NSLocalizedString("splash.title", comment: "E-Parti"))
|
||||||
.font(.system(size: 40, weight: .regular))
|
.font(.system(size: 40, weight: .regular))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
@@ -173,3 +173,68 @@ final class yanaAPITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - User Info API Tests
|
||||||
|
extension yanaAPITests {
|
||||||
|
|
||||||
|
func testGetUserInfoRequest() {
|
||||||
|
// 测试获取用户信息请求创建
|
||||||
|
let uid = "12345"
|
||||||
|
let request = UserInfoHelper.createGetUserInfoRequest(uid: uid)
|
||||||
|
|
||||||
|
XCTAssertEqual(request.endpoint, "/user/get", "端点应该正确")
|
||||||
|
XCTAssertEqual(request.method, .GET, "请求方法应该是GET")
|
||||||
|
XCTAssertEqual(request.queryParameters?["uid"], uid, "UID参数应该正确")
|
||||||
|
XCTAssertFalse(request.shouldShowLoading, "不应该显示loading")
|
||||||
|
XCTAssertFalse(request.shouldShowError, "不应该显示错误")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetUserInfoResponse() {
|
||||||
|
// 测试用户信息响应解析
|
||||||
|
let responseData: [String: Any] = [
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"timestamp": 1640995200000,
|
||||||
|
"data": [
|
||||||
|
"user_id": "12345",
|
||||||
|
"username": "testuser",
|
||||||
|
"nickname": "测试用户",
|
||||||
|
"avatar": "https://example.com/avatar.jpg",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"phone": "13800138000",
|
||||||
|
"status": "active",
|
||||||
|
"create_time": "2024-01-01 00:00:00",
|
||||||
|
"update_time": "2024-01-01 00:00:00"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
do {
|
||||||
|
let jsonData = try JSONSerialization.data(withJSONObject: responseData)
|
||||||
|
let response = try JSONDecoder().decode(GetUserInfoResponse.self, from: jsonData)
|
||||||
|
|
||||||
|
XCTAssertTrue(response.isSuccess, "响应应该成功")
|
||||||
|
XCTAssertEqual(response.code, 200, "状态码应该正确")
|
||||||
|
XCTAssertNotNil(response.data, "用户信息数据应该存在")
|
||||||
|
|
||||||
|
if let userInfo = response.data {
|
||||||
|
XCTAssertEqual(userInfo.userId, "12345", "用户ID应该正确")
|
||||||
|
XCTAssertEqual(userInfo.username, "testuser", "用户名应该正确")
|
||||||
|
XCTAssertEqual(userInfo.nickname, "测试用户", "昵称应该正确")
|
||||||
|
}
|
||||||
|
|
||||||
|
debugInfoSync("✅ 用户信息响应解析测试通过")
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
XCTFail("解析用户信息响应失败: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUserInfoHelper() {
|
||||||
|
// 测试UserInfoHelper
|
||||||
|
let uid = "67890"
|
||||||
|
UserInfoHelper.debugGetUserInfoRequest(uid: uid)
|
||||||
|
|
||||||
|
let request = UserInfoHelper.createGetUserInfoRequest(uid: uid)
|
||||||
|
XCTAssertEqual(request.queryParameters?["uid"], uid, "Helper创建的请求应该正确")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -175,11 +175,13 @@ xcodebuild -workspace yana.xcworkspace -scheme yana -configuration Debug build
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 关键代码修改
|
### 关键代码修改
|
||||||
|
|
||||||
1. **HomeFeature.swift**: 添加设置相关状态管理
|
1. **HomeFeature.swift**: 添加设置相关状态管理
|
||||||
2. **HomeView.swift**: 修复 TCA store 绑定语法
|
2. **HomeView.swift**: 修复 TCA store 绑定语法
|
||||||
3. **SettingFeature.swift**: 确保 Action 完整性
|
3. **SettingFeature.swift**: 确保 Action 完整性
|
||||||
|
|
||||||
### 构建结果
|
### 构建结果
|
||||||
|
|
||||||
✅ **编译成功**: Exit code 0
|
✅ **编译成功**: Exit code 0
|
||||||
⚠️ **警告信息**: 仅 Swift 6 兼容性警告,不影响运行
|
⚠️ **警告信息**: 仅 Swift 6 兼容性警告,不影响运行
|
||||||
|
|
||||||
@@ -188,18 +190,21 @@ xcodebuild -workspace yana.xcworkspace -scheme yana -configuration Debug build
|
|||||||
## 预防措施
|
## 预防措施
|
||||||
|
|
||||||
### 开发规范
|
### 开发规范
|
||||||
|
|
||||||
1. **统一包管理**: 优先使用一种包管理工具
|
1. **统一包管理**: 优先使用一种包管理工具
|
||||||
2. **定期清理**: 定期清理 DerivedData 避免缓存问题
|
2. **定期清理**: 定期清理 DerivedData 避免缓存问题
|
||||||
3. **代码审查**: 确保 TCA Feature 结构完整
|
3. **代码审查**: 确保 TCA Feature 结构完整
|
||||||
4. **版本控制**: 及时提交关键配置文件
|
4. **版本控制**: 及时提交关键配置文件
|
||||||
|
|
||||||
### 监控指标
|
### 监控指标
|
||||||
|
|
||||||
- [ ] 项目编译时间 < 30s
|
- [ ] 项目编译时间 < 30s
|
||||||
- [ ] 无编译错误
|
- [ ] 无编译错误
|
||||||
- [ ] 依赖解析正常
|
- [ ] 依赖解析正常
|
||||||
- [ ] TCA 结构完整
|
- [ ] TCA 结构完整
|
||||||
|
|
||||||
### 工具使用
|
### 工具使用
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 项目健康检查脚本
|
# 项目健康检查脚本
|
||||||
check_project() {
|
check_project() {
|
||||||
@@ -255,6 +260,7 @@ pod install --clean-install
|
|||||||
## 总结
|
## 总结
|
||||||
|
|
||||||
本次问题解决涉及以下关键技术点:
|
本次问题解决涉及以下关键技术点:
|
||||||
|
|
||||||
1. **Xcode 项目配置管理**
|
1. **Xcode 项目配置管理**
|
||||||
2. **Swift Package Manager 与 CocoaPods 共存**
|
2. **Swift Package Manager 与 CocoaPods 共存**
|
||||||
3. **TCA (The Composable Architecture) 最佳实践**
|
3. **TCA (The Composable Architecture) 最佳实践**
|
||||||
@@ -265,5 +271,5 @@ pod install --clean-install
|
|||||||
---
|
---
|
||||||
|
|
||||||
**文档更新时间**: 2025-07-10
|
**文档更新时间**: 2025-07-10
|
||||||
**适用版本**: iOS 15.6+, Swift 6, TCA 1.20.2+
|
**适用版本**: iOS 16+, Swift 6, TCA 1.20.2+
|
||||||
**维护者**: AI Assistant & 开发团队
|
**维护者**: AI Assistant & 开发团队
|
Reference in New Issue
Block a user