feat: 实现动态详情页及相关功能

- 在MePage和MomentListHomePage中新增动态点击事件,支持打开动态详情页。
- 创建MomentDetailPage视图,展示动态详细信息,包括用户信息、动态内容和互动按钮。
- 实现MomentDetailViewModel,管理动态详情页的状态和点赞逻辑。
- 更新MomentListItem组件,添加整体点击回调,提升用户交互体验。
- 优化背景视图组件,确保一致的视觉效果。
This commit is contained in:
edwinQQQ
2025-09-26 16:49:18 +08:00
parent d97de8455a
commit 327d4fd218
9 changed files with 523 additions and 102 deletions

View File

@@ -17,7 +17,7 @@ struct MomentsListData: Codable, Equatable, Sendable {
}
///
public struct MomentsInfo: Codable, Equatable, Sendable {
public struct MomentsInfo: Codable, Equatable, Sendable, Identifiable {
let dynamicId: Int
let uid: Int
let nick: String
@@ -51,6 +51,7 @@ public struct MomentsInfo: Codable, Equatable, Sendable {
let isCustomWord: Bool?
let labelList: [String]?
//
public var id: Int { dynamicId } // Identifiable
var isSquareTop: Bool { (squareTop ?? 0) != 0 }
var isTopicTop: Bool { (topicTop ?? 0) != 0 }
var formattedPublishTime: Date {
@@ -270,7 +271,8 @@ struct MyMomentInfo: Codable, Equatable, Sendable {
likeCount: likeCount ?? 0,
isLike: isLike ?? false,
commentCount: commentCount ?? 0,
publishTime: Int(publishTime / 1000),
// UI formatDisplayTime /1000
publishTime: Int(publishTime),
worldId: worldId ?? 0,
status: status ?? 1,
playCount: playCount,

View File

@@ -150,10 +150,9 @@ struct LiquidGlassBackground: View {
// MARK: -
struct LoginBackgroundView: View {
var body: some View {
Color.blue
// Image("bg")
// .resizable()
// .aspectRatio(contentMode: .fill)
Image("bg")
.resizable()
.aspectRatio(contentMode: .fill)
// .ignoresSafeArea(.all)
}
}

View File

@@ -98,7 +98,7 @@ struct CreateFeedPage: View {
}
.frame(height: 200)
.padding(.horizontal, 20)
.onChange(of: viewModel.content) { newValue in
.onChange(of: viewModel.content) { _, newValue in
//
if newValue.count > maxCharacters {
viewModel.content = String(newValue.prefix(maxCharacters))

View File

@@ -9,10 +9,13 @@ struct MePage: View {
@State private var previewItem: PreviewItem? = nil
@State private var previewCurrentIndex: Int = 0
//
@State private var selectedMoment: MomentsInfo? = nil
var body: some View {
ZStack {
//
MomentListBackgroundView()
// MomentListBackgroundView()
VStack(spacing: 0) {
// + + ID +
@@ -73,6 +76,10 @@ struct MePage: View {
onImageTap: { images, tappedIndex in
previewCurrentIndex = tappedIndex
previewItem = PreviewItem(images: images, index: tappedIndex)
},
onMomentTap: { tapped in
selectedMoment = tapped
debugInfoSync("➡️ MePage: 动态被点击 - ID: \(tapped.dynamicId)")
}
)
.padding(.horizontal, 16)
@@ -163,6 +170,16 @@ struct MePage: View {
previewItem = nil
}
}
//
.sheet(item: $selectedMoment) { moment in
MomentDetailPage(moment: moment) {
selectedMoment = nil
debugInfoSync("📱 MePage: 详情页已关闭")
}
.navigationBarHidden(true)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
}

View File

@@ -0,0 +1,245 @@
import SwiftUI
// MARK: - MomentDetailPage
struct MomentDetailPage: View {
@StateObject private var viewModel: MomentDetailViewModel
let onClose: () -> Void
init(moment: MomentsInfo, onClose: @escaping () -> Void) {
_viewModel = StateObject(wrappedValue: MomentDetailViewModel(moment: moment))
self.onClose = onClose
}
var body: some View {
ZStack {
//
LoginBackgroundView()
.ignoresSafeArea()
VStack(spacing: 0) {
//
HStack {
Button {
onClose()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 20, weight: .medium))
.foregroundColor(.white)
.frame(width: 44, height: 44)
.background(Color.black.opacity(0.3))
.clipShape(Circle())
}
Spacer()
Text(LocalizedString("detail.title", comment: "Detail page title"))
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.white)
.shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1)
Spacer()
//
Color.clear
.frame(width: 44, height: 44)
}
.padding(.horizontal, 16)
.safeAreaPadding(.top, 60)
.padding(.bottom, 12)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color.black.opacity(0.4),
Color.black.opacity(0.2),
Color.clear
]),
startPoint: .top,
endPoint: .bottom
)
)
//
ScrollView {
VStack(alignment: .leading, spacing: 12) {
//
HStack(alignment: .top) {
//
CachedAsyncImage(url: viewModel.moment.avatar) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.overlay(
Text(String(viewModel.moment.nick.prefix(1)))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
)
}
.frame(width: 44, height: 44)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.moment.nick)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
UserIDDisplay(uid: viewModel.moment.uid, fontSize: 12, textColor: .white.opacity(0.6))
}
Spacer()
//
Text(formatDisplayTime(viewModel.moment.publishTime))
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.white.opacity(0.15))
.cornerRadius(4)
}
//
if !viewModel.moment.content.isEmpty {
Text(viewModel.moment.content)
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.95))
.multilineTextAlignment(.leading)
}
//
if let images = viewModel.moment.dynamicResList, !images.isEmpty {
MomentImageGrid(
images: images,
onImageTap: { images, index in
viewModel.onImageTap(index)
}
)
}
//
HStack(spacing: 20) {
Button {
viewModel.like()
} label: {
HStack(spacing: 6) {
if viewModel.isLikeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: viewModel.localIsLike ? .red : .white.opacity(0.8)))
.scaleEffect(0.8)
} else {
Image(systemName: viewModel.localIsLike ? "heart.fill" : "heart")
.font(.system(size: 18))
}
Text("\(viewModel.localLikeCount)")
.font(.system(size: 16))
}
}
.foregroundColor(viewModel.localIsLike ? .red : .white.opacity(0.9))
.disabled(viewModel.isLikeLoading || viewModel.moment.status == 0)
.opacity(viewModel.moment.status == 0 ? 0.5 : 1.0)
Spacer()
// -
if viewModel.moment.status == 0 {
Text("reviewing")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.orange.opacity(0.85))
.clipShape(Capsule())
}
}
.padding(.top, 8)
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
.safeAreaPadding(.top, 8)
}
}
}
.navigationBarHidden(true)
.fullScreenCover(isPresented: $viewModel.showImagePreview) {
ImagePreviewPager(
images: viewModel.images,
currentIndex: $viewModel.currentIndex
) {
viewModel.showImagePreview = false
}
}
.onAppear {
debugInfoSync("📱 MomentDetailPage: 显示详情页")
debugInfoSync(" 动态ID: \(viewModel.moment.dynamicId)")
debugInfoSync(" 用户: \(viewModel.moment.nick)")
debugInfoSync(" 审核状态: \(viewModel.moment.status)")
}
}
// MARK: -
private func formatDisplayTime(_ timestamp: Int) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(timestamp) / 1000.0)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
let now = Date()
let interval = now.timeIntervalSince(date)
let calendar = Calendar.current
if calendar.isDateInToday(date) {
if interval < 60 {
return "刚刚"
} else if interval < 3600 {
return "\(Int(interval / 60))分钟前"
} else {
return "\(Int(interval / 3600))小时前"
}
} else {
formatter.dateFormat = "MM/dd"
return formatter.string(from: date)
}
}
}
//#Preview {
// let testMoment = MomentsInfo(
// dynamicId: 1,
// uid: 123456,
// nick: "",
// avatar: "",
// type: 0,
// content: " MomentDetailPage ",
// likeCount: 42,
// isLike: false,
// commentCount: 5,
// publishTime: Int(Date().timeIntervalSince1970 * 1000),
// worldId: 1,
// status: 0, //
// playCount: nil,
// dynamicResList: [
// MomentsPicture(id: 1, resUrl: "https://picsum.photos/300/300", format: "jpg", width: 300, height: 300, resDuration: nil),
// MomentsPicture(id: 2, resUrl: "https://picsum.photos/301/301", format: "jpg", width: 301, height: 301, resDuration: nil)
// ],
// gender: nil,
// squareTop: nil,
// topicTop: nil,
// newUser: nil,
// defUser: nil,
// scene: nil,
// userVipInfoVO: nil,
// headwearPic: nil,
// headwearEffect: nil,
// headwearType: nil,
// headwearName: nil,
// headwearId: nil,
// experLevelPic: nil,
// charmLevelPic: nil,
// isCustomWord: nil,
// labelList: nil
// )
//
// MomentDetailPage(moment: testMoment) {
// print("")
// }
//}

