feat: 更新Podfile和Podfile.lock,添加最新动态API文档和相关功能

- 在Podfile中添加Alamofire依赖,并更新Podfile.lock以反映更改。
- 新增动态内容API文档,详细描述`dynamic/square/latestDynamics`接口的请求参数、响应数据结构及示例。
- 实现动态内容的模型和API请求结构,支持获取最新动态列表。
- 更新FeedView和HomeView以集成动态内容展示,增强用户体验。
- 添加动态卡片组件,展示用户动态信息及互动功能。
This commit is contained in:
edwinQQQ
2025-07-11 20:18:24 +08:00
parent f9f3dec53f
commit 12bb4a5f8c
11 changed files with 1207 additions and 56 deletions

View File

@@ -16,7 +16,7 @@ target 'yana' do
# pod 'NELocalConversationUIKit', '10.6.1' # 本地会话列表组件。
# Networks
# pod 'Alamofire'
pod 'Alamofire'
end
post_install do |installer|

View File

@@ -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

View File

@@ -47,8 +47,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = yanaAPITests;
sourceTree = "<group>";
};
@@ -145,6 +143,7 @@
4C3E651B2DB61F7A00E5A455 /* Sources */,
4C3E651C2DB61F7A00E5A455 /* Frameworks */,
4C3E651D2DB61F7A00E5A455 /* Resources */,
0C5E1A8360250314246001A5 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -240,6 +239,27 @@
/* End PBXResourcesBuildPhase 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 */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;

View File

@@ -7,7 +7,7 @@
<key>yana.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
<integer>3</integer>
</dict>
</dict>
</dict>

View File

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

View File

@@ -19,6 +19,7 @@ enum APIEndpoint: String, CaseIterable {
case login = "/oauth/token"
case ticket = "/oauth/ticket"
case emailGetCode = "/email/getCode" //
case latestDynamics = "/dynamic/square/latestDynamics" //
// Web
case userAgreement = "/modules/rule/protocol.html"
case privacyPolicy = "/modules/rule/privacy-wap.html"

View 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]?
// IntBool
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
View 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
}

View 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)
}
}
}
}
}

View File

@@ -1,64 +1,242 @@
import SwiftUI
import ComposableArchitecture
struct FeedView: View {
let store: StoreOf<FeedFeature>
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 20) {
// -
HStack {
Spacer()
WithViewStore(store, observe: { $0 }) { viewStore in
GeometryReader { geometry in
ScrollView {
VStack(spacing: 20) {
// -
HStack {
Spacer()
//
Text("Enjoy your Life Time")
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
//
Text("Enjoy your Life Time")
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
Spacer()
Spacer()
//
Button(action: {
//
}) {
Image("add icon")
.frame(width: 36, height: 36)
//
Button(action: {
//
}) {
Image("add icon")
.frame(width: 36, height: 36)
}
}
}
.padding(.horizontal, 20)
// .padding(.top, geometry.safeAreaInsets.top + 20)
.padding(.horizontal, 20)
//
Image(systemName: "heart.fill")
.font(.system(size: 60))
.foregroundColor(.red)
.padding(.top, 40)
//
Image(systemName: "heart.fill")
.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.")
.font(.system(size: 16))
.multilineTextAlignment(.center)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.top, 20)
//
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))
.multilineTextAlignment(.center)
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 30)
.padding(.top, 20)
//
LazyVStack(spacing: 16) {
ForEach(0..<3) { index in
DynamicCardView(index: index)
//
LazyVStack(spacing: 16) {
if viewStore.moments.isEmpty {
//
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(.top, 30)
.padding(.horizontal, 16)
.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 {
let index: Int
@@ -142,5 +320,9 @@ struct DynamicCardView: View {
}
#Preview {
FeedView()
FeedView(
store: Store(initialState: FeedFeature.State()) {
FeedFeature()
}
)
}

View File

@@ -22,8 +22,12 @@ struct HomeView: View {
ZStack {
switch selectedTab {
case .feed:
FeedView()
.transition(.opacity)
FeedView(
store: Store(initialState: FeedFeature.State()) {
FeedFeature()
}
)
.transition(.opacity)
case .me:
MeView()
.transition(.opacity)