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' # 本地会话列表组件。
|
||||
|
||||
# Networks
|
||||
# pod 'Alamofire'
|
||||
pod 'Alamofire'
|
||||
end
|
||||
|
||||
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
|
||||
|
@@ -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;
|
||||
|
@@ -7,7 +7,7 @@
|
||||
<key>yana.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>3</integer>
|
||||
</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 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"
|
||||
|
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 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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user