View File

@@ -21,13 +21,16 @@ struct MomentListHomePage: View {
@State private var previewItem: PreviewItem? = nil
@State private var previewCurrentIndex: Int = 0
// MARK: -
@State private var selectedMoment: MomentsInfo? = nil
// MARK: -
// MainPage TabView
var body: some View {
ZStack {
//
MomentListBackgroundView()
// MomentListBackgroundView()
VStack(alignment: .center, spacing: 0) {
// +
@@ -83,6 +86,13 @@ struct MomentListHomePage: View {
debugInfoSync(" 动态索引: \(index)")
debugInfoSync(" 图片索引: \(tappedIndex)")
debugInfoSync(" 图片数量: \(images.count)")
},
onMomentTap: { tappedMoment in
// -
selectedMoment = tappedMoment
debugInfoSync("➡️ MomentListHomePage: 动态被点击")
debugInfoSync(" 动态ID: \(tappedMoment.dynamicId)")
debugInfoSync(" 用户: \(tappedMoment.nick)")
}
)
.padding(.leading, 16)
@@ -178,6 +188,16 @@ struct MomentListHomePage: View {
debugInfoSync("📸 MomentListHomePage: 图片预览已关闭")
}
}
// MARK: -
.sheet(item: $selectedMoment) { moment in
MomentDetailPage(moment: moment) {
selectedMoment = nil
debugInfoSync("📱 MomentListHomePage: 详情页已关闭")
}
.navigationBarHidden(true)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
//
}
}

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct MomentListItem: View {
let moment: MomentsInfo
let onImageTap: (([String], Int)) -> Void //
let onMomentTap: (MomentsInfo) -> Void //
//
@State private var isLikeLoading = false
@@ -12,111 +13,134 @@ struct MomentListItem: View {
init(
moment: MomentsInfo,
onImageTap: @escaping (([String], Int)) -> Void = { (arg) in let (_, _) = arg; }
onImageTap: @escaping (([String], Int)) -> Void = { (arg) in let (_, _) = arg; },
onMomentTap: @escaping (MomentsInfo) -> Void = { _ in }
) {
self.moment = moment
self.onImageTap = onImageTap
self.onMomentTap = onMomentTap
//
self._localIsLike = State(initialValue: moment.isLike)
self._localLikeCount = State(initialValue: moment.likeCount)
}
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
//
VStack(alignment: .leading, spacing: 10) {
//
HStack(alignment: .top) {
//
CachedAsyncImage(url: moment.avatar) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.overlay(
Text(String(moment.nick.prefix(1)))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
)
}
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(moment.nick)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
UserIDDisplay(uid: moment.uid, fontSize: 12, textColor: .white.opacity(0.6))
}
Spacer()
//
Text(formatDisplayTime(moment.publishTime))
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.white.opacity(0.15))
.cornerRadius(4)
}
//
if !moment.content.isEmpty {
Text(moment.content)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading)
.padding(.leading, 40 + 8) //
}
//
if let images = moment.dynamicResList, !images.isEmpty {
MomentImageGrid(
images: images,
onImageTap: onImageTap
let isReviewing = moment.status == 0
ZStack(alignment: .bottomTrailing) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.padding(.leading, 40 + 8)
.padding(.bottom, images.count == 2 ? 30 : 0) //
}
//
HStack(spacing: 20) {
// Like
Button(action: {
if !isLikeLoading {
handleLikeTap()
.shadow(color: Color(red: 0.43, green: 0.43, blue: 0.43, opacity: 0.34), radius: 10.7, x: 0, y: 1.9)
//
VStack(alignment: .leading, spacing: 10) {
//
HStack(alignment: .top) {
//
CachedAsyncImage(url: moment.avatar) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.overlay(
Text(String(moment.nick.prefix(1)))
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
)
}
}) {
HStack(spacing: 4) {
if isLikeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: localIsLike ? .red : .white.opacity(0.8)))
.scaleEffect(0.8)
} else {
Image(systemName: localIsLike ? "heart.fill" : "heart")
.font(.system(size: 16))
}
Text("\(localLikeCount)")
.font(.system(size: 14))
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(moment.nick)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
UserIDDisplay(uid: moment.uid, fontSize: 12, textColor: .white.opacity(0.6))
}
.foregroundColor(localIsLike ? .red : .white.opacity(0.8))
Spacer()
//
Text(formatDisplayTime(moment.publishTime))
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white.opacity(0.8))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.white.opacity(0.15))
.cornerRadius(4)
}
.disabled(isLikeLoading)
.padding(.leading, 40 + 8) // +
Spacer()
//
if !moment.content.isEmpty {
Text(moment.content)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.9))
.multilineTextAlignment(.leading)
.padding(.leading, 40 + 8) //
}
//
if let images = moment.dynamicResList, !images.isEmpty {
MomentImageGrid(
images: images,
onImageTap: onImageTap
)
.padding(.leading, 40 + 8)
.padding(.bottom, images.count == 2 ? 30 : 0) //
}
//
HStack(spacing: 20) {
// Like
Button(action: {
if !isLikeLoading && !isReviewing {
handleLikeTap()
}
}) {
HStack(spacing: 4) {
if isLikeLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: localIsLike ? .red : .white.opacity(0.8)))
.scaleEffect(0.8)
} else {
Image(systemName: localIsLike ? "heart.fill" : "heart")
.font(.system(size: 16))
}
Text("\(localLikeCount)")
.font(.system(size: 14))
}
.foregroundColor(localIsLike ? .red : .white.opacity(0.8))
}
.disabled(isLikeLoading || isReviewing)
.opacity(isReviewing ? 0.5 : 1.0)
.padding(.leading, 40 + 8) // +
Spacer()
// -
if isReviewing {
Text("reviewing")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.orange.opacity(0.85))
.clipShape(Capsule())
}
}
.padding(.top, 8)
}
.padding(.top, 8)
.padding(16)
}
.contentShape(Rectangle())
.onTapGesture {
onMomentTap(moment)
}
.padding(16)
}
}

