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
|
||||
---
|
||||
# CONTEXT
|
||||
I wish to receive advice using the latest tools and seek step-by-step guidance to fully understand the implementation process.
|
||||
|
||||
# OBJECTIVE
|
||||
As an expert AI programming assistant, your task is to provide me with clear 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.
|
||||
I wish to receive advice using the latest tools and seek step-by-step guidance to fully understand the implementation process.
|
||||
|
||||
# STYLE
|
||||
- Keep answers concise and direct, minimizing unnecessary wording.
|
||||
- Emphasize code readability over performance optimization.
|
||||
- Maintain a professional and supportive tone, ensuring clarity of content.
|
||||
|
||||
|
||||
# AUDIENCE
|
||||
The target audience is me, a native Chinese developer eager to learn Swift 6 and Xcode 15.9, seeking guidance and advice on utilizing the latest technologies.
|
||||
|
||||
# RESPONSE FORMAT
|
||||
- **Utilize the Chain-of-Thought (CoT) method to reason and respond, explaining your thought process step by step.**
|
||||
- The reply should include:
|
||||
## OBJECTIVE
|
||||
|
||||
As an expert AI programming assistant, your task is to provide me with clear, readable, and effective code. You should:
|
||||
|
||||
- Utilize the latest versions of SwiftUI, Swift(6) and TCA(1.20.2), being familiar with the newest features and best practices.
|
||||
- Provide careful and accurate answers that are well-founded and thoughtfully considered.
|
||||
- **Explicitly use the Chain-of-Thought (CoT) method in your reasoning and answers, explaining your thought process step by step.**
|
||||
- Strictly adhere to my requirements and meticulously complete the tasks.
|
||||
- Begin by outlining your proposed approach with detailed steps or pseudocode.
|
||||
- Upon confirming the plan, proceed to write the code.
|
||||
|
||||
## STYLE
|
||||
|
||||
- Keep answers concise and direct, minimizing unnecessary wording.
|
||||
- Emphasize code readability over performance optimization.
|
||||
- Maintain a professional and supportive tone, ensuring clarity of content.
|
||||
|
||||
## RESPONSE FORMAT
|
||||
|
||||
- **Utilize the Chain-of-Thought (CoT) method to reason and respond, explaining your thought process step by step.**
|
||||
- The reply should include:
|
||||
1. **Step-by-Step Plan**: Describe the implementation process with detailed pseudocode or step-by-step explanations, showcasing your thought process.
|
||||
2. **Code Implementation**: Provide correct, up-to-date, error-free, fully functional, runnable, secure, and efficient code. The code should:
|
||||
- Include all necessary imports and properly name key components.
|
||||
- Fully implement all requested features, leaving no to-dos, placeholders, or omissions.
|
||||
3. **Concise Response**: Minimize unnecessary verbosity, focusing only on essential information.
|
||||
|
||||
- If a correct answer may not exist, please point it out. If you do not know the answer, please honestly inform me rather than guessing.
|
||||
- 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.
|
||||
|
81
README.md
81
README.md
@@ -2,37 +2,50 @@
|
||||
|
||||
## 项目简介
|
||||
|
||||
Yana 是一个基于 iOS 平台的即时通讯应用,使用 Swift 语言开发,集成了网易云信 SDK 实现即时通讯功能。
|
||||
Yana 是一个基于 iOS 平台的即时通讯应用,使用 Swift 语言开发,集成了网易云信 SDK 实现即时通讯功能,并采用 The Composable Architecture (TCA) 架构设计。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 开发语言:Swift
|
||||
- 最低支持版本:iOS 15.6
|
||||
- 主要框架:
|
||||
- NIMSDK_LITE:网易云信即时通讯 SDK
|
||||
- **开发语言**:Swift (主要),Objective-C (部分组件)
|
||||
- **最低支持版本**:iOS 16
|
||||
- **架构模式**:The Composable Architecture (TCA) - 1.20.2
|
||||
- **UI 框架**:SwiftUI
|
||||
- **依赖管理**:
|
||||
- CocoaPods
|
||||
- Swift Package Manager
|
||||
- **主要框架**:
|
||||
- NIMSDK_LITE:网易云信即时通讯 SDK (10.6.1)
|
||||
- NEChatKit:聊天核心组件
|
||||
- NEChatUIKit:会话(聊天)UI 组件
|
||||
- NEContactUIKit:通讯录 UI 组件
|
||||
- NELocalConversationUIKit:本地会话列表 UI 组件
|
||||
- Alamofire:网络请求框架
|
||||
- ComposableArchitecture:状态管理 (v1.20.2+)
|
||||
- CasePaths:枚举模式匹配
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
yana/
|
||||
├── AppDelegate.swift # 应用程序代理
|
||||
├── yanaApp.swift # SwiftUI 应用入口
|
||||
├── ContentView.swift # 主视图
|
||||
├── Managers/ # 管理器类
|
||||
├── Models/ # 数据模型
|
||||
├── Configs/ # 配置文件
|
||||
└── Assets.xcassets/ # 资源文件
|
||||
├── yana/ # 主应用源代码
|
||||
│ ├── Info.plist
|
||||
│ ├── yana-Bridging-Header.h # Objective-C 集成桥接头文件
|
||||
│ ├── AppDelegate.swift # 应用程序代理
|
||||
│ ├── yanaApp.swift # SwiftUI 应用入口
|
||||
│ ├── ContentView.swift # 主视图
|
||||
│ ├── Managers/ # 管理器类
|
||||
│ ├── Models/ # 数据模型
|
||||
│ ├── Configs/ # 配置文件
|
||||
│ ├── APIs/ # API 相关文件
|
||||
│ └── Assets.xcassets/ # 资源文件
|
||||
├── yanaAPITests/ # API 测试目标
|
||||
└── Pods/ # CocoaPods 依赖
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Xcode 13.0 或更高版本
|
||||
- iOS 15.6 或更高版本
|
||||
- iOS 16 或更高版本
|
||||
- CocoaPods 包管理器
|
||||
|
||||
## 安装步骤
|
||||
@@ -49,10 +62,24 @@ yana/
|
||||
|
||||
## 主要功能
|
||||
|
||||
- 即时通讯
|
||||
- 会话管理
|
||||
- 通讯录管理
|
||||
- 本地会话列表
|
||||
- **用户认证**:
|
||||
- 邮箱登录流程(带验证码)
|
||||
- 多种认证方式
|
||||
- **即时通讯**
|
||||
- **会话管理**
|
||||
- **通讯录管理**
|
||||
- **本地会话列表**
|
||||
- **云存储集成**
|
||||
|
||||
## UI 组件
|
||||
|
||||
项目包含多种自定义 UI 组件:
|
||||
- 自定义登录按钮
|
||||
- 底部标签导航
|
||||
- API 调用加载效果
|
||||
- Web 视图集成
|
||||
- 图片预览功能
|
||||
- 屏幕适配工具
|
||||
|
||||
## API 使用
|
||||
|
||||
@@ -75,21 +102,27 @@ let response = try await apiService.request(request)
|
||||
|
||||
- 项目使用 CocoaPods 管理依赖
|
||||
- 需要配置网易云信相关密钥
|
||||
- 最低支持 iOS 15.6 版本
|
||||
- 最低支持 iOS 16 版本
|
||||
- 仅支持 iPhone 设备(不支持 iPad、Mac Catalyst 或 Vision Pro)
|
||||
|
||||
## 开发规范
|
||||
|
||||
- 遵循 Swift 官方编码规范
|
||||
- 使用 SwiftUI 构建用户界面
|
||||
- 采用 MVVM 架构模式
|
||||
- 采用 TCA 架构模式
|
||||
- 支持多语言(包含中文本地化)
|
||||
|
||||
## 依赖版本
|
||||
## 测试
|
||||
|
||||
- NIMSDK 相关组件版本:10.6.1
|
||||
- Alamofire:最新版本
|
||||
项目包含专门的 API 测试目标 "yanaAPITests",用于对主应用的 API 功能进行单元测试。
|
||||
|
||||
## 开发团队
|
||||
|
||||
项目由团队 "EKM7RAGNA6" 开发,测试目标的包标识符为 "com.stupidmonkey.yanaAPITests"。
|
||||
|
||||
## 构建配置
|
||||
|
||||
- 项目使用动态框架
|
||||
- 支持 iOS 13.0 及以上版本
|
||||
- 已配置框架冲突处理脚本
|
||||
- 支持 iOS 16 及以上版本
|
||||
- Swift 版本:6.0
|
||||
- 已配置框架冲突处理脚本
|
@@ -583,14 +583,16 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKM7RAGNA6;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yanaAPITests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.junpeiqi.eparty;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
@@ -610,7 +612,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKM7RAGNA6;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stupidmonkey.yanaAPITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@@ -21,10 +21,15 @@ enum APIEndpoint: String, CaseIterable {
|
||||
case emailGetCode = "/email/getCode" // 新增:邮箱验证码获取端点
|
||||
case latestDynamics = "/dynamic/square/latestDynamics" // 新增:获取最新动态列表端点
|
||||
case tcToken = "/tencent/cos/getToken" // 新增:腾讯云 COS Token 获取端点
|
||||
case publishFeed = "/dynamic/square/publish" // 发布动态
|
||||
case getUserInfo = "/user/get" // 新增:获取用户信息端点
|
||||
case getMyDynamic = "/dynamic/getMyDynamic"
|
||||
|
||||
// Web 页面路径
|
||||
case userAgreement = "/modules/rule/protocol.html"
|
||||
case privacyPolicy = "/modules/rule/privacy-wap.html"
|
||||
|
||||
|
||||
var path: String {
|
||||
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: - 响应数据模型
|
||||
|
||||
/// 最新动态响应结构
|
||||
struct MomentsLatestResponse: Codable, Equatable {
|
||||
struct MomentsLatestResponse: Codable, Equatable, Sendable {
|
||||
let code: Int
|
||||
let message: String
|
||||
let data: MomentsListData?
|
||||
@@ -12,18 +12,17 @@ struct MomentsLatestResponse: Codable, Equatable {
|
||||
}
|
||||
|
||||
/// 动态列表数据
|
||||
struct MomentsListData: Codable, Equatable {
|
||||
struct MomentsListData: Codable, Equatable, Sendable {
|
||||
let dynamicList: [MomentsInfo]
|
||||
let nextDynamicId: Int
|
||||
}
|
||||
|
||||
/// 动态信息结构
|
||||
struct MomentsInfo: Codable, Equatable {
|
||||
public struct MomentsInfo: Codable, Equatable, Sendable {
|
||||
let dynamicId: Int
|
||||
let uid: Int
|
||||
let nick: String
|
||||
let avatar: String
|
||||
let gender: Int
|
||||
let type: Int
|
||||
let content: String
|
||||
let likeCount: Int
|
||||
@@ -31,52 +30,47 @@ struct MomentsInfo: Codable, Equatable {
|
||||
let commentCount: Int
|
||||
let publishTime: Int
|
||||
let worldId: Int
|
||||
let squareTop: Int
|
||||
let topicTop: Int
|
||||
let newUser: Bool
|
||||
let defUser: Int
|
||||
let status: Int
|
||||
let scene: String
|
||||
// data.md 里部分字段可选
|
||||
let playCount: Int?
|
||||
let dynamicResList: [MomentsPicture]?
|
||||
// 以下字段后端未返回,全部可选
|
||||
let gender: Int?
|
||||
let squareTop: Int?
|
||||
let topicTop: Int?
|
||||
let newUser: Bool?
|
||||
let defUser: Int?
|
||||
let scene: String?
|
||||
let userVipInfoVO: UserVipInfo?
|
||||
|
||||
// 头饰相关 - 全部可选
|
||||
let headwearPic: String?
|
||||
let headwearEffect: String?
|
||||
let headwearType: Int?
|
||||
let headwearName: String?
|
||||
let headwearId: Int?
|
||||
|
||||
// 等级相关 - 全部可选
|
||||
let experLevelPic: String?
|
||||
let charmLevelPic: String?
|
||||
|
||||
// 其他可选字段
|
||||
let isCustomWord: Bool?
|
||||
let labelList: [String]?
|
||||
|
||||
// 计算属性:将Int转换为Bool
|
||||
var isSquareTop: Bool { squareTop != 0 }
|
||||
var isTopicTop: Bool { topicTop != 0 }
|
||||
|
||||
// 计算属性:格式化时间戳
|
||||
// 计算属性
|
||||
var isSquareTop: Bool { (squareTop ?? 0) != 0 }
|
||||
var isTopicTop: Bool { (topicTop ?? 0) != 0 }
|
||||
var formattedPublishTime: Date {
|
||||
Date(timeIntervalSince1970: TimeInterval(publishTime) / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 动态图片信息
|
||||
struct MomentsPicture: Codable, Equatable {
|
||||
let id: Int
|
||||
let resUrl: String
|
||||
let format: String
|
||||
let width: Int
|
||||
let height: Int
|
||||
struct MomentsPicture: Codable, Equatable, Sendable {
|
||||
let id: Int?
|
||||
let resUrl: String?
|
||||
let format: String?
|
||||
let width: Int?
|
||||
let height: Int?
|
||||
let resDuration: Int? // 可选字段,因为有些图片没有这个字段
|
||||
}
|
||||
|
||||
/// 用户VIP信息 - 完整版本,所有字段都是可选的
|
||||
struct UserVipInfo: Codable, Equatable {
|
||||
struct UserVipInfo: Codable, Equatable, Sendable {
|
||||
let vipLevel: Int?
|
||||
let vipName: String?
|
||||
let vipIcon: String?
|
||||
@@ -157,4 +151,133 @@ struct LatestDynamicsRequest: APIRequestProtocol {
|
||||
// Loading 配置
|
||||
var shouldShowLoading: Bool { true }
|
||||
var shouldShowError: Bool { true }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 发布动态 API 请求与响应
|
||||
|
||||
/// 动态图片资源信息
|
||||
struct ResListItem: Codable, Equatable {
|
||||
let resUrl: String
|
||||
let width: Int
|
||||
let height: Int
|
||||
let format: String
|
||||
}
|
||||
|
||||
/// 发布动态请求
|
||||
struct PublishFeedRequest: APIRequestProtocol {
|
||||
typealias Response = PublishFeedResponse
|
||||
|
||||
let endpoint: String = APIEndpoint.publishFeed.path
|
||||
let method: HTTPMethod = .POST
|
||||
|
||||
let content: String
|
||||
let uid: String
|
||||
let type: String
|
||||
var pub_sign: String
|
||||
let resList: [ResListItem]?
|
||||
|
||||
var queryParameters: [String: String]? { nil }
|
||||
var bodyParameters: [String: Any]? {
|
||||
var params: [String: Any] = [
|
||||
"content": content,
|
||||
"uid": uid,
|
||||
"type": type,
|
||||
"pub_sign": pub_sign
|
||||
]
|
||||
if let resList = resList, !resList.isEmpty {
|
||||
params["resList"] = resList.map { [
|
||||
"resUrl": $0.resUrl,
|
||||
"width": $0.width,
|
||||
"height": $0.height,
|
||||
"format": $0.format
|
||||
] }
|
||||
}
|
||||
return params
|
||||
}
|
||||
var includeBaseParameters: Bool { true }
|
||||
var shouldShowLoading: Bool { true }
|
||||
var shouldShowError: Bool { true }
|
||||
|
||||
/// async 工厂方法,主线程生成 pub_sign
|
||||
static func make(content: String, uid: String, type: String = "0", resList: [ResListItem]? = nil) async -> PublishFeedRequest {
|
||||
let base = await MainActor.run { BaseRequest() }
|
||||
var mutableBase = base
|
||||
mutableBase.generateSignature(with: [
|
||||
"content": content,
|
||||
"uid": uid,
|
||||
"type": type
|
||||
])
|
||||
return PublishFeedRequest(
|
||||
content: content,
|
||||
uid: uid,
|
||||
type: type,
|
||||
pub_sign: mutableBase.pubSign,
|
||||
resList: resList
|
||||
)
|
||||
}
|
||||
|
||||
/// 禁止外部直接调用
|
||||
private init(content: String, uid: String, type: String, pub_sign: String, resList: [ResListItem]?) {
|
||||
self.content = content
|
||||
self.uid = uid
|
||||
self.type = type
|
||||
self.pub_sign = pub_sign
|
||||
self.resList = resList
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布动态响应
|
||||
struct PublishFeedResponse: Codable, Equatable {
|
||||
let code: Int
|
||||
let message: String
|
||||
let data: PublishFeedData?
|
||||
let timestamp: Int?
|
||||
}
|
||||
|
||||
/// 发布动态返回数据
|
||||
struct PublishFeedData: Codable, Equatable {
|
||||
let dynamicId: Int?
|
||||
}
|
||||
|
||||
// MARK: - 我的动态 API 请求
|
||||
|
||||
/// 我的动态响应结构
|
||||
struct MyMomentsResponse: Codable, Equatable, Sendable {
|
||||
let code: Int
|
||||
let message: String
|
||||
let data: [MomentsInfo]?
|
||||
let timestamp: Int?
|
||||
}
|
||||
|
||||
struct GetMyDynamicRequest: APIRequestProtocol {
|
||||
typealias Response = MyMomentsResponse
|
||||
|
||||
let endpoint: String = APIEndpoint.getMyDynamic.path
|
||||
let method: HTTPMethod = .POST
|
||||
|
||||
let fromUid: Int
|
||||
let uid: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
|
||||
init(fromUid: Int, uid: Int, page: Int = 1, pageSize: Int = 20) {
|
||||
self.fromUid = fromUid
|
||||
self.uid = uid
|
||||
self.page = page
|
||||
self.pageSize = pageSize
|
||||
}
|
||||
|
||||
var queryParameters: [String: String]? {
|
||||
[
|
||||
"fromUid": String(fromUid),
|
||||
"uid": String(uid),
|
||||
"page": String(page),
|
||||
"pageSize": String(pageSize)
|
||||
]
|
||||
}
|
||||
|
||||
var bodyParameters: [String: Any]? { nil }
|
||||
var includeBaseParameters: Bool { true }
|
||||
var shouldShowLoading: Bool { true }
|
||||
var shouldShowError: Bool { true }
|
||||
}
|
||||
|
@@ -146,29 +146,209 @@ struct IDLoginData: Codable, Equatable {
|
||||
|
||||
// MARK: - User Info Model
|
||||
struct UserInfo: Codable, Equatable {
|
||||
let userId: String?
|
||||
let username: String?
|
||||
let nickname: String?
|
||||
let uid: Int?
|
||||
let userId: String? // 兼容旧字段
|
||||
let nick: String?
|
||||
let nickname: String? // 兼容旧字段
|
||||
let avatar: String?
|
||||
let email: String?
|
||||
let phone: String?
|
||||
let status: String?
|
||||
let createTime: String?
|
||||
let updateTime: String?
|
||||
|
||||
let region: String?
|
||||
let regionDesc: String?
|
||||
let gender: Int?
|
||||
let birth: Int64?
|
||||
let userDesc: String?
|
||||
let userLevelVo: UserLevelVo?
|
||||
let userVipInfoVO: UserVipInfoVO?
|
||||
let medalsPic: [MedalsPic]?
|
||||
let userHeadwear: UserHeadwear?
|
||||
let privatePhoto: [PrivatePhoto]?
|
||||
let createTime: Int64?
|
||||
let phoneAreaCode: String?
|
||||
let erbanNo: Int?
|
||||
let isCertified: Bool?
|
||||
let isBindPhone: Bool?
|
||||
let isBindApple: Bool?
|
||||
let isBindPasswd: Bool?
|
||||
let isBindPaymentPwd: Bool?
|
||||
let banAccount: Bool?
|
||||
let visitNum: Int?
|
||||
let fansNum: Int?
|
||||
let followNum: Int?
|
||||
let visitHide: Bool?
|
||||
let visitListView: Bool?
|
||||
let newUser: Bool?
|
||||
let defUser: Int?
|
||||
let platformRole: Int?
|
||||
let bindType: Int?
|
||||
let showLimitCharge: Bool?
|
||||
let uploadGifAvatarPrice: Int?
|
||||
let hasRegPacket: Bool?
|
||||
let hasPrettyErbanNo: Bool?
|
||||
let hasSuperRole: Bool?
|
||||
let isRechargeUser: Bool?
|
||||
let isFirstCharge: Bool?
|
||||
let fromSayHelloChannel: Bool?
|
||||
let partitionId: Int?
|
||||
let useStatus: Int?
|
||||
let micNickColor: String?
|
||||
let micCircle: String?
|
||||
let audioCard: AudioCard?
|
||||
let userInfoSkillVo: UserInfoSkillVo?
|
||||
let userInfoCardPic: String?
|
||||
let iosBubbleUrl: String?
|
||||
let androidBubbleUrl: String?
|
||||
let status: String? // 兼容旧字段
|
||||
let username: String? // 兼容旧字段
|
||||
let email: String? // 兼容旧字段
|
||||
let phone: String? // 兼容旧字段
|
||||
let updateTime: String? // 兼容旧字段
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case uid
|
||||
case userId = "user_id"
|
||||
case username
|
||||
case nick
|
||||
case nickname
|
||||
case avatar
|
||||
case region
|
||||
case regionDesc
|
||||
case gender
|
||||
case birth
|
||||
case userDesc
|
||||
case userLevelVo
|
||||
case userVipInfoVO
|
||||
case medalsPic
|
||||
case userHeadwear
|
||||
case privatePhoto
|
||||
case createTime
|
||||
case phoneAreaCode
|
||||
case erbanNo
|
||||
case isCertified
|
||||
case isBindPhone
|
||||
case isBindApple
|
||||
case isBindPasswd
|
||||
case isBindPaymentPwd
|
||||
case banAccount
|
||||
case visitNum
|
||||
case fansNum
|
||||
case followNum
|
||||
case visitHide
|
||||
case visitListView
|
||||
case newUser
|
||||
case defUser
|
||||
case platformRole
|
||||
case bindType
|
||||
case showLimitCharge
|
||||
case uploadGifAvatarPrice
|
||||
case hasRegPacket
|
||||
case hasPrettyErbanNo
|
||||
case hasSuperRole
|
||||
case isRechargeUser
|
||||
case isFirstCharge
|
||||
case fromSayHelloChannel
|
||||
case partitionId
|
||||
case useStatus
|
||||
case micNickColor
|
||||
case micCircle
|
||||
case audioCard
|
||||
case userInfoSkillVo
|
||||
case userInfoCardPic
|
||||
case iosBubbleUrl
|
||||
case androidBubbleUrl
|
||||
case status
|
||||
case username
|
||||
case email
|
||||
case phone
|
||||
case status
|
||||
case createTime = "create_time"
|
||||
case updateTime = "update_time"
|
||||
case updateTime
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 嵌套对象结构体
|
||||
struct UserLevelVo: Codable, Equatable {
|
||||
let experUrl: String?
|
||||
let charmLevelSeq: Int?
|
||||
let experLevelName: String?
|
||||
let charmLevelName: String?
|
||||
let charmAmount: Int?
|
||||
let experLevelGrp: String?
|
||||
let charmUrl: String?
|
||||
let experLevelSeq: Int?
|
||||
let experAmount: Int?
|
||||
let charmLevelGrp: String?
|
||||
}
|
||||
|
||||
struct UserVipInfoVO: Codable, Equatable {
|
||||
let vipIcon: String?
|
||||
let nameplateId: Int?
|
||||
let vipLogo: String?
|
||||
let userCardBG: String?
|
||||
let preventKick: Bool?
|
||||
let preventTrace: Bool?
|
||||
let preventFollow: Bool?
|
||||
let micNickColour: String?
|
||||
let micCircle: String?
|
||||
let enterRoomEffects: String?
|
||||
let medalSeat: Int?
|
||||
let friendNickColour: String?
|
||||
let visitHide: Bool?
|
||||
let visitListView: Bool?
|
||||
let privateChatLimit: Bool?
|
||||
let nameplateUrl: String?
|
||||
let roomPicScreen: Bool?
|
||||
let uploadGifAvatar: Bool?
|
||||
let expireTime: Int64?
|
||||
let enterHide: Bool?
|
||||
let vipLevel: Int?
|
||||
let vipName: String?
|
||||
}
|
||||
|
||||
struct MedalsPic: Codable, Equatable {
|
||||
let picUrl: String?
|
||||
let mp4Url: String?
|
||||
}
|
||||
|
||||
struct UserHeadwear: Codable, Equatable {
|
||||
let expireTime: Int64?
|
||||
let renewPrice: Int?
|
||||
let uid: Int?
|
||||
let comeFrom: Int?
|
||||
let labelType: Int?
|
||||
let limitDesc: String?
|
||||
let redirectLink: String?
|
||||
let headwearId: Int?
|
||||
let buyTime: Int64?
|
||||
let pic: String?
|
||||
let used: Bool?
|
||||
let price: Int?
|
||||
let originalPrice: Int?
|
||||
let type: Int?
|
||||
let days: Int?
|
||||
let headwearName: String?
|
||||
let effect: String?
|
||||
let expireDays: Int?
|
||||
let status: Int?
|
||||
}
|
||||
|
||||
struct PrivatePhoto: Codable, Equatable {
|
||||
let seqNo: Int?
|
||||
let photoUrl: String?
|
||||
let createTime: Int64?
|
||||
let review: Bool?
|
||||
let pid: Int?
|
||||
}
|
||||
|
||||
struct AudioCard: Codable, Equatable {
|
||||
let uid: Int?
|
||||
let status: Int?
|
||||
}
|
||||
|
||||
struct UserInfoSkillVo: Codable, Equatable {
|
||||
let liveTag: Bool?
|
||||
let liveSkillVoList: [LiveSkillVo]?
|
||||
}
|
||||
|
||||
struct LiveSkillVo: Codable, Equatable {
|
||||
// 具体字段根据API返回补充,这里暂留空
|
||||
}
|
||||
|
||||
// MARK: - Login Helper
|
||||
struct LoginHelper {
|
||||
|
||||
@@ -413,3 +593,66 @@ extension LoginHelper {
|
||||
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 @@
|
||||
## 📝 给继任者的详细工作交接说明
|
||||
|
||||
亲爱的继任者,我刚刚为这个 **yana iOS 项目**完成了一个完整的图片缓存优化工作。以下是关键信息:
|
||||
|
||||
### 🎯 已完成的核心工作
|
||||
|
||||
1. **解决了重大性能问题**:
|
||||
- **问题**:FeedView 中图片每次滚动都重新加载,用户体验极差
|
||||
- **原因**:AsyncImage 缓存不足,没有预加载机制,cell 重用时图片丢失
|
||||
|
||||
2. **创建了企业级图片缓存系统**:
|
||||
- **文件**:`yana/Utils/ImageCacheManager.swift`
|
||||
- **功能**:三层缓存(内存+磁盘+网络)+ 智能预加载 + 任务去重
|
||||
|
||||
3. **优化了 FeedView 架构**:
|
||||
- **文件**:`yana/Views/FeedView.swift`
|
||||
- **改进**:使用 `CachedAsyncImage` 替代 `AsyncImage`,添加预加载机制
|
||||
|
||||
### ✅ 技术架构详情
|
||||
|
||||
#### **ImageCacheManager 核心特性**:
|
||||
- **内存缓存**:NSCache,50MB 限制,100张图片
|
||||
- **磁盘缓存**:Documents/ImageCache,100MB 限制,SHA256 文件名
|
||||
- **预加载**:当前位置前后2个动态的所有图片
|
||||
- **任务去重**:同一图片多次请求共享下载任务
|
||||
|
||||
#### **CachedAsyncImage 组件**:
|
||||
- **缓存优先级**:内存 → 磁盘 → 网络
|
||||
- **异步加载**:不阻塞主线程
|
||||
- **SwiftUI 兼容**:完全兼容现有 AsyncImage 语法
|
||||
|
||||
#### **FeedView 优化**:
|
||||
- **OptimizedDynamicCardView**:使用缓存图片组件
|
||||
- **OptimizedImageGrid**:优化的图片网格
|
||||
- **智能预加载**:onAppear 时触发相邻内容预加载
|
||||
|
||||
### 🔧 重要的技术细节
|
||||
|
||||
1. **哈希冲突解决**:
|
||||
- 项目中已有 `String+MD5.swift` 文件
|
||||
- 使用现有的 `sha256()` 和 `md5()` 方法,避免重复声明
|
||||
|
||||
2. **兼容性处理**:
|
||||
- iOS 13+:使用 CryptoKit 的 SHA256
|
||||
- iOS 13以下:使用 CommonCrypto 的 MD5
|
||||
|
||||
3. **Bridging Header 配置**:
|
||||
- 已添加 `#import <CommonCrypto/CommonCrypto.h>`
|
||||
|
||||
### 🚀 性能提升效果
|
||||
|
||||
| 优化前 | 优化后 |
|
||||
|--------|--------|
|
||||
| ❌ 每次滚动重新加载图片 | ✅ 缓存图片瞬间显示 |
|
||||
| ❌ 频繁网络请求 | ✅ 大幅减少网络请求 |
|
||||
| ❌ 用户体验差 | ✅ 流畅滚动体验 |
|
||||
|
||||
### 📋 项目上下文回顾
|
||||
|
||||
1. **API 功能已完成**:
|
||||
- 动态内容 API 集成完毕(DynamicsModels.swift + FeedFeature.swift)
|
||||
- 数据解析问题已解决(类型匹配修复)
|
||||
- TCA 架构状态管理正常工作
|
||||
|
||||
2. **当前状态**:
|
||||
- ✅ 编译成功
|
||||
- ✅ API 数据正常显示
|
||||
- ✅ 图片缓存系统就绪
|
||||
- ✅ 性能优化完成
|
||||
|
||||
### 🔍 可能的后续工作
|
||||
|
||||
用户可能需要:
|
||||
1. **功能扩展**:点赞、评论、分享等交互功能
|
||||
2. **UI 优化**:更丰富的动画效果、主题切换
|
||||
3. **性能监控**:添加缓存命中率统计、内存使用监控
|
||||
4. **错误处理**:网络异常时的重试机制优化
|
||||
|
||||
### 💡 重要提醒
|
||||
|
||||
- **用户是中文开发者**:需要中文回复,使用 Chain-of-Thought 思考过程
|
||||
- **项目基于 iOS 15.6**:注意兼容性要求
|
||||
- **TCA 架构**:遵循项目现有的 TCA 模式
|
||||
- **图片缓存系统**:已经是生产就绪的企业级方案,无需重构
|
||||
|
||||
### 🎉 工作成果
|
||||
|
||||
这次优化彻底解决了图片重复加载的性能问题,用户现在可以享受流畅的滚动体验。缓存系统设计完善,支持大规模图片内容,为后续功能扩展奠定了坚实基础。
|
||||
|
||||
**继任者,你接手的是一个功能完整、性能优秀的动态内容展示系统!** 🚀
|
||||
|
||||
祝你工作顺利!
|
||||
📦 Response Data:
|
||||
{
|
||||
"code" : 200,
|
||||
"message" : "success",
|
||||
"data" : [
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 267,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753182147000,
|
||||
"status" : 0,
|
||||
"content" : "我"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"dynamicResList" : [
|
||||
{
|
||||
"height" : 3024,
|
||||
"id" : 443,
|
||||
"width" : 4032,
|
||||
"resUrl" : "https:\/\/image.molistar.xyz\/images\/C32EB0F8-CBF5-4F4B-8114-C3C7E1AF192F.jpg",
|
||||
"format" : "jpeg"
|
||||
}
|
||||
],
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 2,
|
||||
"dynamicId" : 266,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753181890000,
|
||||
"status" : 0,
|
||||
"content" : ""
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"dynamicResList" : [
|
||||
{
|
||||
"height" : 828,
|
||||
"id" : 442,
|
||||
"width" : 828,
|
||||
"resUrl" : "https:\/\/image.molistar.xyz\/images\/1E8FE811-1989-4337-BDEE-63554F92A686.jpg",
|
||||
"format" : "jpeg"
|
||||
}
|
||||
],
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 2,
|
||||
"dynamicId" : 265,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753181143000,
|
||||
"status" : 0,
|
||||
"content" : "大"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"dynamicResList" : [
|
||||
{
|
||||
"height" : 3024,
|
||||
"id" : 440,
|
||||
"width" : 4032,
|
||||
"resUrl" : "https:\/\/https:\/\/image.molistar.xyz\/images\/DF8E655B-2F63-4B34-90B3-13C8A812245C.jpg",
|
||||
"format" : "jpeg"
|
||||
},
|
||||
{
|
||||
"height" : 1792,
|
||||
"id" : 441,
|
||||
"width" : 828,
|
||||
"resUrl" : "https:\/\/https:\/\/image.molistar.xyz\/images\/D869C761-59CC-4E6B-BB2A-74F87D4A4979.jpg",
|
||||
"format" : "jpeg"
|
||||
}
|
||||
],
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 2,
|
||||
"dynamicId" : 264,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753180835000,
|
||||
"status" : 0,
|
||||
"content" : "好"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"dynamicResList" : [
|
||||
{
|
||||
"height" : 1792,
|
||||
"id" : 438,
|
||||
"width" : 828,
|
||||
"resUrl" : "https:\/\/image.molistar.xyz\/image\/9d5c8e10eb0d228a26ec4e8d58b41c38.jpeg",
|
||||
"format" : "jpeg"
|
||||
},
|
||||
{
|
||||
"height" : 1792,
|
||||
"id" : 439,
|
||||
"width" : 828,
|
||||
"resUrl" : "https:\/\/image.molistar.xyz\/image\/9ab8dff9f5ffbb4d65998822dd126794.jpeg",
|
||||
"format" : "jpeg"
|
||||
}
|
||||
],
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 2,
|
||||
"dynamicId" : 263,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753180130000,
|
||||
"status" : 0,
|
||||
"content" : "猜猜猜"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 262,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753168392000,
|
||||
"status" : 0,
|
||||
"content" : "他哥哥哥哥哥哥"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 261,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753168329000,
|
||||
"status" : 0,
|
||||
"content" : "一直以为自己是真的"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 260,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753167661000,
|
||||
"status" : 0,
|
||||
"content" : "在意那些是自己一"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 259,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753166596000,
|
||||
"status" : 0,
|
||||
"content" : "哈哈我觉得这个世界"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 258,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753166592000,
|
||||
"status" : 0,
|
||||
"content" : "哈哈我觉得这个世界"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 257,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753166298000,
|
||||
"status" : 0,
|
||||
"content" : "哈哈哈哈更"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 256,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753165531000,
|
||||
"status" : 0,
|
||||
"content" : "不不不不不"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 255,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1753156105000,
|
||||
"status" : 0,
|
||||
"content" : "你有什么"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 254,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1752650142000,
|
||||
"status" : 0,
|
||||
"content" : "igvigciycoyvcoyvyovoy突袭陶瓷陶瓷陶瓷陶瓷陶瓷陶瓷陶瓷"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 247,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1742801936000,
|
||||
"status" : 0,
|
||||
"content" : "vicigiigohvhveerr让你表弟姐姐接你吧多半都不\n\n\n\n代表脯肉吧多半日品牌狠批人品很频频频频噢……在一起的时候就是一次又来了就可以😌!我们一起加油呀!我要好好学习📑!你好开心🥳、在一起的时候就像一只手握在一起\n"
|
||||
},
|
||||
{
|
||||
"isLike" : false,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"worldId" : -1,
|
||||
"likeCount" : 0,
|
||||
"type" : 0,
|
||||
"dynamicId" : 206,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1726834519000,
|
||||
"status" : 1,
|
||||
"content" : "爸爸不会后悔就"
|
||||
},
|
||||
{
|
||||
"isLike" : true,
|
||||
"uid" : 3184,
|
||||
"playCount" : 0,
|
||||
"dynamicResList" : [
|
||||
{
|
||||
"height" : 500,
|
||||
"id" : 355,
|
||||
"width" : 500,
|
||||
"resUrl" : "https:\/\/image.pekolive.com\/image\/0c091078d01305f3144ab3352a9fe21a.jpeg",
|
||||
"format" : "jpeg"
|
||||
},
|
||||
{
|
||||
"height" : 328,
|
||||
"id" : 356,
|
||||
"width" : 440,
|
||||
"resUrl" : "https:\/\/image.pekolive.com\/image\/8cdbbab3a0e6df7389f2d2671ee48bc3.jpeg",
|
||||
"format" : "jpeg"
|
||||
}
|
||||
],
|
||||
"worldId" : -1,
|
||||
"likeCount" : 1,
|
||||
"type" : 2,
|
||||
"dynamicId" : 205,
|
||||
"nick" : "hansome",
|
||||
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||
"commentCount" : 0,
|
||||
"publishTime" : 1726834050000,
|
||||
"status" : 1,
|
||||
"content" : "寄件人、一直以为自己是什么地方玩不"
|
||||
}
|
||||
],
|
||||
"timestamp" : 1753256129947
|
||||
}
|
||||
=====================================
|
@@ -2,7 +2,9 @@ import UIKit
|
||||
//import NIMSDK
|
||||
|
||||
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)
|
||||
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 {
|
||||
typealias Response = PublishDynamicResponse
|
||||
let endpoint: String = "/dynamic/square/publish"
|
||||
let endpoint: String = APIEndpoint.publishFeed.path
|
||||
let method: HTTPMethod = .POST
|
||||
let includeBaseParameters: Bool = true
|
||||
let queryParameters: [String: String]? = nil
|
||||
|
@@ -160,10 +160,20 @@ struct EMailLoginFeature {
|
||||
case .loginResponse(.success(let accountModel)):
|
||||
state.isLoading = false
|
||||
state.loginStep = .completed
|
||||
// Effect 保存AccountModel并发送通知
|
||||
// Effect 保存AccountModel并获取用户信息
|
||||
return .run { _ in
|
||||
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)):
|
||||
|
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 ComposableArchitecture
|
||||
|
||||
struct FeedListFeature: Reducer {
|
||||
@Reducer
|
||||
struct FeedListFeature {
|
||||
@Dependency(\.apiService) var apiService
|
||||
struct State: Equatable {
|
||||
var isFirstLoad: Bool = true
|
||||
var feeds: [Feed] = [] // 预留 feed 内容
|
||||
var isLoading: Bool = false
|
||||
var error: String? = nil
|
||||
var isEditFeedPresented: Bool = false // 新增:控制 EditFeedView 弹窗
|
||||
// 新增:动态内容
|
||||
var moments: [MomentsInfo] = []
|
||||
// 新增:只加载一次标志
|
||||
var isLoaded: Bool = false
|
||||
// 分页相关
|
||||
var currentPage: Int = 1
|
||||
var hasMore: Bool = true
|
||||
var isLoadingMore: Bool = false
|
||||
}
|
||||
|
||||
enum Action: Equatable {
|
||||
case onAppear
|
||||
case reload
|
||||
case loadMore
|
||||
case loadMoreResponse(TaskResult<MomentsLatestResponse>)
|
||||
case editFeedButtonTapped // 新增:点击 add 按钮
|
||||
case editFeedDismissed // 新增:关闭编辑页
|
||||
// 新增:动态内容相关
|
||||
case fetchFeeds
|
||||
case fetchFeedsResponse(TaskResult<MomentsLatestResponse>)
|
||||
// 预留后续 Action
|
||||
}
|
||||
|
||||
func reduce(into state: inout State, action: Action) -> Effect<Action> {
|
||||
switch action {
|
||||
case .onAppear:
|
||||
// 预留数据加载逻辑
|
||||
return .none
|
||||
guard state.isFirstLoad else { return .none }
|
||||
state.isFirstLoad = false
|
||||
return .send(.fetchFeeds)
|
||||
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:
|
||||
// 预留分页加载逻辑
|
||||
// 上拉加载更多
|
||||
guard state.hasMore, !state.isLoadingMore, !state.isLoading else { return .none }
|
||||
state.isLoadingMore = true
|
||||
let lastDynamicId: String = {
|
||||
if let last = state.moments.last {
|
||||
return String(last.dynamicId)
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}()
|
||||
return .run { [apiService] send in
|
||||
await send(.loadMoreResponse(TaskResult {
|
||||
let request = LatestDynamicsRequest(dynamicId: lastDynamicId, pageSize: 20, types: [.text, .picture])
|
||||
return try await apiService.request(request)
|
||||
}))
|
||||
}
|
||||
case let .loadMoreResponse(.success(response)):
|
||||
state.isLoadingMore = false
|
||||
if let list = response.data?.dynamicList {
|
||||
if list.isEmpty {
|
||||
state.hasMore = false
|
||||
} else {
|
||||
state.moments.append(contentsOf: list)
|
||||
state.currentPage += 1
|
||||
state.hasMore = (list.count >= 20)
|
||||
}
|
||||
state.error = nil
|
||||
} else {
|
||||
state.hasMore = false
|
||||
state.error = response.message
|
||||
}
|
||||
return .none
|
||||
case let .loadMoreResponse(.failure(error)):
|
||||
state.isLoadingMore = false
|
||||
state.hasMore = false
|
||||
state.error = error.localizedDescription
|
||||
return .none
|
||||
case .fetchFeeds:
|
||||
state.isLoading = true
|
||||
state.error = nil
|
||||
// 发起 API 请求
|
||||
return .run { [apiService] send in
|
||||
await send(.fetchFeedsResponse(TaskResult {
|
||||
let request = LatestDynamicsRequest(dynamicId: "", pageSize: 20, types: [.text, .picture])
|
||||
return try await apiService.request(request)
|
||||
}))
|
||||
}
|
||||
case let .fetchFeedsResponse(.success(response)):
|
||||
state.isLoading = false
|
||||
if let list = response.data?.dynamicList {
|
||||
state.moments = list
|
||||
state.error = nil
|
||||
state.currentPage = 1
|
||||
state.hasMore = (list.count >= 20)
|
||||
} else {
|
||||
state.moments = []
|
||||
state.error = response.message
|
||||
state.hasMore = false
|
||||
}
|
||||
return .none
|
||||
case let .fetchFeedsResponse(.failure(error)):
|
||||
state.isLoading = false
|
||||
state.moments = []
|
||||
state.error = error.localizedDescription
|
||||
state.hasMore = false
|
||||
return .none
|
||||
case .editFeedButtonTapped:
|
||||
state.isEditFeedPresented = true
|
||||
|
@@ -1,33 +1,22 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
|
||||
@Reducer
|
||||
struct HomeFeature {
|
||||
struct HomeFeature: Reducer {
|
||||
enum Route: Equatable {
|
||||
case createFeed
|
||||
}
|
||||
|
||||
@ObservableState
|
||||
struct State: Equatable, Sendable {
|
||||
|
||||
struct State: Equatable {
|
||||
var isInitialized = false
|
||||
var userInfo: UserInfo?
|
||||
var accountModel: AccountModel?
|
||||
var error: String?
|
||||
|
||||
// 设置页面相关状态
|
||||
var isSettingPresented = false
|
||||
var settingState = SettingFeature.State()
|
||||
|
||||
// 新增:Feed 状态
|
||||
var feedState = FeedFeature.State()
|
||||
|
||||
// 新增:登出状态
|
||||
var meDynamic = MeDynamicFeature.State(uid: 0)
|
||||
var isLoggedOut = false
|
||||
|
||||
// 新增:路由状态
|
||||
var route: Route? = nil
|
||||
}
|
||||
|
||||
|
||||
@CasePathable
|
||||
enum Action: Equatable {
|
||||
case onAppear
|
||||
@@ -37,91 +26,67 @@ struct HomeFeature {
|
||||
case accountModelLoaded(AccountModel?)
|
||||
case logoutTapped
|
||||
case logout
|
||||
|
||||
// 设置页面相关actions
|
||||
case settingDismissed
|
||||
case setting(SettingFeature.Action)
|
||||
|
||||
// 新增:Feed actions
|
||||
case feed(FeedFeature.Action)
|
||||
|
||||
// 新增:登出完成
|
||||
case meDynamic(MeDynamicFeature.Action)
|
||||
case logoutCompleted
|
||||
|
||||
// 新增:路由 actions
|
||||
case showCreateFeed
|
||||
case createFeedDismissed
|
||||
}
|
||||
|
||||
var body: some Reducer<State, Action> {
|
||||
// Reducer<State, Action>.combine([
|
||||
// Reducer { state, action in
|
||||
// switch action {
|
||||
// case .onAppear:
|
||||
// guard !state.isInitialized else {
|
||||
// return Effect.none
|
||||
// }
|
||||
// state.isInitialized = true
|
||||
// return .concatenate(
|
||||
// .send(.loadUserInfo),
|
||||
// .send(.loadAccountModel)
|
||||
// )
|
||||
// case .loadUserInfo:
|
||||
// return .run { send in
|
||||
// let userInfo = await UserInfoManager.getUserInfo()
|
||||
// await send(.userInfoLoaded(userInfo))
|
||||
// }
|
||||
// case let .userInfoLoaded(userInfo):
|
||||
// state.userInfo = userInfo
|
||||
// return Effect.none
|
||||
// case .loadAccountModel:
|
||||
// return .run { send in
|
||||
// let accountModel = await UserInfoManager.getAccountModel()
|
||||
// await send(.accountModelLoaded(accountModel))
|
||||
// }
|
||||
// case let .accountModelLoaded(accountModel):
|
||||
// state.accountModel = accountModel
|
||||
// return Effect.none
|
||||
// case .logoutTapped:
|
||||
// return .send(.logout)
|
||||
// case .logout:
|
||||
// return .run { send in
|
||||
// await UserInfoManager.clearAllAuthenticationData()
|
||||
// await send(.logoutCompleted)
|
||||
// }
|
||||
// case .logoutCompleted:
|
||||
// state.isLoggedOut = true
|
||||
// return Effect.none
|
||||
// case .settingDismissed:
|
||||
// state.isSettingPresented = false
|
||||
// return Effect.none
|
||||
// case .setting:
|
||||
// return Effect.none
|
||||
// case .showCreateFeed:
|
||||
// state.route = .createFeed
|
||||
// return Effect.none
|
||||
// case .createFeedDismissed:
|
||||
// state.route = nil
|
||||
// return Effect.none
|
||||
// case .feed:
|
||||
// return Effect.none
|
||||
// }
|
||||
// },
|
||||
// Scope(
|
||||
// state: \State.settingState,
|
||||
// action: /Action.setting,
|
||||
// child: SettingFeature()
|
||||
// ),
|
||||
// Scope(
|
||||
// state: \State.feedState,
|
||||
// action: /Action.feed,
|
||||
// child: FeedFeature()
|
||||
// )
|
||||
// ])
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Scope(state: \.feedState, action: \.feed) {
|
||||
FeedFeature()
|
||||
}
|
||||
Scope(state: \.meDynamic, action: \.meDynamic) {
|
||||
MeDynamicFeature()
|
||||
}
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .onAppear:
|
||||
guard !state.isInitialized else { return .none }
|
||||
state.isInitialized = true
|
||||
return .concatenate(
|
||||
.send(.loadUserInfo),
|
||||
.send(.loadAccountModel)
|
||||
)
|
||||
case .loadUserInfo:
|
||||
return .run { send in
|
||||
let userInfo = await UserInfoManager.getUserInfo()
|
||||
await send(.userInfoLoaded(userInfo))
|
||||
}
|
||||
case let .userInfoLoaded(userInfo):
|
||||
state.userInfo = userInfo
|
||||
state.meDynamic.uid = userInfo?.uid ?? 0
|
||||
return .none
|
||||
case .loadAccountModel:
|
||||
return .run { send in
|
||||
let accountModel = await UserInfoManager.getAccountModel()
|
||||
await send(.accountModelLoaded(accountModel))
|
||||
}
|
||||
case let .accountModelLoaded(accountModel):
|
||||
state.accountModel = accountModel
|
||||
return .none
|
||||
case .logoutTapped:
|
||||
return .send(.logout)
|
||||
case .logout:
|
||||
return .run { send in
|
||||
await UserInfoManager.clearAllAuthenticationData()
|
||||
await send(.logoutCompleted)
|
||||
}
|
||||
case .logoutCompleted:
|
||||
state.isLoggedOut = true
|
||||
return .none
|
||||
case .feed:
|
||||
return .none
|
||||
case .meDynamic:
|
||||
return .none
|
||||
case .showCreateFeed:
|
||||
state.route = .createFeed
|
||||
return .none
|
||||
case .createFeedDismissed:
|
||||
state.route = nil
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除:未使用的通知名称定义
|
||||
// extension Notification.Name {
|
||||
// static let homeLogout = Notification.Name("homeLogout")
|
||||
// }
|
||||
|
@@ -135,15 +135,24 @@ struct IDLoginFeature {
|
||||
state.loginStep = .completed
|
||||
debugInfoSync("✅ ID 登录完整流程成功")
|
||||
debugInfoSync("🎫 Ticket 获取成功: \(response.ticket ?? "nil")")
|
||||
// --- 并发安全修正:彻底避免 Effect 闭包捕获 state/accountModel ---
|
||||
|
||||
if let ticket = response.ticket, let oldAccountModel = state.accountModel {
|
||||
// 用 withTicket 生成新 struct,闭包只捕获 newAccountModel
|
||||
let newAccountModel = oldAccountModel.withTicket(ticket)
|
||||
state.accountModel = newAccountModel
|
||||
// 只捕获 newAccountModel,绝不捕获 state
|
||||
|
||||
return .run { _ in
|
||||
// 这里不能捕获 state/accountModel,否则 Swift 并发会报错
|
||||
await UserInfoManager.saveAccountModel(newAccountModel)
|
||||
|
||||
// 新增:登录成功后自动获取用户信息
|
||||
debugInfoSync("🔄 登录成功,开始获取用户信息")
|
||||
if let _ = await UserInfoManager.fetchUserInfoFromServer(
|
||||
uid: newAccountModel.uid,
|
||||
apiService: apiService
|
||||
) {
|
||||
debugInfoSync("✅ 用户信息获取成功")
|
||||
} else {
|
||||
debugErrorSync("❌ 用户信息获取失败,但不影响登录流程")
|
||||
}
|
||||
}
|
||||
} else if response.ticket == nil {
|
||||
state.ticketError = "Ticket 为空"
|
||||
|
@@ -10,26 +10,102 @@ struct MainFeature: Reducer {
|
||||
struct State: Equatable {
|
||||
var selectedTab: Tab = .feed
|
||||
var feedList: FeedListFeature.State = .init()
|
||||
var me: MeFeature.State = .init()
|
||||
var accountModel: AccountModel? = nil
|
||||
// 新增:导航路径和设置页面 State
|
||||
var navigationPath: [Destination] = []
|
||||
var appSettingState: AppSettingFeature.State? = nil
|
||||
// 新增:登出标志
|
||||
var isLoggedOut: Bool = false
|
||||
}
|
||||
|
||||
// 新增:导航目标
|
||||
enum Destination: Hashable, Equatable {
|
||||
case test
|
||||
case appSetting
|
||||
}
|
||||
|
||||
@CasePathable
|
||||
enum Action: Equatable {
|
||||
case onAppear
|
||||
case selectTab(Tab)
|
||||
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> {
|
||||
Scope(state: \.feedList, action: \.feedList) {
|
||||
FeedListFeature()
|
||||
}
|
||||
Scope(state: \.me, action: \.me) {
|
||||
MeFeature()
|
||||
}
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
case .onAppear:
|
||||
return .run { send in
|
||||
let accountModel = await UserInfoManager.getAccountModel()
|
||||
await send(.accountModelLoaded(accountModel))
|
||||
}
|
||||
case .selectTab(let tab):
|
||||
state.selectedTab = tab
|
||||
state.navigationPath = []
|
||||
if tab == .other, let uidStr = state.accountModel?.uid, let uid = Int(uidStr), uid > 0 {
|
||||
if state.me.uid != uid {
|
||||
state.me.uid = uid
|
||||
state.me.isFirstLoad = true // 仅当用户切换时才重置首次加载
|
||||
}
|
||||
return .send(.me(.onAppear))
|
||||
}
|
||||
return .none
|
||||
case .feedList:
|
||||
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 authenticationChecked(UserInfoManager.AuthenticationStatus)
|
||||
|
||||
// 新增:用户信息获取 actions
|
||||
case fetchUserInfo
|
||||
case userInfoFetched(Bool)
|
||||
|
||||
// 新增:导航 actions
|
||||
case navigateToLogin
|
||||
case navigateToMain
|
||||
}
|
||||
|
||||
@Dependency(\.apiService) var apiService // 新增:API服务依赖
|
||||
|
||||
var body: some ReducerOf<Self> {
|
||||
Reduce { state, action in
|
||||
switch action {
|
||||
@@ -65,15 +71,31 @@ struct SplashFeature {
|
||||
state.isCheckingAuthentication = false
|
||||
state.authenticationStatus = status
|
||||
|
||||
// 根据认证状态决定导航目标
|
||||
// 根据认证状态决定下一步操作
|
||||
if status.canAutoLogin {
|
||||
debugInfoSync("🎉 自动登录成功,进入主页")
|
||||
return .send(.navigateToMain)
|
||||
debugInfoSync("🎉 自动登录成功,开始获取用户信息")
|
||||
// 新增:认证成功后自动获取用户信息
|
||||
return .send(.fetchUserInfo)
|
||||
} else {
|
||||
debugInfoSync("🔑 需要手动登录")
|
||||
return .send(.navigateToLogin)
|
||||
}
|
||||
|
||||
case .fetchUserInfo:
|
||||
// 新增:获取用户信息
|
||||
return .run { send in
|
||||
let success = await UserInfoManager.autoFetchUserInfoOnAppLaunch(apiService: apiService)
|
||||
await send(.userInfoFetched(success))
|
||||
}
|
||||
|
||||
case let .userInfoFetched(success):
|
||||
if success {
|
||||
debugInfoSync("✅ 用户信息获取成功,进入主页")
|
||||
} else {
|
||||
debugInfoSync("⚠️ 用户信息获取失败,但仍进入主页")
|
||||
}
|
||||
return .send(.navigateToMain)
|
||||
|
||||
case .navigateToLogin:
|
||||
state.navigationDestination = .login
|
||||
return .none
|
||||
|
@@ -2,11 +2,10 @@
|
||||
Localizable.strings
|
||||
yana
|
||||
|
||||
Created on 2024.
|
||||
英文本地化文件
|
||||
English localization file (auto-aligned)
|
||||
*/
|
||||
|
||||
// MARK: - 登录界面
|
||||
// MARK: - Login Screen
|
||||
"login.id_login" = "ID Login";
|
||||
"login.email_login" = "Email Login";
|
||||
"login.app_title" = "E-PARTI";
|
||||
@@ -14,32 +13,32 @@
|
||||
"login.agreement" = "User Service Agreement";
|
||||
"login.policy" = "Privacy Policy";
|
||||
|
||||
// MARK: - 通用按钮
|
||||
// MARK: - Common Buttons
|
||||
"common.login" = "Login";
|
||||
"common.register" = "Register";
|
||||
"common.cancel" = "Cancel";
|
||||
"common.confirm" = "Confirm";
|
||||
"common.ok" = "OK";
|
||||
|
||||
// MARK: - 错误信息
|
||||
// MARK: - Error Messages
|
||||
"error.network" = "Network Error";
|
||||
"error.invalid_input" = "Invalid Input";
|
||||
"error.login_failed" = "Login Failed";
|
||||
|
||||
// MARK: - 占位符文本
|
||||
// MARK: - Placeholders
|
||||
"placeholder.email" = "Enter your email";
|
||||
"placeholder.password" = "Enter your password";
|
||||
"placeholder.username" = "Enter your username";
|
||||
"placeholder.enter_id" = "Please enter ID";
|
||||
"placeholder.enter_password" = "Please enter password";
|
||||
|
||||
// MARK: - ID登录页面
|
||||
// MARK: - ID Login Page
|
||||
"id_login.title" = "ID Login";
|
||||
"id_login.forgot_password" = "Forgot Password?";
|
||||
"id_login.login_button" = "Login";
|
||||
"id_login.logging_in" = "Logging in...";
|
||||
|
||||
// MARK: - 邮箱登录页面
|
||||
// MARK: - Email Login Page
|
||||
"email_login.title" = "Email Login";
|
||||
"email_login.email_required" = "Please enter email";
|
||||
"email_login.invalid_email" = "Please enter a valid email address";
|
||||
@@ -52,13 +51,13 @@
|
||||
"placeholder.enter_email" = "Please enter email";
|
||||
"placeholder.enter_verification_code" = "Please enter verification code";
|
||||
|
||||
// MARK: - 验证和错误信息
|
||||
// MARK: - Validation and Error Messages
|
||||
"validation.id_required" = "Please enter your ID";
|
||||
"validation.password_required" = "Please enter your password";
|
||||
"error.encryption_failed" = "Encryption failed, please try again";
|
||||
"error.login_failed" = "Login failed, please check your credentials";
|
||||
|
||||
// MARK: - 密码恢复页面
|
||||
// MARK: - Password Recovery Page
|
||||
"recover_password.title" = "Recover Password";
|
||||
"recover_password.placeholder_email" = "Please enter email";
|
||||
"recover_password.placeholder_verification_code" = "Please enter verification code";
|
||||
@@ -74,5 +73,60 @@
|
||||
"recover_password.reset_success" = "Password reset successfully";
|
||||
"recover_password.resetting" = "Resetting...";
|
||||
|
||||
// MARK: - 主页
|
||||
"home.title" = "Enjoy your Life Time";
|
||||
// MARK: - Home
|
||||
"home.title" = "Enjoy your Life Time";
|
||||
|
||||
// MARK: - Create Feed
|
||||
"createFeed.enterContent" = "Enter Content";
|
||||
"createFeed.processingImages" = "Processing images...";
|
||||
"createFeed.publishing" = "Publishing...";
|
||||
"createFeed.publish" = "Publish";
|
||||
"createFeed.title" = "Image & Text Publish";
|
||||
|
||||
// MARK: - Edit Feed
|
||||
"editFeed.title" = "Image & Text Edit";
|
||||
"editFeed.publish" = "Publish";
|
||||
"editFeed.enterContent" = "Enter Content";
|
||||
|
||||
// MARK: - Feed List
|
||||
"feedList.title" = "Enjoy your Life Time";
|
||||
"feedList.slogan" = "The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.";
|
||||
|
||||
// MARK: - Feed
|
||||
"feed.title" = "Enjoy your Life Time";
|
||||
"feed.empty" = "No moments yet";
|
||||
"feed.error" = "Error: %@";
|
||||
"feed.retry" = "Retry";
|
||||
"feed.loadingMore" = "Loading more...";
|
||||
"me.title" = "Me";
|
||||
"me.nickname" = "Nickname";
|
||||
"me.id" = "ID: %@";
|
||||
"language.select" = "Select Language";
|
||||
"language.current" = "Current Language";
|
||||
"language.info" = "Language Info";
|
||||
"feed.user" = "User %d";
|
||||
"feed.2hoursago" = "2 hours ago";
|
||||
"feed.demoContent" = "Today is a beautiful day, sharing some little happiness in life. Hope everyone cherishes every moment.";
|
||||
"feed.vip" = "VIP%d";
|
||||
|
||||
// MARK: - Splash
|
||||
"splash.title" = "E-Parti";
|
||||
|
||||
// MARK: - Setting
|
||||
"setting.title" = "Settings";
|
||||
"setting.user" = "User";
|
||||
"setting.language" = "Language Settings";
|
||||
"setting.about" = "About Us";
|
||||
"setting.version" = "Version Info";
|
||||
"setting.logout" = "Logout";
|
||||
|
||||
// MARK: - App Setting
|
||||
"appSetting.title" = "Edit";
|
||||
"appSetting.nickname" = "Nickname";
|
||||
"appSetting.personalInfoPermissions" = "Personal Information and Permissions";
|
||||
"appSetting.help" = "Help";
|
||||
"appSetting.clearCache" = "Clear Cache";
|
||||
"appSetting.checkUpdates" = "Check for Updates";
|
||||
"appSetting.logout" = "Log Out";
|
||||
"appSetting.aboutUs" = "About Us";
|
||||
"appSetting.logoutAccount" = "Log out of account";
|
@@ -76,3 +76,53 @@
|
||||
|
||||
// MARK: - 主页
|
||||
"home.title" = "享受您的生活时光";
|
||||
|
||||
"createFeed.enterContent" = "输入内容";
|
||||
"createFeed.processingImages" = "处理图片中...";
|
||||
"createFeed.publishing" = "发布中...";
|
||||
"createFeed.publish" = "发布";
|
||||
"createFeed.title" = "图文发布";
|
||||
|
||||
"editFeed.title" = "图文发布";
|
||||
"editFeed.publish" = "发布";
|
||||
"editFeed.enterContent" = "输入内容";
|
||||
|
||||
"feedList.title" = "享受您的生活时光";
|
||||
"feedList.slogan" = "疾病如同残酷的统治者,\n而时间是我们最宝贵的财富。\n我们活着的每一刻,都是对不可避免命运的胜利。";
|
||||
|
||||
"feed.title" = "享受您的生活时光";
|
||||
"feed.empty" = "暂无动态内容";
|
||||
"feed.error" = "错误: %@";
|
||||
"feed.retry" = "重试";
|
||||
"feed.loadingMore" = "加载更多...";
|
||||
|
||||
"splash.title" = "E-Parti";
|
||||
|
||||
"setting.title" = "设置";
|
||||
"setting.user" = "用户";
|
||||
"setting.language" = "语言设置";
|
||||
"setting.about" = "关于我们";
|
||||
"setting.version" = "版本信息";
|
||||
"setting.logout" = "退出登录";
|
||||
|
||||
"me.title" = "我的";
|
||||
"me.nickname" = "用户昵称";
|
||||
"me.id" = "ID: %@";
|
||||
"language.select" = "选择语言";
|
||||
"language.current" = "当前语言";
|
||||
"language.info" = "语言信息";
|
||||
"feed.user" = "用户%d";
|
||||
"feed.2hoursago" = "2小时前";
|
||||
"feed.demoContent" = "今天是美好的一天,分享一些生活中的小确幸。希望大家都能珍惜每一个当下的时刻。";
|
||||
"feed.vip" = "VIP%d";
|
||||
|
||||
// MARK: - App Setting
|
||||
"appSetting.title" = "编辑";
|
||||
"appSetting.nickname" = "昵称";
|
||||
"appSetting.personalInfoPermissions" = "个人信息与权限";
|
||||
"appSetting.help" = "帮助";
|
||||
"appSetting.clearCache" = "清除缓存";
|
||||
"appSetting.checkUpdates" = "检查更新";
|
||||
"appSetting.logout" = "退出登录";
|
||||
"appSetting.aboutUs" = "关于我们";
|
||||
"appSetting.logoutAccount" = "退出账户";
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import ComposableArchitecture
|
||||
import QCloudCOSXML
|
||||
|
||||
// MARK: - 腾讯云 COS 管理器
|
||||
|
||||
@@ -15,6 +15,26 @@ class COSManager: ObservableObject {
|
||||
|
||||
private init() {}
|
||||
|
||||
// 幂等初始化标记
|
||||
private static var isCOSInitialized = false
|
||||
|
||||
// 幂等初始化方法
|
||||
private func ensureCOSInitialized(tokenData: TcTokenData) {
|
||||
guard !Self.isCOSInitialized else { return }
|
||||
let configuration = QCloudServiceConfiguration()
|
||||
let endpoint = QCloudCOSXMLEndPoint()
|
||||
endpoint.regionName = tokenData.region
|
||||
endpoint.useHTTPS = true
|
||||
if tokenData.accelerate {
|
||||
endpoint.suffix = "cos.accelerate.myqcloud.com"
|
||||
}
|
||||
configuration.endpoint = endpoint
|
||||
QCloudCOSXMLService.registerDefaultCOSXML(with: configuration)
|
||||
QCloudCOSTransferMangerService.registerDefaultCOSTransferManger(with: configuration)
|
||||
Self.isCOSInitialized = true
|
||||
debugInfoSync("✅ COS服务已初始化,region: \(tokenData.region)")
|
||||
}
|
||||
|
||||
// MARK: - Token 管理
|
||||
|
||||
/// 当前缓存的 Token 信息
|
||||
@@ -102,13 +122,97 @@ class COSManager: ObservableObject {
|
||||
|
||||
/// 获取当前 Token 状态信息
|
||||
func getTokenStatus() -> String {
|
||||
if let cached = cachedToken, let expiration = tokenExpirationDate {
|
||||
if let _ = cachedToken, let expiration = tokenExpirationDate {
|
||||
let isExpired = Date() >= expiration
|
||||
return "Token 状态: \(isExpired ? "已过期" : "有效"), 过期时间: \(expiration)"
|
||||
} else {
|
||||
return "Token 状态: 未缓存"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 上传功能
|
||||
|
||||
/// 上传图片到腾讯云 COS
|
||||
/// - Parameters:
|
||||
/// - imageData: 图片数据
|
||||
/// - apiService: API 服务实例
|
||||
/// - Returns: 上传成功的云地址,如果失败返回 nil
|
||||
func uploadImage(_ imageData: Data, apiService: any APIServiceProtocol & Sendable) async -> String? {
|
||||
guard let tokenData = await getToken(apiService: apiService) else {
|
||||
debugInfoSync("❌ 无法获取 COS Token")
|
||||
return nil
|
||||
}
|
||||
// 上传前确保COS服务已初始化
|
||||
ensureCOSInitialized(tokenData: tokenData)
|
||||
|
||||
// 初始化 COS 配置
|
||||
let credential = QCloudCredential()
|
||||
credential.secretID = tokenData.secretId
|
||||
// 打印secretKey原始内容,去除首尾空白
|
||||
let rawSecretKey = tokenData.secretKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
debugInfoSync("secretKey原始内容: [\(rawSecretKey)]")
|
||||
credential.secretKey = rawSecretKey
|
||||
credential.token = tokenData.sessionToken
|
||||
credential.startDate = tokenData.startDate
|
||||
credential.expirationDate = tokenData.expirationDate
|
||||
|
||||
let request = QCloudCOSXMLUploadObjectRequest<AnyObject>()
|
||||
request.bucket = tokenData.bucket
|
||||
request.regionName = tokenData.region
|
||||
request.credential = credential
|
||||
|
||||
// 生成唯一 key
|
||||
let fileExtension = "jpg" // 假设为 JPG,可根据实际调整
|
||||
let key = "images/\(UUID().uuidString).\(fileExtension)"
|
||||
request.object = key
|
||||
request.body = imageData as AnyObject
|
||||
|
||||
//监听上传进度
|
||||
request.sendProcessBlock = { (bytesSent, totalBytesSent,
|
||||
totalBytesExpectedToSend) in
|
||||
debugInfoSync("\(bytesSent), \(totalBytesSent), \(totalBytesExpectedToSend)")
|
||||
// bytesSent 本次要发送的字节数(一个大文件可能要分多次发送)
|
||||
// totalBytesSent 已发送的字节数
|
||||
// totalBytesExpectedToSend 本次上传要发送的总字节数(即一个文件大小)
|
||||
};
|
||||
|
||||
// 设置加速
|
||||
if tokenData.accelerate {
|
||||
request.enableQuic = true
|
||||
// endpoint 增加 "cos.accelerate.myqcloud.com"
|
||||
}
|
||||
|
||||
// 使用 async/await 包装上传回调
|
||||
return await withCheckedContinuation { continuation in
|
||||
request.setFinish { result, error in
|
||||
if let error = error {
|
||||
debugInfoSync("❌ 图片上传失败: \(error.localizedDescription)")
|
||||
continuation.resume(returning: " ?????????? ")
|
||||
} else {
|
||||
// 构建云地址
|
||||
let domain = tokenData.customDomain.isEmpty ? "\(tokenData.bucket).cos.\(tokenData.region).myqcloud.com" : tokenData.customDomain
|
||||
let prefix = domain.hasPrefix("http") ? "" : "https://"
|
||||
let cloudURL = "\(prefix)\(domain)/\(key)"
|
||||
debugInfoSync("✅ 图片上传成功: \(cloudURL)")
|
||||
continuation.resume(returning: cloudURL)
|
||||
}
|
||||
}
|
||||
QCloudCOSTransferMangerService.defaultCOSTransferManager().uploadObject(request)
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传 UIImage 到腾讯云 COS,自动压缩为 JPEG(0.8)
|
||||
/// - Parameters:
|
||||
/// - image: UIImage 实例
|
||||
/// - apiService: API 服务实例
|
||||
/// - Returns: 上传成功的云地址,如果失败返回 nil
|
||||
func uploadUIImage(_ image: UIImage, apiService: any APIServiceProtocol & Sendable) async -> String? {
|
||||
guard let data = image.jpegData(compressionQuality: 0.8) else {
|
||||
debugInfoSync("❌ 图片压缩失败,无法生成 JPEG 数据")
|
||||
return nil
|
||||
}
|
||||
return await uploadImage(data, apiService: apiService)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 调试扩展
|
||||
@@ -134,4 +238,4 @@ extension COSManager {
|
||||
debugInfoSync("✅ 腾讯云 COS Token 测试完成\n")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -23,4 +23,28 @@ extension Color {
|
||||
let blue = Double(hex & 0xFF) / 255.0
|
||||
self.init(red: red, green: green, blue: blue, opacity: alpha)
|
||||
}
|
||||
}
|
||||
|
||||
init(hexString: String) {
|
||||
let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let a, r, g, b: UInt64
|
||||
switch hex.count {
|
||||
case 3: // RGB (12-bit)
|
||||
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
case 6: // RGB (24-bit)
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8: // ARGB (32-bit)
|
||||
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(a, r, g, b) = (255, 0, 0, 0)
|
||||
}
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double(r) / 255,
|
||||
green: Double(g) / 255,
|
||||
blue: Double(b) / 255,
|
||||
opacity: Double(a) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
|
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
|
||||
|
||||
if store.content.isEmpty {
|
||||
Text("Enter Content")
|
||||
Text(NSLocalizedString("createFeed.enterContent", comment: "Enter Content"))
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
@@ -79,7 +79,7 @@ struct CreateFeedView: View {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
Text("处理图片中...")
|
||||
Text(NSLocalizedString("createFeed.processingImages", comment: "Processing images..."))
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
@@ -111,11 +111,11 @@ struct CreateFeedView: View {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(0.8)
|
||||
Text("发布中...")
|
||||
Text(NSLocalizedString("createFeed.publishing", comment: "Publishing..."))
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
} else {
|
||||
Text("发布")
|
||||
Text(NSLocalizedString("createFeed.publish", comment: "Publish"))
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
@@ -137,7 +137,7 @@ struct CreateFeedView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("图文发布")
|
||||
.navigationTitle(NSLocalizedString("createFeed.title", comment: "Image & Text Publish"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
@@ -168,66 +168,66 @@ struct CreateFeedView: View {
|
||||
}
|
||||
|
||||
// MARK: - iOS 16+ 图片选择网格组件
|
||||
struct ModernImageSelectionGrid: View {
|
||||
let images: [UIImage]
|
||||
let selectedItems: [PhotosPickerItem]
|
||||
let canAddMore: Bool
|
||||
let onItemsChanged: ([PhotosPickerItem]) -> Void
|
||||
let onRemoveImage: (Int) -> Void
|
||||
|
||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
|
||||
|
||||
var body: some View {
|
||||
WithPerceptionTracking {
|
||||
LazyVGrid(columns: columns, spacing: 8) {
|
||||
// 显示已选择的图片
|
||||
ForEach(Array(images.enumerated()), id: \.offset) { index, image in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 100)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
|
||||
// 删除按钮
|
||||
Button(action: {
|
||||
onRemoveImage(index)
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.white)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加图片按钮
|
||||
if canAddMore {
|
||||
PhotosPicker(
|
||||
selection: .init(
|
||||
get: { selectedItems },
|
||||
set: onItemsChanged
|
||||
),
|
||||
maxSelectionCount: 9,
|
||||
matching: .images
|
||||
) {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.white.opacity(0.1))
|
||||
.frame(height: 100)
|
||||
.overlay(
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
//struct ModernImageSelectionGrid: View {
|
||||
// let images: [UIImage]
|
||||
// let selectedItems: [PhotosPickerItem]
|
||||
// let canAddMore: Bool
|
||||
// let onItemsChanged: ([PhotosPickerItem]) -> Void
|
||||
// let onRemoveImage: (Int) -> Void
|
||||
//
|
||||
// private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
|
||||
//
|
||||
// var body: some View {
|
||||
// WithPerceptionTracking {
|
||||
// LazyVGrid(columns: columns, spacing: 8) {
|
||||
// // 显示已选择的图片
|
||||
// ForEach(Array(images.enumerated()), id: \.offset) { index, image in
|
||||
// ZStack(alignment: .topTrailing) {
|
||||
// Image(uiImage: image)
|
||||
// .resizable()
|
||||
// .aspectRatio(contentMode: .fill)
|
||||
// .frame(height: 100)
|
||||
// .clipped()
|
||||
// .cornerRadius(8)
|
||||
//
|
||||
// // 删除按钮
|
||||
// Button(action: {
|
||||
// onRemoveImage(index)
|
||||
// }) {
|
||||
// Image(systemName: "xmark.circle.fill")
|
||||
// .font(.system(size: 20))
|
||||
// .foregroundColor(.white)
|
||||
// .background(Color.black.opacity(0.6))
|
||||
// .clipShape(Circle())
|
||||
// }
|
||||
// .padding(4)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // 添加图片按钮
|
||||
// if canAddMore {
|
||||
// PhotosPicker(
|
||||
// selection: .init(
|
||||
// get: { selectedItems },
|
||||
// set: onItemsChanged
|
||||
// ),
|
||||
// maxSelectionCount: 9,
|
||||
// matching: .images
|
||||
// ) {
|
||||
// RoundedRectangle(cornerRadius: 8)
|
||||
// .fill(Color.white.opacity(0.1))
|
||||
// .frame(height: 100)
|
||||
// .overlay(
|
||||
// Image(systemName: "plus")
|
||||
// .font(.system(size: 40))
|
||||
// .foregroundColor(.white.opacity(0.6))
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
// MARK: - 预览
|
||||
//#Preview {
|
||||
|
@@ -1,19 +1,299 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
import PhotosUI
|
||||
//import ImagePreviewPager
|
||||
|
||||
struct EditFeedView: View {
|
||||
let onDismiss: () -> Void
|
||||
let store: StoreOf<EditFeedFeature>
|
||||
@State private var isKeyboardVisible = false
|
||||
private let maxCount = 500
|
||||
|
||||
private func hideKeyboard() {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("编辑动态")
|
||||
.font(.title)
|
||||
.bold()
|
||||
Text("这里是 EditFeedView 占位内容")
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
WithPerceptionTracking {
|
||||
GeometryReader { geometry in
|
||||
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||
WithPerceptionTracking {
|
||||
ZStack {
|
||||
backgroundView
|
||||
mainContent(geometry: geometry, viewStore: viewStore)
|
||||
if viewStore.isUploadingImages {
|
||||
uploadingImagesOverlay(progress: viewStore.imageUploadProgress)
|
||||
} else if viewStore.isLoading {
|
||||
loadingOverlay
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if isKeyboardVisible {
|
||||
hideKeyboard()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isKeyboardVisible = true
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
isKeyboardVisible = false
|
||||
}
|
||||
}
|
||||
.onChange(of: viewStore.errorMessage) { error in
|
||||
if error != nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
viewStore.send(.clearError)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: viewStore.shouldDismiss) { shouldDismiss in
|
||||
if shouldDismiss {
|
||||
onDismiss()
|
||||
NotificationCenter.default.post(name: Notification.Name("reloadFeedList"), object: nil)
|
||||
viewStore.send(.clearDismissFlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundView: some View {
|
||||
Color(hexString: "0C0527")
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private func mainContent(geometry: GeometryProxy, viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
headerView(geometry: geometry, viewStore: viewStore)
|
||||
textInputArea(viewStore: viewStore)
|
||||
// 新增:图片输入区域
|
||||
ModernImageSelectionGrid(
|
||||
images: viewStore.processedImages,
|
||||
selectedItems: viewStore.selectedImages,
|
||||
canAddMore: viewStore.canAddMoreImages,
|
||||
onItemsChanged: { items in
|
||||
viewStore.send(.photosPickerItemsChanged(items))
|
||||
},
|
||||
onRemoveImage: { index in
|
||||
viewStore.send(.removeImage(index))
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 32)
|
||||
Spacer()
|
||||
if !isKeyboardVisible {
|
||||
publishButtonBottom(viewStore: viewStore, geometry: geometry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func headerView(geometry: GeometryProxy, viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
|
||||
HStack {
|
||||
Text(NSLocalizedString("editFeed.title", comment: "Image & Text Edit"))
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
if isKeyboardVisible {
|
||||
WithPerceptionTracking {
|
||||
Button(action: {
|
||||
hideKeyboard()
|
||||
viewStore.send(.publishButtonTapped)
|
||||
}) {
|
||||
Text(NSLocalizedString("editFeed.publish", comment: "Publish"))
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(hexString: "A14AC6"),
|
||||
Color(hexString: "3B1EEB")
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.cornerRadius(16)
|
||||
)
|
||||
}
|
||||
.disabled(!viewStore.canPublish)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, geometry.safeAreaInsets.top + 16)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
|
||||
private func textInputArea(viewStore: ViewStoreOf<EditFeedFeature>) -> some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color(hexString: "1C143A"))
|
||||
TextEditor(text: Binding(
|
||||
get: { viewStore.content },
|
||||
set: { viewStore.send(.contentChanged($0)) }
|
||||
))
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(16)
|
||||
.frame(height: 160)
|
||||
.foregroundColor(.white)
|
||||
.background(.clear)
|
||||
.cornerRadius(20)
|
||||
.font(.system(size: 16))
|
||||
if viewStore.content.isEmpty {
|
||||
Text(NSLocalizedString("editFeed.enterContent", comment: "Enter Content"))
|
||||
.foregroundColor(Color.white.opacity(0.4))
|
||||
.padding(20)
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
WithPerceptionTracking {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("\(viewStore.content.count)/\(maxCount)")
|
||||
.foregroundColor(Color.white.opacity(0.4))
|
||||
.font(.system(size: 14))
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 160)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
|
||||
private func publishButtonBottom(viewStore: ViewStoreOf<EditFeedFeature>, geometry: GeometryProxy) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
Button(action: {
|
||||
hideKeyboard()
|
||||
viewStore.send(.publishButtonTapped)
|
||||
}) {
|
||||
Text(NSLocalizedString("editFeed.publish", comment: "Publish"))
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color(hexString: "A14AC6"), Color(hexString: "3B1EEB")],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.cornerRadius(28)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, geometry.safeAreaInsets.bottom + 24)
|
||||
.disabled(!viewStore.canPublish || viewStore.isUploadingImages || viewStore.isLoading)
|
||||
.opacity(viewStore.canPublish ? 1.0 : 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingOverlay: some View {
|
||||
Group {
|
||||
Color.black.opacity(0.3)
|
||||
.ignoresSafeArea()
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.scaleEffect(1.5)
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:图片上传进度遮罩
|
||||
private func uploadingImagesOverlay(progress: Double) -> some View {
|
||||
Group {
|
||||
Color.black.opacity(0.3)
|
||||
.ignoresSafeArea()
|
||||
VStack(spacing: 16) {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(LinearProgressViewStyle(tint: .white))
|
||||
.frame(width: 180)
|
||||
Text("正在上传图片...\(Int(progress * 100))%")
|
||||
.foregroundColor(.white)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
EditFeedView()
|
||||
}
|
||||
//#Preview {
|
||||
// EditFeedView()
|
||||
//}
|
||||
|
||||
// MARK: - 九宫格图片选择组件
|
||||
struct ModernImageSelectionGrid: View {
|
||||
let images: [UIImage]
|
||||
let selectedItems: [PhotosPickerItem]
|
||||
let canAddMore: Bool
|
||||
let onItemsChanged: ([PhotosPickerItem]) -> Void
|
||||
let onRemoveImage: (Int) -> Void
|
||||
private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)
|
||||
@State private var showPreview = false
|
||||
@State private var previewIndex = 0
|
||||
var body: some View {
|
||||
let totalSpacing: CGFloat = 8 * 2
|
||||
let totalWidth = UIScreen.main.bounds.width - 24 * 2 - totalSpacing
|
||||
let gridItemSize: CGFloat = totalWidth / 3
|
||||
WithPerceptionTracking {
|
||||
LazyVGrid(columns: columns, spacing: 8) {
|
||||
ForEach(Array(images.enumerated()), id: \.offset) { index, image in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill) // aspectFill
|
||||
.frame(width: gridItemSize, height: gridItemSize)
|
||||
.clipped()
|
||||
.cornerRadius(12)
|
||||
.onTapGesture {
|
||||
previewIndex = index
|
||||
showPreview = true
|
||||
}
|
||||
Button(action: {
|
||||
onRemoveImage(index)
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.white)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
}
|
||||
if canAddMore {
|
||||
PhotosPicker(
|
||||
selection: .init(
|
||||
get: { selectedItems },
|
||||
set: { items in DispatchQueue.main.async { onItemsChanged(items) } }
|
||||
),
|
||||
maxSelectionCount: 9 - images.count,
|
||||
matching: .images
|
||||
) {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(hexString: "1C143A"))
|
||||
.frame(width: gridItemSize, height: gridItemSize)
|
||||
.overlay(
|
||||
Image("add photo")
|
||||
.resizable()
|
||||
.frame(width: 40, height: 40)
|
||||
.opacity(0.6)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showPreview) {
|
||||
ImagePreviewPager(images: images, currentIndex: $previewIndex, onClose: { showPreview = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import ComposableArchitecture
|
||||
//import OptimizedDynamicCardView // 导入新组件
|
||||
|
||||
struct FeedListView: View {
|
||||
let store: StoreOf<FeedListFeature>
|
||||
@@ -20,7 +21,7 @@ struct FeedListView: View {
|
||||
HStack {
|
||||
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))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -40,12 +41,58 @@ struct FeedListView: View {
|
||||
.font(.system(size: 60))
|
||||
.foregroundColor(.red)
|
||||
.padding(.top, 40)
|
||||
Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.")
|
||||
Text(NSLocalizedString("feedList.slogan", comment: "The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable."))
|
||||
.font(.system(size: 16))
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.padding(.horizontal, 30)
|
||||
.padding(.bottom, 30)
|
||||
// 新增:动态内容列表
|
||||
if viewStore.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.padding(.top, 20)
|
||||
} else if let error = viewStore.error {
|
||||
Text(error)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
} else if viewStore.moments.isEmpty {
|
||||
Text(NSLocalizedString("feedList.empty", comment: "暂无动态"))
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.padding(.top, 20)
|
||||
} else {
|
||||
ScrollView {
|
||||
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()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
@@ -54,11 +101,23 @@ struct FeedListView: View {
|
||||
.onAppear {
|
||||
viewStore.send(.onAppear)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("reloadFeedList"))) { _ in
|
||||
viewStore.send(.reload)
|
||||
}
|
||||
.sheet(isPresented: viewStore.binding(
|
||||
get: \.isEditFeedPresented,
|
||||
send: { $0 ? .editFeedButtonTapped : .editFeedDismissed }
|
||||
)) {
|
||||
EditFeedView()
|
||||
EditFeedView(
|
||||
onDismiss: {
|
||||
viewStore.send(.editFeedDismissed)
|
||||
},
|
||||
store: Store(
|
||||
initialState: EditFeedFeature.State()
|
||||
) {
|
||||
EditFeedFeature()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -8,12 +8,12 @@ struct FeedTopBarView: View {
|
||||
WithPerceptionTracking {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Enjoy your Life Time")
|
||||
Text(NSLocalizedString("feed.title", comment: "Enjoy your Life Time"))
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
onShowCreateFeed() // 只调用回调
|
||||
// showEditFeed = true // 显示编辑界面
|
||||
}) {
|
||||
Image("add icon")
|
||||
.frame(width: 36, height: 36)
|
||||
@@ -34,11 +34,11 @@ struct FeedMomentsListView: View {
|
||||
Image(systemName: "heart.text.square")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
Text("暂无动态内容")
|
||||
Text(NSLocalizedString("feed.empty", comment: "No moments yet"))
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
if let error = store.error {
|
||||
Text("错误: \(error)")
|
||||
Text(String(format: NSLocalizedString("feed.error", comment: "Error: %@"), error))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.red.opacity(0.8))
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -50,7 +50,7 @@ struct FeedMomentsListView: View {
|
||||
Button(action: {
|
||||
store.send(.retryLoad)
|
||||
}) {
|
||||
Text("重试")
|
||||
Text(NSLocalizedString("feed.retry", comment: "Retry"))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 20)
|
||||
@@ -85,7 +85,7 @@ struct FeedMomentsListView: View {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
Text("加载更多...")
|
||||
Text(NSLocalizedString("feed.loadingMore", comment: "Loading more..."))
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
@@ -102,6 +102,7 @@ struct FeedMomentsListView: View {
|
||||
struct FeedView: View {
|
||||
let store: StoreOf<FeedFeature>
|
||||
let onShowCreateFeed: () -> Void
|
||||
@State private var showEditFeed = false
|
||||
|
||||
var body: some View {
|
||||
WithPerceptionTracking {
|
||||
@@ -154,465 +155,477 @@ struct FeedView: View {
|
||||
.onAppear {
|
||||
store.send(.onAppear)
|
||||
}
|
||||
.sheet(isPresented: $showEditFeed) {
|
||||
EditFeedView(
|
||||
onDismiss: {
|
||||
showEditFeed = false
|
||||
},
|
||||
store: Store(
|
||||
initialState: EditFeedFeature.State()
|
||||
) {
|
||||
EditFeedFeature()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 优化的动态卡片组件
|
||||
struct OptimizedDynamicCardView: View {
|
||||
let moment: MomentsInfo
|
||||
let allMoments: [MomentsInfo]
|
||||
let currentIndex: Int
|
||||
|
||||
var body: some View {
|
||||
WithPerceptionTracking{
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// 用户信息
|
||||
HStack {
|
||||
// 使用缓存的头像
|
||||
CachedAsyncImage(url: moment.avatar) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.overlay(
|
||||
Text(String(moment.nick.prefix(1)))
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
)
|
||||
}
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(moment.nick)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text(formatTime(moment.publishTime))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// VIP 标识
|
||||
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
||||
Text("VIP\(vipLevel)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(.yellow)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.yellow.opacity(0.2))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
|
||||
// 动态内容
|
||||
if !moment.content.isEmpty {
|
||||
Text(moment.content)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
// 优化的图片网格
|
||||
if let images = moment.dynamicResList, !images.isEmpty {
|
||||
OptimizedImageGrid(images: images)
|
||||
}
|
||||
|
||||
// 互动按钮
|
||||
HStack(spacing: 20) {
|
||||
Button(action: {}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "message")
|
||||
.font(.system(size: 16))
|
||||
Text("\(moment.commentCount)")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
Button(action: {}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||
.font(.system(size: 16))
|
||||
Text("\(moment.likeCount)")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
Color.white.opacity(0.1)
|
||||
.cornerRadius(12)
|
||||
)
|
||||
.onAppear {
|
||||
// 预加载相邻的图片
|
||||
preloadNearbyImages()
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ timestamp: Int) -> String {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
|
||||
let now = Date()
|
||||
let interval = now.timeIntervalSince(date)
|
||||
|
||||
if interval < 60 {
|
||||
return "刚刚"
|
||||
} else if interval < 3600 {
|
||||
return "\(Int(interval / 60))分钟前"
|
||||
} else if interval < 86400 {
|
||||
return "\(Int(interval / 3600))小时前"
|
||||
} else {
|
||||
formatter.dateFormat = "MM-dd HH:mm"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
private func preloadNearbyImages() {
|
||||
var urlsToPreload: [String] = []
|
||||
|
||||
// 预加载前后2个动态的图片
|
||||
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.map { $0.resUrl })
|
||||
}
|
||||
}
|
||||
|
||||
// 异步预加载
|
||||
ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
|
||||
}
|
||||
}
|
||||
//struct OptimizedDynamicCardView: View {
|
||||
// let moment: MomentsInfo
|
||||
// let allMoments: [MomentsInfo]
|
||||
// let currentIndex: Int
|
||||
//
|
||||
// var body: some View {
|
||||
// WithPerceptionTracking{
|
||||
// VStack(alignment: .leading, spacing: 12) {
|
||||
// // 用户信息
|
||||
// HStack {
|
||||
// // 使用缓存的头像
|
||||
// CachedAsyncImage(url: moment.avatar) { image in
|
||||
// image
|
||||
// .resizable()
|
||||
// .aspectRatio(contentMode: .fill)
|
||||
// } placeholder: {
|
||||
// Circle()
|
||||
// .fill(Color.gray.opacity(0.3))
|
||||
// .overlay(
|
||||
// Text(String(moment.nick.prefix(1)))
|
||||
// .font(.system(size: 16, weight: .medium))
|
||||
// .foregroundColor(.white)
|
||||
// )
|
||||
// }
|
||||
// .frame(width: 40, height: 40)
|
||||
// .clipShape(Circle())
|
||||
//
|
||||
// VStack(alignment: .leading, spacing: 2) {
|
||||
// Text(moment.nick)
|
||||
// .font(.system(size: 16, weight: .medium))
|
||||
// .foregroundColor(.white)
|
||||
//
|
||||
// Text(formatTime(moment.publishTime))
|
||||
// .font(.system(size: 12))
|
||||
// .foregroundColor(.white.opacity(0.6))
|
||||
// }
|
||||
//
|
||||
// Spacer()
|
||||
//
|
||||
// // VIP 标识
|
||||
// if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
||||
// Text("VIP\(vipLevel)")
|
||||
// .font(.system(size: 10, weight: .bold))
|
||||
// .foregroundColor(.yellow)
|
||||
// .padding(.horizontal, 6)
|
||||
// .padding(.vertical, 2)
|
||||
// .background(Color.yellow.opacity(0.2))
|
||||
// .cornerRadius(4)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // 动态内容
|
||||
// if !moment.content.isEmpty {
|
||||
// Text(moment.content)
|
||||
// .font(.system(size: 14))
|
||||
// .foregroundColor(.white.opacity(0.9))
|
||||
// .multilineTextAlignment(.leading)
|
||||
// }
|
||||
//
|
||||
// // 优化的图片网格
|
||||
// if let images = moment.dynamicResList, !images.isEmpty {
|
||||
// OptimizedImageGrid(images: images)
|
||||
// }
|
||||
//
|
||||
// // 互动按钮
|
||||
// HStack(spacing: 20) {
|
||||
// Button(action: {}) {
|
||||
// HStack(spacing: 4) {
|
||||
// Image(systemName: "message")
|
||||
// .font(.system(size: 16))
|
||||
// Text("\(moment.commentCount)")
|
||||
// .font(.system(size: 14))
|
||||
// }
|
||||
// .foregroundColor(.white.opacity(0.8))
|
||||
// }
|
||||
//
|
||||
// Button(action: {}) {
|
||||
// HStack(spacing: 4) {
|
||||
// Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||
// .font(.system(size: 16))
|
||||
// Text("\(moment.likeCount)")
|
||||
// .font(.system(size: 14))
|
||||
// }
|
||||
// .foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||||
// }
|
||||
//
|
||||
// Spacer()
|
||||
// }
|
||||
// .padding(.top, 8)
|
||||
// }
|
||||
// }
|
||||
// .padding(16)
|
||||
// .background(
|
||||
// Color.white.opacity(0.1)
|
||||
// .cornerRadius(12)
|
||||
// )
|
||||
// .onAppear {
|
||||
// // 预加载相邻的图片
|
||||
// preloadNearbyImages()
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func formatTime(_ timestamp: Int) -> String {
|
||||
// let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
|
||||
// let formatter = DateFormatter()
|
||||
// formatter.locale = Locale(identifier: "zh_CN")
|
||||
//
|
||||
// let now = Date()
|
||||
// let interval = now.timeIntervalSince(date)
|
||||
//
|
||||
// if interval < 60 {
|
||||
// return "刚刚"
|
||||
// } else if interval < 3600 {
|
||||
// return "\(Int(interval / 60))分钟前"
|
||||
// } else if interval < 86400 {
|
||||
// return "\(Int(interval / 3600))小时前"
|
||||
// } else {
|
||||
// formatter.dateFormat = "MM-dd HH:mm"
|
||||
// return formatter.string(from: date)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func preloadNearbyImages() {
|
||||
// var urlsToPreload: [String] = []
|
||||
//
|
||||
// // 预加载前后2个动态的图片
|
||||
// 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.map { $0.resUrl })
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // 异步预加载
|
||||
// ImageCacheManager.shared.preloadImages(urls: urlsToPreload)
|
||||
// }
|
||||
//}
|
||||
|
||||
// MARK: - 优化的图片网格
|
||||
struct OptimizedImageGrid: View {
|
||||
let images: [MomentsPicture]
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let availableWidth = max(geometry.size.width, 1) // 防止为0或负数
|
||||
let spacing: CGFloat = 8
|
||||
|
||||
// 保护:如果availableWidth不合理,直接返回空视图
|
||||
if availableWidth < 10 {
|
||||
Color.clear.frame(height: 1)
|
||||
} else {
|
||||
switch images.count {
|
||||
case 1:
|
||||
// 单张图片:大正方形居中显示
|
||||
let imageSize: CGFloat = min(availableWidth * 0.6, 200)
|
||||
HStack {
|
||||
Spacer()
|
||||
SquareImageView(image: images[0], size: imageSize)
|
||||
Spacer()
|
||||
}
|
||||
case 2:
|
||||
// 两张图片:并排显示
|
||||
let imageSize: CGFloat = (availableWidth - spacing) / 2
|
||||
HStack(spacing: spacing) {
|
||||
SquareImageView(image: images[0], size: imageSize)
|
||||
SquareImageView(image: images[1], size: imageSize)
|
||||
}
|
||||
case 3:
|
||||
// 三张图片:水平排列
|
||||
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(images.prefix(3), id: \.id) { image in
|
||||
SquareImageView(image: image, size: imageSize)
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 四张及以上:九宫格布局(最多9张)
|
||||
let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||
let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
|
||||
LazyVGrid(columns: columns, spacing: spacing) {
|
||||
ForEach(images.prefix(9), id: \.id) { image in
|
||||
SquareImageView(image: image, size: imageSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 // 九宫格2行的高度 (实际图片大小 * 2 + 间距)
|
||||
default:
|
||||
return 340 // 九宫格3行的高度 (实际图片大小 * 3 + 间距 + 额外空间)
|
||||
}
|
||||
}
|
||||
}
|
||||
//struct OptimizedImageGrid: View {
|
||||
// let images: [MomentsPicture]
|
||||
//
|
||||
// var body: some View {
|
||||
// GeometryReader { geometry in
|
||||
// let availableWidth = max(geometry.size.width, 1) // 防止为0或负数
|
||||
// let spacing: CGFloat = 8
|
||||
//
|
||||
// // 保护:如果availableWidth不合理,直接返回空视图
|
||||
// if availableWidth < 10 {
|
||||
// Color.clear.frame(height: 1)
|
||||
// } else {
|
||||
// switch images.count {
|
||||
// case 1:
|
||||
// // 单张图片:大正方形居中显示
|
||||
// let imageSize: CGFloat = min(availableWidth * 0.6, 200)
|
||||
// HStack {
|
||||
// Spacer()
|
||||
// SquareImageView(image: images[0], size: imageSize)
|
||||
// Spacer()
|
||||
// }
|
||||
// case 2:
|
||||
// // 两张图片:并排显示
|
||||
// let imageSize: CGFloat = (availableWidth - spacing) / 2
|
||||
// HStack(spacing: spacing) {
|
||||
// SquareImageView(image: images[0], size: imageSize)
|
||||
// SquareImageView(image: images[1], size: imageSize)
|
||||
// }
|
||||
// case 3:
|
||||
// // 三张图片:水平排列
|
||||
// let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||
// HStack(spacing: spacing) {
|
||||
// ForEach(images.prefix(3), id: \.id) { image in
|
||||
// SquareImageView(image: image, size: imageSize)
|
||||
// }
|
||||
// }
|
||||
// default:
|
||||
// // 四张及以上:九宫格布局(最多9张)
|
||||
// let imageSize: CGFloat = (availableWidth - spacing * 2) / 3
|
||||
// let columns = Array(repeating: GridItem(.fixed(imageSize), spacing: spacing), count: 3)
|
||||
// LazyVGrid(columns: columns, spacing: spacing) {
|
||||
// ForEach(images.prefix(9), id: \.id) { image in
|
||||
// SquareImageView(image: image, size: imageSize)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// .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 // 九宫格2行的高度 (实际图片大小 * 2 + 间距)
|
||||
// default:
|
||||
// return 340 // 九宫格3行的高度 (实际图片大小 * 3 + 间距 + 额外空间)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
// MARK: - 正方形图片视图组件
|
||||
struct SquareImageView: View {
|
||||
let image: MomentsPicture
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
let safeSize = size.isFinite && size > 0 ? size : 100 // 防止非有限或负数
|
||||
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)
|
||||
)
|
||||
}
|
||||
.frame(width: safeSize, height: safeSize)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
//struct SquareImageView: View {
|
||||
// let image: MomentsPicture
|
||||
// let size: CGFloat
|
||||
//
|
||||
// var body: some View {
|
||||
// let safeSize = size.isFinite && size > 0 ? size : 100 // 防止非有限或负数
|
||||
// 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)
|
||||
// )
|
||||
// }
|
||||
// .frame(width: safeSize, height: safeSize)
|
||||
// .clipped()
|
||||
// .cornerRadius(8)
|
||||
// }
|
||||
//}
|
||||
|
||||
// MARK: - 旧的真实动态卡片组件(保留备用)
|
||||
struct RealDynamicCardView: View {
|
||||
let moment: MomentsInfo
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// 用户信息
|
||||
HStack {
|
||||
AsyncImage(url: URL(string: moment.avatar)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.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(formatTime(moment.publishTime))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// VIP 标识
|
||||
if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
||||
Text("VIP\(vipLevel)")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(.yellow)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.yellow.opacity(0.2))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
}
|
||||
|
||||
// 动态内容
|
||||
if !moment.content.isEmpty {
|
||||
Text(moment.content)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
// 图片网格
|
||||
if let images = moment.dynamicResList, !images.isEmpty {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) {
|
||||
ForEach(images.prefix(9), id: \.id) { image in
|
||||
AsyncImage(url: URL(string: 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)))
|
||||
)
|
||||
}
|
||||
.frame(height: 100)
|
||||
.clipped()
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 互动按钮
|
||||
HStack(spacing: 20) {
|
||||
Button(action: {}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "message")
|
||||
.font(.system(size: 16))
|
||||
Text("\(moment.commentCount)")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
Button(action: {}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||
.font(.system(size: 16))
|
||||
Text("\(moment.likeCount)")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
Color.white.opacity(0.1)
|
||||
.cornerRadius(12)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
//struct RealDynamicCardView: View {
|
||||
// let moment: MomentsInfo
|
||||
//
|
||||
// var body: some View {
|
||||
// VStack(alignment: .leading, spacing: 12) {
|
||||
// // 用户信息
|
||||
// HStack {
|
||||
// AsyncImage(url: URL(string: moment.avatar)) { image in
|
||||
// image
|
||||
// .resizable()
|
||||
// .aspectRatio(contentMode: .fill)
|
||||
// } placeholder: {
|
||||
// Circle()
|
||||
// .fill(Color.gray.opacity(0.3))
|
||||
// .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(formatTime(moment.publishTime))
|
||||
// .font(.system(size: 12))
|
||||
// .foregroundColor(.white.opacity(0.6))
|
||||
// }
|
||||
//
|
||||
// Spacer()
|
||||
//
|
||||
// // VIP 标识
|
||||
// if let vipInfo = moment.userVipInfoVO, let vipLevel = vipInfo.vipLevel {
|
||||
// Text("VIP\(vipLevel)")
|
||||
// .font(.system(size: 10, weight: .bold))
|
||||
// .foregroundColor(.yellow)
|
||||
// .padding(.horizontal, 6)
|
||||
// .padding(.vertical, 2)
|
||||
// .background(Color.yellow.opacity(0.2))
|
||||
// .cornerRadius(4)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // 动态内容
|
||||
// if !moment.content.isEmpty {
|
||||
// Text(moment.content)
|
||||
// .font(.system(size: 14))
|
||||
// .foregroundColor(.white.opacity(0.9))
|
||||
// .multilineTextAlignment(.leading)
|
||||
// }
|
||||
//
|
||||
// // 图片网格
|
||||
// if let images = moment.dynamicResList, !images.isEmpty {
|
||||
// LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: min(images.count, 3)), spacing: 8) {
|
||||
// ForEach(images.prefix(9), id: \.id) { image in
|
||||
// AsyncImage(url: URL(string: 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)))
|
||||
// )
|
||||
// }
|
||||
// .frame(height: 100)
|
||||
// .clipped()
|
||||
// .cornerRadius(8)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // 互动按钮
|
||||
// HStack(spacing: 20) {
|
||||
// Button(action: {}) {
|
||||
// HStack(spacing: 4) {
|
||||
// Image(systemName: "message")
|
||||
// .font(.system(size: 16))
|
||||
// Text("\(moment.commentCount)")
|
||||
// .font(.system(size: 14))
|
||||
// }
|
||||
// .foregroundColor(.white.opacity(0.8))
|
||||
// }
|
||||
//
|
||||
// Button(action: {}) {
|
||||
// HStack(spacing: 4) {
|
||||
// Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||
// .font(.system(size: 16))
|
||||
// Text("\(moment.likeCount)")
|
||||
// .font(.system(size: 14))
|
||||
// }
|
||||
// .foregroundColor(moment.isLike ? .red : .white.opacity(0.8))
|
||||
// }
|
||||
//
|
||||
// Spacer()
|
||||
// }
|
||||
// .padding(.top, 8)
|
||||
// }
|
||||
// .padding(16)
|
||||
// .background(
|
||||
// Color.white.opacity(0.1)
|
||||
// .cornerRadius(12)
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// 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)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
// MARK: - 旧的模拟卡片组件(保留备用)
|
||||
struct DynamicCardView: View {
|
||||
let index: Int
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// 用户信息
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(width: 40, height: 40)
|
||||
.overlay(
|
||||
Text("U\(index + 1)")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("用户\(index + 1)")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("2小时前")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// 动态内容
|
||||
Text("今天是美好的一天,分享一些生活中的小确幸。希望大家都能珍惜每一个当下的时刻。")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.9))
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
// 图片网格
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
|
||||
ForEach(0..<3) { imageIndex in
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.overlay(
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 互动按钮
|
||||
HStack(spacing: 20) {
|
||||
Button(action: {}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "message")
|
||||
.font(.system(size: 16))
|
||||
Text("354")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
Button(action: {}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "heart")
|
||||
.font(.system(size: 16))
|
||||
Text("354")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
Color.white.opacity(0.1)
|
||||
.cornerRadius(12)
|
||||
)
|
||||
}
|
||||
}
|
||||
//struct DynamicCardView: View {
|
||||
// let index: Int
|
||||
//
|
||||
// var body: some View {
|
||||
// VStack(alignment: .leading, spacing: 12) {
|
||||
// // 用户信息
|
||||
// HStack {
|
||||
// Circle()
|
||||
// .fill(Color.gray.opacity(0.3))
|
||||
// .frame(width: 40, height: 40)
|
||||
// .overlay(
|
||||
// Text("U\(index + 1)")
|
||||
// .font(.system(size: 16, weight: .medium))
|
||||
// .foregroundColor(.white)
|
||||
// )
|
||||
//
|
||||
// VStack(alignment: .leading, spacing: 2) {
|
||||
// Text("用户\(index + 1)")
|
||||
// .font(.system(size: 16, weight: .medium))
|
||||
// .foregroundColor(.white)
|
||||
//
|
||||
// Text("2小时前")
|
||||
// .font(.system(size: 12))
|
||||
// .foregroundColor(.white.opacity(0.6))
|
||||
// }
|
||||
//
|
||||
// Spacer()
|
||||
// }
|
||||
//
|
||||
// // 动态内容
|
||||
// Text("今天是美好的一天,分享一些生活中的小确幸。希望大家都能珍惜每一个当下的时刻。")
|
||||
// .font(.system(size: 14))
|
||||
// .foregroundColor(.white.opacity(0.9))
|
||||
// .multilineTextAlignment(.leading)
|
||||
//
|
||||
// // 图片网格
|
||||
// LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8) {
|
||||
// ForEach(0..<3) { imageIndex in
|
||||
// Rectangle()
|
||||
// .fill(Color.gray.opacity(0.3))
|
||||
// .aspectRatio(1, contentMode: .fit)
|
||||
// .overlay(
|
||||
// Image(systemName: "photo")
|
||||
// .foregroundColor(.white.opacity(0.6))
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // 互动按钮
|
||||
// HStack(spacing: 20) {
|
||||
// Button(action: {}) {
|
||||
// HStack(spacing: 4) {
|
||||
// Image(systemName: "message")
|
||||
// .font(.system(size: 16))
|
||||
// Text("354")
|
||||
// .font(.system(size: 14))
|
||||
// }
|
||||
// .foregroundColor(.white.opacity(0.8))
|
||||
// }
|
||||
//
|
||||
// Button(action: {}) {
|
||||
// HStack(spacing: 4) {
|
||||
// Image(systemName: "heart")
|
||||
// .font(.system(size: 16))
|
||||
// Text("354")
|
||||
// .font(.system(size: 14))
|
||||
// }
|
||||
// .foregroundColor(.white.opacity(0.8))
|
||||
// }
|
||||
//
|
||||
// Spacer()
|
||||
// }
|
||||
// .padding(.top, 8)
|
||||
// }
|
||||
// .padding(16)
|
||||
// .background(
|
||||
// Color.white.opacity(0.1)
|
||||
// .cornerRadius(12)
|
||||
// )
|
||||
// }
|
||||
//}
|
||||
|
||||
//#Preview {
|
||||
// FeedView(
|
||||
|
@@ -34,8 +34,15 @@ struct HomeView: View {
|
||||
)
|
||||
.transition(.opacity)
|
||||
case .me:
|
||||
MeView(onLogout: onLogout)
|
||||
.transition(.opacity)
|
||||
Spacer()
|
||||
// MeView(
|
||||
// meDynamicStore: store.scope(
|
||||
// state: \.meDynamic,
|
||||
// action: \.meDynamic
|
||||
// ),
|
||||
// onLogout: onLogout
|
||||
// )
|
||||
// .transition(.opacity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -51,12 +58,6 @@ struct HomeView: View {
|
||||
.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(
|
||||
get: { store.withState(\.route) == .createFeed },
|
||||
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 ComposableArchitecture
|
||||
//import Components // 如果 BottomTabView 在 Components 命名空间,否则移除
|
||||
|
||||
struct MainView: View {
|
||||
let store: StoreOf<MainFeature>
|
||||
|
||||
var onLogout: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
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
|
||||
ZStack {
|
||||
// 背景图片
|
||||
@@ -19,17 +34,18 @@ struct MainView: View {
|
||||
.ignoresSafeArea(.all)
|
||||
// 主内容
|
||||
ZStack {
|
||||
switch viewStore.selectedTab {
|
||||
case .feed:
|
||||
FeedListView(store: store.scope(
|
||||
state: \.feedList,
|
||||
action: \.feedList
|
||||
))
|
||||
.transition(.opacity)
|
||||
case .other:
|
||||
MeView(onLogout: {}) // 这里可根据需要传递实际登出回调
|
||||
.transition(.opacity)
|
||||
}
|
||||
FeedListView(store: store.scope(
|
||||
state: \.feedList,
|
||||
action: \.feedList
|
||||
))
|
||||
.isHidden(viewStore.selectedTab != .feed)
|
||||
MeView(
|
||||
store: store.scope(
|
||||
state: \.me,
|
||||
action: \.me
|
||||
)
|
||||
)
|
||||
.isHidden(viewStore.selectedTab != .other)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// 底部导航栏
|
||||
@@ -43,7 +59,39 @@ struct MainView: View {
|
||||
.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 ComposableArchitecture
|
||||
|
||||
struct MeView: View {
|
||||
@State private var showLogoutConfirmation = false
|
||||
let onLogout: () -> Void // 新增:登出回调
|
||||
let store: StoreOf<MeFeature>
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// 顶部标题
|
||||
ZStack {
|
||||
Image("bg")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.clipped()
|
||||
.ignoresSafeArea(.all)
|
||||
VStack(spacing: 0) {
|
||||
// 顶部栏,右上角设置按钮
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("我的")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, geometry.safeAreaInsets.top + 20)
|
||||
|
||||
// 用户头像区域
|
||||
VStack(spacing: 16) {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(width: 80, height: 80)
|
||||
.overlay(
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: 40))
|
||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||
Button(action: {
|
||||
viewStore.send(.settingButtonTapped)
|
||||
}) {
|
||||
Image(systemName: "gearshape")
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
)
|
||||
|
||||
Text("用户昵称")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("ID: 123456789")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
}
|
||||
.padding(.top, 30)
|
||||
|
||||
// 功能菜单
|
||||
VStack(spacing: 12) {
|
||||
MenuItemView(icon: "gearshape", title: "设置", action: {})
|
||||
MenuItemView(icon: "person.circle", title: "个人信息", action: {})
|
||||
MenuItemView(icon: "heart", title: "我的收藏", action: {})
|
||||
MenuItemView(icon: "clock", title: "浏览历史", action: {})
|
||||
MenuItemView(icon: "questionmark.circle", title: "帮助与反馈", action: {})
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 40)
|
||||
|
||||
// 退出登录按钮
|
||||
Button(action: {
|
||||
showLogoutConfirmation = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
.font(.system(size: 16))
|
||||
Text("退出登录")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(
|
||||
Color.white.opacity(0.1)
|
||||
.cornerRadius(12)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 30)
|
||||
|
||||
// 底部安全区域 - 为底部导航栏和安全区域留出空间
|
||||
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
|
||||
// 用户信息区域
|
||||
WithViewStore(self.store, observe: { $0 }) { viewStore in
|
||||
if viewStore.isLoadingUserInfo {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.frame(height: 130)
|
||||
} else if let error = viewStore.userInfoError {
|
||||
Text(error)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.red)
|
||||
.frame(height: 130)
|
||||
} else if let userInfo = viewStore.userInfo {
|
||||
VStack(spacing: 8) {
|
||||
if let avatarUrl = userInfo.avatar, !avatarUrl.isEmpty {
|
||||
AsyncImage(url: URL(string: avatarUrl)) { image in
|
||||
image.resizable().aspectRatio(contentMode: .fill).clipShape(Circle())
|
||||
} placeholder: {
|
||||
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
|
||||
}
|
||||
.frame(width: 90, height: 90)
|
||||
} else {
|
||||
Image(systemName: "person.fill").font(.system(size: 40)).foregroundColor(.white)
|
||||
.frame(width: 90, height: 90)
|
||||
}
|
||||
Text(userInfo.nick ?? "用户昵称")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
Text("ID: \(userInfo.uid ?? 0)")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
}
|
||||
.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)
|
||||
.alert("确认退出", isPresented: $showLogoutConfirmation) {
|
||||
Button("取消", role: .cancel) { }
|
||||
Button("退出", role: .destructive) {
|
||||
Task { await performLogout() }
|
||||
}
|
||||
} message: {
|
||||
Text("确定要退出登录吗?")
|
||||
.onAppear {
|
||||
ViewStore(self.store, observe: { $0 }).send(.onAppear)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
) {
|
||||
MainFeature()
|
||||
},
|
||||
onLogout: {
|
||||
store.send(.navigateToLogin)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -64,7 +67,7 @@ struct SplashView: View {
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
// 应用标题 - 白色,40pt字体
|
||||
Text("E-Parti")
|
||||
Text(NSLocalizedString("splash.title", comment: "E-Parti"))
|
||||
.font(.system(size: 40, weight: .regular))
|
||||
.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**: 添加设置相关状态管理
|
||||
2. **HomeView.swift**: 修复 TCA store 绑定语法
|
||||
3. **SettingFeature.swift**: 确保 Action 完整性
|
||||
|
||||
### 构建结果
|
||||
|
||||
✅ **编译成功**: Exit code 0
|
||||
⚠️ **警告信息**: 仅 Swift 6 兼容性警告,不影响运行
|
||||
|
||||
@@ -188,18 +190,21 @@ xcodebuild -workspace yana.xcworkspace -scheme yana -configuration Debug build
|
||||
## 预防措施
|
||||
|
||||
### 开发规范
|
||||
|
||||
1. **统一包管理**: 优先使用一种包管理工具
|
||||
2. **定期清理**: 定期清理 DerivedData 避免缓存问题
|
||||
3. **代码审查**: 确保 TCA Feature 结构完整
|
||||
4. **版本控制**: 及时提交关键配置文件
|
||||
|
||||
### 监控指标
|
||||
|
||||
- [ ] 项目编译时间 < 30s
|
||||
- [ ] 无编译错误
|
||||
- [ ] 依赖解析正常
|
||||
- [ ] TCA 结构完整
|
||||
|
||||
### 工具使用
|
||||
|
||||
```bash
|
||||
# 项目健康检查脚本
|
||||
check_project() {
|
||||
@@ -255,6 +260,7 @@ pod install --clean-install
|
||||
## 总结
|
||||
|
||||
本次问题解决涉及以下关键技术点:
|
||||
|
||||
1. **Xcode 项目配置管理**
|
||||
2. **Swift Package Manager 与 CocoaPods 共存**
|
||||
3. **TCA (The Composable Architecture) 最佳实践**
|
||||
@@ -265,5 +271,5 @@ pod install --clean-install
|
||||
---
|
||||
|
||||
**文档更新时间**: 2025-07-10
|
||||
**适用版本**: iOS 15.6+, Swift 6, TCA 1.20.2+
|
||||
**维护者**: AI Assistant & 开发团队
|
||||
**适用版本**: iOS 16+, Swift 6, TCA 1.20.2+
|
||||
**维护者**: AI Assistant & 开发团队
|
Reference in New Issue
Block a user