diff --git a/Podfile b/Podfile index 5d7c5d5..c6c17cb 100644 --- a/Podfile +++ b/Podfile @@ -16,7 +16,7 @@ target 'yana' do # pod 'NELocalConversationUIKit', '10.6.1' # 本地会话列表组件。 # Networks -# pod 'Alamofire' + pod 'Alamofire' end post_install do |installer| diff --git a/Podfile.lock b/Podfile.lock index 31e689b..4dc3a44 100644 --- a/Podfile.lock +++ b/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 diff --git a/yana.xcodeproj/project.pbxproj b/yana.xcodeproj/project.pbxproj index c0215b5..4ab69b9 100644 --- a/yana.xcodeproj/project.pbxproj +++ b/yana.xcodeproj/project.pbxproj @@ -47,8 +47,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 4C4C8FBE2DE5AF9200384527 /* yanaAPITests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = yanaAPITests; sourceTree = ""; }; @@ -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; diff --git a/yana.xcodeproj/xcuserdata/edwinqqq.xcuserdatad/xcschemes/xcschememanagement.plist b/yana.xcodeproj/xcuserdata/edwinqqq.xcuserdatad/xcschemes/xcschememanagement.plist index e5c6177..9674f9b 100644 --- a/yana.xcodeproj/xcuserdata/edwinqqq.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/yana.xcodeproj/xcuserdata/edwinqqq.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ yana.xcscheme_^#shared#^_ orderHint - 1 + 3 diff --git a/yana/APIs/API dynamic feed rule.md b/yana/APIs/API dynamic feed rule.md new file mode 100644 index 0000000..2027f7b --- /dev/null +++ b/yana/APIs/API dynamic feed rule.md @@ -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 { + + // 构建请求参数 + 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() + + // 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. **错误重试**:网络错误时实现自动重试机制 \ No newline at end of file diff --git a/yana/APIs/APIEndpoints.swift b/yana/APIs/APIEndpoints.swift index 60b1611..8020659 100644 --- a/yana/APIs/APIEndpoints.swift +++ b/yana/APIs/APIEndpoints.swift @@ -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" diff --git a/yana/APIs/DynamicsModels.swift b/yana/APIs/DynamicsModels.swift new file mode 100644 index 0000000..f8cecef --- /dev/null +++ b/yana/APIs/DynamicsModels.swift @@ -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 } +} \ No newline at end of file diff --git a/yana/APIs/data.txt b/yana/APIs/data.txt new file mode 100644 index 0000000..7bf4527 --- /dev/null +++ b/yana/APIs/data.txt @@ -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 +} \ No newline at end of file diff --git a/yana/Features/FeedFeature.swift b/yana/Features/FeedFeature.swift new file mode 100644 index 0000000..bf209ab --- /dev/null +++ b/yana/Features/FeedFeature.swift @@ -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) + case clearError + case retryLoad + } + + @Dependency(\.apiService) var apiService + + var body: some ReducerOf { + 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) + } + } + } + } +} diff --git a/yana/Views/FeedView.swift b/yana/Views/FeedView.swift index 410a78f..74e5898 100644 --- a/yana/Views/FeedView.swift +++ b/yana/Views/FeedView.swift @@ -1,64 +1,242 @@ import SwiftUI +import ComposableArchitecture struct FeedView: View { + let store: StoreOf + var body: some View { - GeometryReader { geometry in - ScrollView { - VStack(spacing: 20) { - // 顶部区域 - 标题和加号按钮 - HStack { - Spacer() - - // 标题 - Text("Enjoy your Life Time") - .font(.system(size: 22, weight: .semibold)) - .foregroundColor(.white) - - Spacer() - - // 右侧加号按钮 - Button(action: { - // 加号按钮操作 - }) { - Image("add icon") - .frame(width: 36, height: 36) + 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) + + Spacer() + + // 右侧加号按钮 + Button(action: { + // 加号按钮操作 + }) { + Image("add icon") + .frame(width: 36, height: 36) + } } - } - .padding(.horizontal, 20) -// .padding(.top, geometry.safeAreaInsets.top + 20) - - // 心脏图标 - 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) - - // 模拟动态卡片 - LazyVStack(spacing: 16) { - ForEach(0..<3) { index in - DynamicCardView(index: index) + .padding(.horizontal, 20) + + // 心脏图标 + 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) + + // 真实动态数据 + 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) + + // 加载状态 + 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) } - .padding(.horizontal, 16) - .padding(.top, 30) - - // 底部安全区域 - 为底部导航栏和安全区域留出空间 - 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() + } + ) } diff --git a/yana/Views/HomeView.swift b/yana/Views/HomeView.swift index 3e9491f..c32a484 100644 --- a/yana/Views/HomeView.swift +++ b/yana/Views/HomeView.swift @@ -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)