View File

@@ -0,0 +1,114 @@
import SwiftUI
import Combine
// MARK: - MomentDetailViewModel
@MainActor
final class MomentDetailViewModel: ObservableObject {
// MARK: - Published Properties
@Published var moment: MomentsInfo
@Published var isLikeLoading = false
@Published var localIsLike: Bool
@Published var localLikeCount: Int
@Published var showImagePreview = false
@Published var images: [String] = []
@Published var currentIndex: Int = 0
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(moment: MomentsInfo) {
self.moment = moment
self.localIsLike = moment.isLike
self.localLikeCount = moment.likeCount
self.images = moment.dynamicResList?.compactMap { $0.resUrl } ?? []
debugInfoSync("📱 MomentDetailViewModel: 初始化")
debugInfoSync(" 动态ID: \(moment.dynamicId)")
debugInfoSync(" 用户: \(moment.nick)")
debugInfoSync(" 图片数量: \(images.count)")
}
// MARK: - Public Methods
func onImageTap(_ index: Int) {
currentIndex = index
showImagePreview = true
debugInfoSync("📸 MomentDetailViewModel: 图片被点击,索引: \(index)")
}
func like() {
guard !isLikeLoading, moment.status != 0 else {
debugInfoSync("⏸️ MomentDetailViewModel: 跳过点赞 - 正在加载: \(isLikeLoading), 审核中: \(moment.status == 0)")
return
}
isLikeLoading = true
debugInfoSync("📡 MomentDetailViewModel: 开始点赞操作")
Task {
do {
// ID
guard let uidStr = await UserInfoManager.getCurrentUserId(),
let uid = Int(uidStr) else {
await MainActor.run {
isLikeLoading = false
}
setAPILoadingErrorSync(UUID(), errorMessage: "无法获取用户信息,请重新登录")
return
}
//
let status = localIsLike ? 0 : 1 // 0: , 1:
// API
let api = LiveAPIService()
//
let request = LikeDynamicRequest(
dynamicId: moment.dynamicId,
uid: uid,
status: status,
likedUid: moment.uid,
worldId: moment.worldId
)
debugInfoSync("📡 MomentDetailViewModel: 发送点赞请求")
debugInfoSync(" 动态ID: \(moment.dynamicId)")
debugInfoSync(" 当前状态: \(localIsLike)")
debugInfoSync(" 请求状态: \(status)")
//
let response: LikeDynamicResponse = try await api.request(request)
await MainActor.run {
isLikeLoading = false
//
if response.code == 200 {
localIsLike.toggle()
localLikeCount += localIsLike ? 1 : -1
debugInfoSync("✅ MomentDetailViewModel: 点赞操作成功")
debugInfoSync(" 动态ID: \(moment.dynamicId)")
debugInfoSync(" 新状态: \(localIsLike)")
debugInfoSync(" 新数量: \(localLikeCount)")
} else {
let errorMessage = response.message.isEmpty ? "点赞失败,请重试" : response.message
setAPILoadingErrorSync(UUID(), errorMessage: errorMessage)
debugErrorSync("❌ MomentDetailViewModel: 点赞操作失败")
debugErrorSync(" 动态ID: \(moment.dynamicId)")
debugErrorSync(" 错误: \(errorMessage)")
}
}
} catch {
await MainActor.run {
isLikeLoading = false
}
setAPILoadingErrorSync(UUID(), errorMessage: error.localizedDescription)
debugErrorSync("❌ MomentDetailViewModel: 点赞请求异常")
debugErrorSync(" 动态ID: \(moment.dynamicId)")
debugErrorSync(" 错误: \(error.localizedDescription)")
}
}
}
}

View File

@@ -90,10 +90,10 @@
"createFeed.processingImages" = "Processing images...";
"createFeed.publishing" = "Publishing...";
"createFeed.publish" = "Publish";
"createFeed.title" = "Image & Text Publish";
"createFeed.title" = "Image & Text";
// MARK: - Edit Feed
"editFeed.title" = "Image & Text Edit";
"editFeed.title" = "Image & Text";
"editFeed.publish" = "Publish";
"editFeed.enterContent" = "Enter Content";
@@ -225,4 +225,4 @@
"config.version" = "Version";
"config.debug_mode" = "Debug Mode";
"config.api_timeout" = "API Timeout";
"config.max_retries" = "Max Retries";
"config.max_retries" = "Max Retries";