feat: 更新Podfile和Podfile.lock,添加最新动态API文档和相关功能
- 在Podfile中添加Alamofire依赖,并更新Podfile.lock以反映更改。 - 新增动态内容API文档,详细描述`dynamic/square/latestDynamics`接口的请求参数、响应数据结构及示例。 - 实现动态内容的模型和API请求结构,支持获取最新动态列表。 - 更新FeedView和HomeView以集成动态内容展示,增强用户体验。 - 添加动态卡片组件,展示用户动态信息及互动功能。
This commit is contained in:
2
Podfile
2
Podfile
@@ -16,7 +16,7 @@ target 'yana' do
|
|||||||
# pod 'NELocalConversationUIKit', '10.6.1' # 本地会话列表组件。
|
# pod 'NELocalConversationUIKit', '10.6.1' # 本地会话列表组件。
|
||||||
|
|
||||||
# Networks
|
# Networks
|
||||||
# pod 'Alamofire'
|
pod 'Alamofire'
|
||||||
end
|
end
|
||||||
|
|
||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
|
15
Podfile.lock
15
Podfile.lock
@@ -1,3 +1,16 @@
|
|||||||
PODFILE CHECKSUM: 9817fb04504ebed48143ca78630f70d3b3402405
|
PODS:
|
||||||
|
- Alamofire (5.10.2)
|
||||||
|
|
||||||
|
DEPENDENCIES:
|
||||||
|
- Alamofire
|
||||||
|
|
||||||
|
SPEC REPOS:
|
||||||
|
trunk:
|
||||||
|
- Alamofire
|
||||||
|
|
||||||
|
SPEC CHECKSUMS:
|
||||||
|
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||||
|
|
||||||
|
PODFILE CHECKSUM: 4ccb5fbbedd3dcb71c35d00e7bfd0d280d4ced88
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
@@ -47,8 +47,6 @@
|
|||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = {
|
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = yanaAPITests;
|
path = yanaAPITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -145,6 +143,7 @@
|
|||||||
4C3E651B2DB61F7A00E5A455 /* Sources */,
|
4C3E651B2DB61F7A00E5A455 /* Sources */,
|
||||||
4C3E651C2DB61F7A00E5A455 /* Frameworks */,
|
4C3E651C2DB61F7A00E5A455 /* Frameworks */,
|
||||||
4C3E651D2DB61F7A00E5A455 /* Resources */,
|
4C3E651D2DB61F7A00E5A455 /* Resources */,
|
||||||
|
0C5E1A8360250314246001A5 /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -240,6 +239,27 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
0C5E1A8360250314246001A5 /* [CP] Embed Pods Frameworks */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "[CP] Embed Pods Frameworks";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-yana/Pods-yana-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
5EE242260A8B893DC4D6B6DD /* [CP] Check Pods Manifest.lock */ = {
|
5EE242260A8B893DC4D6B6DD /* [CP] Check Pods Manifest.lock */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
|
@@ -7,7 +7,7 @@
|
|||||||
<key>yana.xcscheme_^#shared#^_</key>
|
<key>yana.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>1</integer>
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
521
yana/APIs/API dynamic feed rule.md
Normal file
521
yana/APIs/API dynamic feed rule.md
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
# **dynamic/square/latestDynamics API 文档**
|
||||||
|
|
||||||
|
## **概述**
|
||||||
|
|
||||||
|
`dynamic/square/latestDynamics` 是获取朋友圈动态最新列表的 API 接口,用于获取用户动态内容的最新更新。
|
||||||
|
|
||||||
|
## **接口信息**
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| **接口路径** | `GET /dynamic/square/latestDynamics` |
|
||||||
|
| **请求方法** | `GET` |
|
||||||
|
| **认证要求** | 需要 `pub_uid` 和 `pub_ticket` |
|
||||||
|
| **内容类型** | `application/json` |
|
||||||
|
|
||||||
|
## **请求参数**
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 描述 | 示例值 |
|
||||||
|
|--------|------|------|------|--------|
|
||||||
|
| `dynamicId` | `String` | 否 | 最新动态的ID,用于分页加载。首次请求传空字符串 | `""` 或 `"123456"` |
|
||||||
|
| `pageSize` | `String` | 是 | 每页返回的数据数量 | `"20"` |
|
||||||
|
| `types` | `String` | 是 | 动态内容类型,多个类型用逗号分隔 | `"0,2"` |
|
||||||
|
|
||||||
|
### **types 参数说明**
|
||||||
|
- `0`: 纯文字动态
|
||||||
|
- `2`: 图片动态
|
||||||
|
|
||||||
|
## **响应数据结构**
|
||||||
|
|
||||||
|
### **成功响应 (200)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"dynamicList": [
|
||||||
|
{
|
||||||
|
"dynamicId": "123456",
|
||||||
|
"uid": "789012",
|
||||||
|
"nick": "用户昵称",
|
||||||
|
"avatar": "https://example.com/avatar.jpg",
|
||||||
|
"gender": 1,
|
||||||
|
"age": 25,
|
||||||
|
"type": 0,
|
||||||
|
"content": "动态内容文字",
|
||||||
|
"likeCount": "15",
|
||||||
|
"isLike": false,
|
||||||
|
"commentCount": "3",
|
||||||
|
"publishTime": "2024-01-15 10:30:00",
|
||||||
|
"worldId": 456,
|
||||||
|
"worldName": "话题名称",
|
||||||
|
"squareTop": false,
|
||||||
|
"topicTop": false,
|
||||||
|
"newUser": false,
|
||||||
|
"defUser": 0,
|
||||||
|
"inRoomUid": "",
|
||||||
|
"dynamicResList": [
|
||||||
|
{
|
||||||
|
"resUrl": "https://example.com/image.jpg",
|
||||||
|
"format": "jpg",
|
||||||
|
"width": 720,
|
||||||
|
"height": 960
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"userVipInfoVO": {
|
||||||
|
"vipLevel": 3,
|
||||||
|
"vipExpire": "2024-12-31"
|
||||||
|
},
|
||||||
|
"headwearPic": "https://example.com/headwear.png",
|
||||||
|
"headwearEffect": "https://example.com/effect.svga",
|
||||||
|
"headwearType": 1,
|
||||||
|
"expertLevelPic": "https://example.com/expert_lv3.png",
|
||||||
|
"charmLevelPic": "https://example.com/charm_lv2.png",
|
||||||
|
"nameplatePic": "https://example.com/nameplate.png",
|
||||||
|
"nameplateWord": "自定义铭牌",
|
||||||
|
"isCustomWord": true,
|
||||||
|
"labelList": ["新人", "活跃"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nextDynamicId": "123455"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **错误响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"message": "参数错误",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## **Swift 实现示例**
|
||||||
|
|
||||||
|
### **1. 数据模型定义**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// MARK: - 响应数据模型
|
||||||
|
struct MomentsLatestResponse: Codable {
|
||||||
|
let code: Int
|
||||||
|
let message: String
|
||||||
|
let data: MomentsListData?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MomentsListData: Codable {
|
||||||
|
let dynamicList: [MomentsInfo]
|
||||||
|
let nextDynamicId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MomentsInfo: Codable {
|
||||||
|
let dynamicId: String
|
||||||
|
let uid: String
|
||||||
|
let nick: String
|
||||||
|
let avatar: String
|
||||||
|
let gender: Int
|
||||||
|
let age: Int
|
||||||
|
let type: Int
|
||||||
|
let content: String
|
||||||
|
let likeCount: String
|
||||||
|
let isLike: Bool
|
||||||
|
let commentCount: String
|
||||||
|
let publishTime: String
|
||||||
|
let worldId: Int
|
||||||
|
let worldName: String?
|
||||||
|
let squareTop: Bool
|
||||||
|
let topicTop: Bool
|
||||||
|
let newUser: Bool
|
||||||
|
let defUser: Int
|
||||||
|
let inRoomUid: String?
|
||||||
|
let dynamicResList: [MomentsPicture]?
|
||||||
|
let userVipInfoVO: UserVipInfo?
|
||||||
|
let headwearPic: String?
|
||||||
|
let headwearEffect: String?
|
||||||
|
let headwearType: Int?
|
||||||
|
let expertLevelPic: String?
|
||||||
|
let charmLevelPic: String?
|
||||||
|
let nameplatePic: String?
|
||||||
|
let nameplateWord: String?
|
||||||
|
let isCustomWord: Bool?
|
||||||
|
let labelList: [String]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MomentsPicture: Codable {
|
||||||
|
let resUrl: String
|
||||||
|
let format: String
|
||||||
|
let width: CGFloat
|
||||||
|
let height: CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserVipInfo: Codable {
|
||||||
|
let vipLevel: Int
|
||||||
|
let vipExpire: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 内容类型枚举
|
||||||
|
enum MomentsContentType: Int, CaseIterable {
|
||||||
|
case text = 0 // 纯文字
|
||||||
|
case picture = 2 // 图片
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **2. API 服务实现**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class MomentsAPIService {
|
||||||
|
|
||||||
|
private let baseURL = "https://api.yourapp.com"
|
||||||
|
private let session = URLSession.shared
|
||||||
|
|
||||||
|
// MARK: - 获取最新动态列表
|
||||||
|
func fetchLatestMoments(
|
||||||
|
dynamicId: String = "",
|
||||||
|
pageSize: Int = 20,
|
||||||
|
types: [MomentsContentType] = [.text, .picture]
|
||||||
|
) -> AnyPublisher<MomentsListData, Error> {
|
||||||
|
|
||||||
|
// 构建请求参数
|
||||||
|
var components = URLComponents(string: "\(baseURL)/dynamic/square/latestDynamics")!
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "dynamicId", value: dynamicId),
|
||||||
|
URLQueryItem(name: "pageSize", value: String(pageSize)),
|
||||||
|
URLQueryItem(name: "types", value: types.map { String($0.rawValue) }.joined(separator: ","))
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let url = components.url else {
|
||||||
|
return Fail(error: APIError.invalidURL)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
|
||||||
|
// 添加认证头
|
||||||
|
if let uid = AuthManager.shared.currentUID {
|
||||||
|
request.setValue(uid, forHTTPHeaderField: "pub_uid")
|
||||||
|
}
|
||||||
|
if let ticket = AuthManager.shared.currentTicket {
|
||||||
|
request.setValue(ticket, forHTTPHeaderField: "pub_ticket")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加其他公共头
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue(AppInfo.version, forHTTPHeaderField: "App-Version")
|
||||||
|
request.setValue(Locale.current.languageCode ?? "en", forHTTPHeaderField: "Accept-Language")
|
||||||
|
|
||||||
|
return session.dataTaskPublisher(for: request)
|
||||||
|
.map(\.data)
|
||||||
|
.decode(type: MomentsLatestResponse.self, decoder: JSONDecoder())
|
||||||
|
.compactMap { response in
|
||||||
|
guard response.code == 200 else {
|
||||||
|
throw APIError.serverError(response.code, response.message)
|
||||||
|
}
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 错误类型定义
|
||||||
|
enum APIError: Error, LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case noData
|
||||||
|
case serverError(Int, String)
|
||||||
|
case networkError(Error)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return "无效的URL"
|
||||||
|
case .noData:
|
||||||
|
return "无数据返回"
|
||||||
|
case .serverError(let code, let message):
|
||||||
|
return "服务器错误 (\(code)): \(message)"
|
||||||
|
case .networkError(let error):
|
||||||
|
return "网络错误: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **3. ViewModel 实现**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class MomentsLatestViewModel: ObservableObject {
|
||||||
|
|
||||||
|
@Published var moments: [MomentsInfo] = []
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var hasMoreData = true
|
||||||
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
|
private var nextDynamicId = ""
|
||||||
|
private let apiService = MomentsAPIService()
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - 加载最新数据
|
||||||
|
func loadLatestMoments() {
|
||||||
|
loadMoments(isRefresh: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 加载更多数据
|
||||||
|
func loadMoreMoments() {
|
||||||
|
guard hasMoreData && !isLoading else { return }
|
||||||
|
loadMoments(isRefresh: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 私有方法:统一加载逻辑
|
||||||
|
private func loadMoments(isRefresh: Bool) {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
let dynamicId = isRefresh ? "" : nextDynamicId
|
||||||
|
|
||||||
|
apiService.fetchLatestMoments(
|
||||||
|
dynamicId: dynamicId,
|
||||||
|
pageSize: 20,
|
||||||
|
types: [.text, .picture]
|
||||||
|
)
|
||||||
|
.sink(
|
||||||
|
receiveCompletion: { [weak self] completion in
|
||||||
|
self?.isLoading = false
|
||||||
|
if case .failure(let error) = completion {
|
||||||
|
self?.errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receiveValue: { [weak self] data in
|
||||||
|
if isRefresh {
|
||||||
|
self?.moments = data.dynamicList
|
||||||
|
} else {
|
||||||
|
self?.moments.append(contentsOf: data.dynamicList)
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.nextDynamicId = data.nextDynamicId
|
||||||
|
self?.hasMoreData = !data.dynamicList.isEmpty
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 点赞操作
|
||||||
|
func toggleLike(for momentId: String) {
|
||||||
|
// 实现点赞逻辑
|
||||||
|
guard let index = moments.firstIndex(where: { $0.dynamicId == momentId }) else { return }
|
||||||
|
|
||||||
|
moments[index] = MomentsInfo(
|
||||||
|
dynamicId: moments[index].dynamicId,
|
||||||
|
uid: moments[index].uid,
|
||||||
|
nick: moments[index].nick,
|
||||||
|
avatar: moments[index].avatar,
|
||||||
|
gender: moments[index].gender,
|
||||||
|
age: moments[index].age,
|
||||||
|
type: moments[index].type,
|
||||||
|
content: moments[index].content,
|
||||||
|
likeCount: moments[index].isLike ?
|
||||||
|
String(max(0, Int(moments[index].likeCount) ?? 0 - 1)) :
|
||||||
|
String((Int(moments[index].likeCount) ?? 0) + 1),
|
||||||
|
isLike: !moments[index].isLike,
|
||||||
|
commentCount: moments[index].commentCount,
|
||||||
|
publishTime: moments[index].publishTime,
|
||||||
|
worldId: moments[index].worldId,
|
||||||
|
worldName: moments[index].worldName,
|
||||||
|
squareTop: moments[index].squareTop,
|
||||||
|
topicTop: moments[index].topicTop,
|
||||||
|
newUser: moments[index].newUser,
|
||||||
|
defUser: moments[index].defUser,
|
||||||
|
inRoomUid: moments[index].inRoomUid,
|
||||||
|
dynamicResList: moments[index].dynamicResList,
|
||||||
|
userVipInfoVO: moments[index].userVipInfoVO,
|
||||||
|
headwearPic: moments[index].headwearPic,
|
||||||
|
headwearEffect: moments[index].headwearEffect,
|
||||||
|
headwearType: moments[index].headwearType,
|
||||||
|
expertLevelPic: moments[index].expertLevelPic,
|
||||||
|
charmLevelPic: moments[index].charmLevelPic,
|
||||||
|
nameplatePic: moments[index].nameplatePic,
|
||||||
|
nameplateWord: moments[index].nameplateWord,
|
||||||
|
isCustomWord: moments[index].isCustomWord,
|
||||||
|
labelList: moments[index].labelList
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### **4. SwiftUI 视图实现**
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MomentsLatestView: View {
|
||||||
|
@StateObject private var viewModel = MomentsLatestViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
List {
|
||||||
|
ForEach(viewModel.moments, id: \.dynamicId) { moment in
|
||||||
|
MomentCardView(moment: moment) {
|
||||||
|
viewModel.toggleLike(for: moment.dynamicId)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// 当显示最后一个元素时加载更多
|
||||||
|
if moment.dynamicId == viewModel.moments.last?.dynamicId {
|
||||||
|
viewModel.loadMoreMoments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isLoading {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewModel.loadLatestMoments()
|
||||||
|
}
|
||||||
|
.navigationTitle("最新动态")
|
||||||
|
.onAppear {
|
||||||
|
if viewModel.moments.isEmpty {
|
||||||
|
viewModel.loadLatestMoments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("错误", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||||
|
Button("确定") {
|
||||||
|
viewModel.errorMessage = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(viewModel.errorMessage ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MomentCardView: View {
|
||||||
|
let moment: MomentsInfo
|
||||||
|
let onLike: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// 用户信息
|
||||||
|
HStack {
|
||||||
|
AsyncImage(url: URL(string: moment.avatar)) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
}
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(moment.nick)
|
||||||
|
.font(.headline)
|
||||||
|
Text(moment.publishTime)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态内容
|
||||||
|
if !moment.content.isEmpty {
|
||||||
|
Text(moment.content)
|
||||||
|
.font(.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片内容
|
||||||
|
if let pictures = moment.dynamicResList, !pictures.isEmpty {
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3)) {
|
||||||
|
ForEach(pictures.indices, id: \.self) { index in
|
||||||
|
AsyncImage(url: URL(string: pictures[index].resUrl)) { image in
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
} placeholder: {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
}
|
||||||
|
.frame(height: 100)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作栏
|
||||||
|
HStack {
|
||||||
|
Button(action: onLike) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: moment.isLike ? "heart.fill" : "heart")
|
||||||
|
.foregroundColor(moment.isLike ? .red : .gray)
|
||||||
|
Text(moment.likeCount)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "message")
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
Text(moment.commentCount)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## **使用说明**
|
||||||
|
|
||||||
|
### **基本用法**
|
||||||
|
```swift
|
||||||
|
let viewModel = MomentsLatestViewModel()
|
||||||
|
|
||||||
|
// 加载最新数据
|
||||||
|
viewModel.loadLatestMoments()
|
||||||
|
|
||||||
|
// 加载更多数据
|
||||||
|
viewModel.loadMoreMoments()
|
||||||
|
```
|
||||||
|
|
||||||
|
### **分页逻辑**
|
||||||
|
- 首次请求:`dynamicId` 传空字符串
|
||||||
|
- 后续分页:使用上次响应中的 `nextDynamicId`
|
||||||
|
- 无更多数据:返回的 `dynamicList` 为空数组
|
||||||
|
|
||||||
|
### **错误处理**
|
||||||
|
- 网络错误:检查网络连接
|
||||||
|
- 401 认证失败:重新登录获取 ticket
|
||||||
|
- 其他服务器错误:显示具体错误信息
|
||||||
|
|
||||||
|
### **性能优化建议**
|
||||||
|
1. 使用图片缓存库(如 Kingfisher)
|
||||||
|
2. 实现虚拟列表避免内存过载
|
||||||
|
3. 预加载下一页数据提升用户体验
|
||||||
|
4. 实现本地缓存减少网络请求
|
||||||
|
|
||||||
|
## **注意事项**
|
||||||
|
|
||||||
|
1. **认证要求**:所有请求必须包含有效的 `pub_uid` 和 `pub_ticket`
|
||||||
|
2. **参数验证**:`pageSize` 建议范围为 10-50
|
||||||
|
3. **类型过滤**:`types` 参数支持多选,用逗号分隔
|
||||||
|
4. **数据更新**:推荐使用下拉刷新获取最新数据
|
||||||
|
5. **错误重试**:网络错误时实现自动重试机制
|
@@ -19,6 +19,7 @@ enum APIEndpoint: String, CaseIterable {
|
|||||||
case login = "/oauth/token"
|
case login = "/oauth/token"
|
||||||
case ticket = "/oauth/ticket"
|
case ticket = "/oauth/ticket"
|
||||||
case emailGetCode = "/email/getCode" // 新增:邮箱验证码获取端点
|
case emailGetCode = "/email/getCode" // 新增:邮箱验证码获取端点
|
||||||
|
case latestDynamics = "/dynamic/square/latestDynamics" // 新增:获取最新动态列表端点
|
||||||
// Web 页面路径
|
// Web 页面路径
|
||||||
case userAgreement = "/modules/rule/protocol.html"
|
case userAgreement = "/modules/rule/protocol.html"
|
||||||
case privacyPolicy = "/modules/rule/privacy-wap.html"
|
case privacyPolicy = "/modules/rule/privacy-wap.html"
|
||||||
|
160
yana/APIs/DynamicsModels.swift
Normal file
160
yana/APIs/DynamicsModels.swift
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import Foundation
|
||||||
|
import ComposableArchitecture
|
||||||
|
|
||||||
|
// MARK: - 响应数据模型
|
||||||
|
|
||||||
|
/// 最新动态响应结构
|
||||||
|
struct MomentsLatestResponse: Codable, Equatable {
|
||||||
|
let code: Int
|
||||||
|
let message: String
|
||||||
|
let data: MomentsListData?
|
||||||
|
let timestamp: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 动态列表数据
|
||||||
|
struct MomentsListData: Codable, Equatable {
|
||||||
|
let dynamicList: [MomentsInfo]
|
||||||
|
let nextDynamicId: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 动态信息结构
|
||||||
|
struct MomentsInfo: Codable, Equatable {
|
||||||
|
let dynamicId: Int
|
||||||
|
let uid: Int
|
||||||
|
let nick: String
|
||||||
|
let avatar: String
|
||||||
|
let gender: Int
|
||||||
|
let type: Int
|
||||||
|
let content: String
|
||||||
|
let likeCount: Int
|
||||||
|
let isLike: Bool
|
||||||
|
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
|
||||||
|
let dynamicResList: [MomentsPicture]?
|
||||||
|
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 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
|
||||||
|
let resDuration: Int? // 可选字段,因为有些图片没有这个字段
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户VIP信息 - 完整版本,所有字段都是可选的
|
||||||
|
struct UserVipInfo: Codable, Equatable {
|
||||||
|
let vipLevel: Int?
|
||||||
|
let vipName: String?
|
||||||
|
let vipIcon: String?
|
||||||
|
let vipLogo: String?
|
||||||
|
let nameplateId: Int?
|
||||||
|
let nameplateUrl: String?
|
||||||
|
let userCardBG: String?
|
||||||
|
let expireTime: Int?
|
||||||
|
let preventKick: Bool?
|
||||||
|
let preventTrace: Bool?
|
||||||
|
let preventFollow: Bool?
|
||||||
|
let micNickColour: String?
|
||||||
|
let micCircle: String?
|
||||||
|
let enterRoomEffects: String?
|
||||||
|
let medalSeat: Int?
|
||||||
|
let friendNickColour: String?
|
||||||
|
let visitHide: Bool?
|
||||||
|
let visitListView: Bool?
|
||||||
|
let privateChatLimit: Bool?
|
||||||
|
let roomPicScreen: Bool?
|
||||||
|
let uploadGifAvatar: Bool?
|
||||||
|
let enterHide: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 内容类型枚举
|
||||||
|
|
||||||
|
/// 动态内容类型
|
||||||
|
enum MomentsContentType: Int, CaseIterable {
|
||||||
|
case text = 0 // 纯文字
|
||||||
|
case picture = 2 // 图片
|
||||||
|
|
||||||
|
/// 转换为API参数字符串
|
||||||
|
static func toAPIParameter(_ types: [MomentsContentType]) -> String {
|
||||||
|
return types.map { String($0.rawValue) }.joined(separator: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 最新动态 API 请求
|
||||||
|
|
||||||
|
/// 获取最新动态列表的API请求
|
||||||
|
struct LatestDynamicsRequest: APIRequestProtocol {
|
||||||
|
typealias Response = MomentsLatestResponse
|
||||||
|
|
||||||
|
let endpoint: String = APIEndpoint.latestDynamics.path
|
||||||
|
let method: HTTPMethod = .GET
|
||||||
|
|
||||||
|
let dynamicId: String
|
||||||
|
let pageSize: Int
|
||||||
|
let types: [MomentsContentType]
|
||||||
|
|
||||||
|
/// 初始化请求
|
||||||
|
/// - Parameters:
|
||||||
|
/// - dynamicId: 最新动态的ID,用于分页加载。首次请求传空字符串
|
||||||
|
/// - pageSize: 每页返回的数据数量,默认20
|
||||||
|
/// - types: 动态内容类型数组,默认包含文字和图片
|
||||||
|
init(
|
||||||
|
dynamicId: String = "",
|
||||||
|
pageSize: Int = 20,
|
||||||
|
types: [MomentsContentType] = [.text, .picture]
|
||||||
|
) {
|
||||||
|
self.dynamicId = dynamicId
|
||||||
|
self.pageSize = pageSize
|
||||||
|
self.types = types
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryParameters: [String: String]? {
|
||||||
|
return [
|
||||||
|
"dynamicId": dynamicId,
|
||||||
|
"pageSize": String(pageSize),
|
||||||
|
"types": MomentsContentType.toAPIParameter(types)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyParameters: [String: Any]? { nil }
|
||||||
|
|
||||||
|
var includeBaseParameters: Bool { true }
|
||||||
|
|
||||||
|
// Loading 配置
|
||||||
|
var shouldShowLoading: Bool { true }
|
||||||
|
var shouldShowError: Bool { true }
|
||||||
|
}
|
131
yana/APIs/data.txt
Normal file
131
yana/APIs/data.txt
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
📦 Response Data:
|
||||||
|
{
|
||||||
|
"code" : 200,
|
||||||
|
"message" : "success",
|
||||||
|
"data" : {
|
||||||
|
"nextDynamicId" : 243,
|
||||||
|
"dynamicList" : [
|
||||||
|
{
|
||||||
|
"scene" : "square",
|
||||||
|
"worldId" : -1,
|
||||||
|
"headwearEffect" : "https:\/\/image.hfighting.com\/1dfa2d36-e7c7-4f35-b7f9-1af83e13f7aa",
|
||||||
|
"headwearPic" : "https:\/\/image.hfighting.com\/2eafdf83-df63-4336-b677-8a163f6f20bc",
|
||||||
|
"status" : 0,
|
||||||
|
"experLevelPic" : "https:\/\/image.pekolive.com\/Wealth_51.png",
|
||||||
|
"headwearType" : 1,
|
||||||
|
"userVipInfoVO" : {
|
||||||
|
"vipIcon" : "https:\/\/image.pekolive.com\/v6.png",
|
||||||
|
"nameplateId" : 6,
|
||||||
|
"vipLogo" : "https:\/\/image.pekolive.com\/v6.mp4",
|
||||||
|
"userCardBG" : "https:\/\/image.molistar.xyz\/V6_user_bg.png",
|
||||||
|
"preventKick" : false,
|
||||||
|
"preventTrace" : false,
|
||||||
|
"preventFollow" : false,
|
||||||
|
"micNickColour" : "#A5FFDC",
|
||||||
|
"micCircle" : "https:\/\/image.molistar.xyz\/v6_mic_cycle.svga",
|
||||||
|
"enterRoomEffects" : "https:\/\/image.molistar.xyz\/v6_enter_effect.svga",
|
||||||
|
"medalSeat" : 7,
|
||||||
|
"friendNickColour" : "#A5FFDC",
|
||||||
|
"visitHide" : true,
|
||||||
|
"visitListView" : true,
|
||||||
|
"privateChatLimit" : false,
|
||||||
|
"nameplateUrl" : "https:\/\/image.molistar.xyz\/VIP6_nameplate.png",
|
||||||
|
"roomPicScreen" : true,
|
||||||
|
"uploadGifAvatar" : false,
|
||||||
|
"expireTime" : 1753675200000,
|
||||||
|
"enterHide" : false,
|
||||||
|
"vipLevel" : 6,
|
||||||
|
"vipName" : "VIP6"
|
||||||
|
},
|
||||||
|
"dynamicId" : 247,
|
||||||
|
"charmLevelPic" : "https:\/\/image.pekolive.com\/Charm_32.png",
|
||||||
|
"isCustomWord" : false,
|
||||||
|
"headwearName" : "海豚之心",
|
||||||
|
"type" : 0,
|
||||||
|
"topicTop" : 0,
|
||||||
|
"gender" : 1,
|
||||||
|
"uid" : 3184,
|
||||||
|
"defUser" : 1,
|
||||||
|
"nick" : "hansome",
|
||||||
|
"headwearId" : 6,
|
||||||
|
"labelList" : [
|
||||||
|
|
||||||
|
],
|
||||||
|
"commentCount" : 0,
|
||||||
|
"avatar" : "https:\/\/image.pekolive.com\/image\/023296a7d637e6406fb2aa19e967b607.jpeg",
|
||||||
|
"publishTime" : 1742801936000,
|
||||||
|
"newUser" : false,
|
||||||
|
"isLike" : false,
|
||||||
|
"likeCount" : 0,
|
||||||
|
"content" : "vicigiigohvhveerr让你表弟姐姐接你吧多半都不\n\n\n\n代表脯肉吧多半日品牌狠批人品很频频频频噢……在一起的时候就是一次又来了就可以😌!我们一起加油呀!我要好好学习📑!你好开心🥳、在一起的时候就像一只手握在一起\n",
|
||||||
|
"squareTop" : 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"scene" : "square",
|
||||||
|
"worldId" : -1,
|
||||||
|
"headwearEffect" : "https:\/\/image.hfighting.com\/1dfa2d36-e7c7-4f35-b7f9-1af83e13f7aa",
|
||||||
|
"headwearPic" : "https:\/\/image.hfighting.com\/2eafdf83-df63-4336-b677-8a163f6f20bc",
|
||||||
|
"status" : 1,
|
||||||
|
"experLevelPic" : "https:\/\/image.pekolive.com\/Wealth_48.png",
|
||||||
|
"headwearType" : 1,
|
||||||
|
"userVipInfoVO" : {
|
||||||
|
"vipIcon" : "https:\/\/image.pekolive.com\/v6.png",
|
||||||
|
"nameplateId" : 6,
|
||||||
|
"vipLogo" : "https:\/\/image.pekolive.com\/v6.mp4",
|
||||||
|
"userCardBG" : "https:\/\/image.molistar.xyz\/V6_user_bg.png",
|
||||||
|
"preventKick" : false,
|
||||||
|
"preventTrace" : false,
|
||||||
|
"preventFollow" : false,
|
||||||
|
"micNickColour" : "#A5FFDC",
|
||||||
|
"micCircle" : "https:\/\/image.molistar.xyz\/v6_mic_cycle.svga",
|
||||||
|
"enterRoomEffects" : "https:\/\/image.molistar.xyz\/v6_enter_effect.svga",
|
||||||
|
"medalSeat" : 7,
|
||||||
|
"friendNickColour" : "#A5FFDC",
|
||||||
|
"visitHide" : false,
|
||||||
|
"visitListView" : true,
|
||||||
|
"privateChatLimit" : false,
|
||||||
|
"nameplateUrl" : "https:\/\/image.molistar.xyz\/VIP6_nameplate.png",
|
||||||
|
"roomPicScreen" : true,
|
||||||
|
"uploadGifAvatar" : false,
|
||||||
|
"expireTime" : 1754712000000,
|
||||||
|
"enterHide" : false,
|
||||||
|
"vipLevel" : 6,
|
||||||
|
"vipName" : "VIP6"
|
||||||
|
},
|
||||||
|
"dynamicId" : 243,
|
||||||
|
"charmLevelPic" : "https:\/\/image.pekolive.com\/Charm_42.png",
|
||||||
|
"isCustomWord" : false,
|
||||||
|
"headwearName" : "海豚之心",
|
||||||
|
"type" : 2,
|
||||||
|
"dynamicResList" : [
|
||||||
|
{
|
||||||
|
"height" : 800,
|
||||||
|
"id" : 431,
|
||||||
|
"resDuration" : 0,
|
||||||
|
"width" : 800,
|
||||||
|
"resUrl" : "https:\/\/image.pekolive.com\/71bae51b-1466-4822-b29a-de4020a1f20a.jpg",
|
||||||
|
"format" : "image\/webp"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"topicTop" : 0,
|
||||||
|
"gender" : 1,
|
||||||
|
"uid" : 3354,
|
||||||
|
"defUser" : 4,
|
||||||
|
"nick" : "Easua",
|
||||||
|
"headwearId" : 6,
|
||||||
|
"labelList" : [
|
||||||
|
|
||||||
|
],
|
||||||
|
"commentCount" : 1,
|
||||||
|
"avatar" : "https:\/\/image.pekolive.com\/ec78214c-2b56-4069-a775-0820482f3228.gif",
|
||||||
|
"publishTime" : 1740447810000,
|
||||||
|
"newUser" : false,
|
||||||
|
"isLike" : false,
|
||||||
|
"likeCount" : 3,
|
||||||
|
"content" : "ABBBBBBB",
|
||||||
|
"squareTop" : 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"timestamp" : 1752231138900
|
||||||
|
}
|
119
yana/Features/FeedFeature.swift
Normal file
119
yana/Features/FeedFeature.swift
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import Foundation
|
||||||
|
import ComposableArchitecture
|
||||||
|
|
||||||
|
@Reducer
|
||||||
|
struct FeedFeature {
|
||||||
|
@ObservableState
|
||||||
|
struct State: Equatable {
|
||||||
|
var moments: [MomentsInfo] = []
|
||||||
|
var isLoading = false
|
||||||
|
var hasMoreData = true
|
||||||
|
var error: String?
|
||||||
|
var nextDynamicId: Int = 0
|
||||||
|
|
||||||
|
// 是否已初始化
|
||||||
|
var isInitialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Action: Equatable {
|
||||||
|
case onAppear
|
||||||
|
case loadLatestMoments
|
||||||
|
case loadMoreMoments
|
||||||
|
case momentsResponse(TaskResult<MomentsLatestResponse>)
|
||||||
|
case clearError
|
||||||
|
case retryLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dependency(\.apiService) var apiService
|
||||||
|
|
||||||
|
var body: some ReducerOf<Self> {
|
||||||
|
Reduce { state, action in
|
||||||
|
switch action {
|
||||||
|
case .onAppear:
|
||||||
|
// 只在首次出现时触发加载
|
||||||
|
guard !state.isInitialized else { return .none }
|
||||||
|
state.isInitialized = true
|
||||||
|
return .send(.loadLatestMoments)
|
||||||
|
|
||||||
|
case .loadLatestMoments:
|
||||||
|
// 加载最新数据(下拉刷新)
|
||||||
|
state.isLoading = true
|
||||||
|
state.error = nil
|
||||||
|
|
||||||
|
let request = LatestDynamicsRequest(
|
||||||
|
dynamicId: "", // 首次加载传空字符串
|
||||||
|
pageSize: 2,
|
||||||
|
types: [.text, .picture]
|
||||||
|
)
|
||||||
|
|
||||||
|
return .run { send in
|
||||||
|
await send(.momentsResponse(TaskResult {
|
||||||
|
try await apiService.request(request)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .loadMoreMoments:
|
||||||
|
// 加载更多数据(分页加载)
|
||||||
|
guard !state.isLoading && state.hasMoreData else { return .none }
|
||||||
|
|
||||||
|
state.isLoading = true
|
||||||
|
state.error = nil
|
||||||
|
|
||||||
|
let request = LatestDynamicsRequest(
|
||||||
|
dynamicId: state.nextDynamicId == 0 ? "" : String(state.nextDynamicId),
|
||||||
|
pageSize: 20,
|
||||||
|
types: [.text, .picture]
|
||||||
|
)
|
||||||
|
|
||||||
|
return .run { send in
|
||||||
|
await send(.momentsResponse(TaskResult {
|
||||||
|
try await apiService.request(request)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
case let .momentsResponse(.success(response)):
|
||||||
|
state.isLoading = false
|
||||||
|
|
||||||
|
// 检查响应状态
|
||||||
|
guard response.code == 200, let data = response.data else {
|
||||||
|
state.error = response.message.isEmpty ? "获取动态失败" : response.message
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是刷新还是加载更多
|
||||||
|
let isRefresh = state.nextDynamicId == 0
|
||||||
|
|
||||||
|
if isRefresh {
|
||||||
|
// 刷新:替换所有数据
|
||||||
|
state.moments = data.dynamicList
|
||||||
|
} else {
|
||||||
|
// 加载更多:追加到现有数据
|
||||||
|
state.moments.append(contentsOf: data.dynamicList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新分页状态
|
||||||
|
state.nextDynamicId = data.nextDynamicId
|
||||||
|
state.hasMoreData = !data.dynamicList.isEmpty
|
||||||
|
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case let .momentsResponse(.failure(error)):
|
||||||
|
state.isLoading = false
|
||||||
|
state.error = error.localizedDescription
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .clearError:
|
||||||
|
state.error = nil
|
||||||
|
return .none
|
||||||
|
|
||||||
|
case .retryLoad:
|
||||||
|
// 重试加载
|
||||||
|
if state.moments.isEmpty {
|
||||||
|
return .send(.loadLatestMoments)
|
||||||
|
} else {
|
||||||
|
return .send(.loadMoreMoments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,64 +1,242 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ComposableArchitecture
|
||||||
|
|
||||||
struct FeedView: View {
|
struct FeedView: View {
|
||||||
|
let store: StoreOf<FeedFeature>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
WithViewStore(store, observe: { $0 }) { viewStore in
|
||||||
ScrollView {
|
GeometryReader { geometry in
|
||||||
VStack(spacing: 20) {
|
ScrollView {
|
||||||
// 顶部区域 - 标题和加号按钮
|
VStack(spacing: 20) {
|
||||||
HStack {
|
// 顶部区域 - 标题和加号按钮
|
||||||
Spacer()
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
// 标题
|
// 标题
|
||||||
Text("Enjoy your Life Time")
|
Text("Enjoy your Life Time")
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// 右侧加号按钮
|
// 右侧加号按钮
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// 加号按钮操作
|
// 加号按钮操作
|
||||||
}) {
|
}) {
|
||||||
Image("add icon")
|
Image("add icon")
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.padding(.horizontal, 20)
|
||||||
.padding(.horizontal, 20)
|
|
||||||
// .padding(.top, geometry.safeAreaInsets.top + 20)
|
|
||||||
|
|
||||||
// 心脏图标
|
// 心脏图标
|
||||||
Image(systemName: "heart.fill")
|
Image(systemName: "heart.fill")
|
||||||
.font(.system(size: 60))
|
.font(.system(size: 60))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.padding(.top, 40)
|
.padding(.top, 40)
|
||||||
|
|
||||||
// 励志文字
|
// 励志文字
|
||||||
Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.")
|
Text("The disease is like a cruel ruler,\nand time is our most precious treasure.\nEvery moment we live is a victory\nagainst the inevitable.")
|
||||||
.font(.system(size: 16))
|
.font(.system(size: 16))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(.white.opacity(0.9))
|
.foregroundColor(.white.opacity(0.9))
|
||||||
.padding(.horizontal, 30)
|
.padding(.horizontal, 30)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
|
|
||||||
// 模拟动态卡片
|
// 真实动态数据
|
||||||
LazyVStack(spacing: 16) {
|
LazyVStack(spacing: 16) {
|
||||||
ForEach(0..<3) { index in
|
if viewStore.moments.isEmpty {
|
||||||
DynamicCardView(index: index)
|
// 空状态
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "heart.text.square")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(.white.opacity(0.6))
|
||||||
|
|
||||||
|
Text("暂无动态内容")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
|
||||||
|
if let error = viewStore.error {
|
||||||
|
Text("错误: \(error)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.red.opacity(0.8))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 40)
|
||||||
|
} else {
|
||||||
|
// 显示真实动态数据
|
||||||
|
ForEach(viewStore.moments, id: \.dynamicId) { moment in
|
||||||
|
RealDynamicCardView(moment: moment)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.padding(.horizontal, 16)
|
||||||
.padding(.horizontal, 16)
|
.padding(.top, 30)
|
||||||
.padding(.top, 30)
|
|
||||||
|
|
||||||
// 底部安全区域 - 为底部导航栏和安全区域留出空间
|
// 加载状态
|
||||||
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
|
if viewStore.isLoading {
|
||||||
|
HStack {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
|
Text("加载中...")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundColor(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部安全区域
|
||||||
|
Color.clear.frame(height: geometry.safeAreaInsets.bottom + 100)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewStore.send(.loadLatestMoments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
viewStore.send(.onAppear)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 动态卡片组件
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 旧的模拟卡片组件(保留备用)
|
||||||
struct DynamicCardView: View {
|
struct DynamicCardView: View {
|
||||||
let index: Int
|
let index: Int
|
||||||
|
|
||||||
@@ -142,5 +320,9 @@ struct DynamicCardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
FeedView()
|
FeedView(
|
||||||
|
store: Store(initialState: FeedFeature.State()) {
|
||||||
|
FeedFeature()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@@ -22,8 +22,12 @@ struct HomeView: View {
|
|||||||
ZStack {
|
ZStack {
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
case .feed:
|
case .feed:
|
||||||
FeedView()
|
FeedView(
|
||||||
.transition(.opacity)
|
store: Store(initialState: FeedFeature.State()) {
|
||||||
|
FeedFeature()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
case .me:
|
case .me:
|
||||||
MeView()
|
MeView()
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
Reference in New Issue
Block a user