diff --git a/V2er/State/DataFlow/Model/FeedDetailInfo.swift b/V2er/State/DataFlow/Model/FeedDetailInfo.swift index 46e1e28..10eee78 100644 --- a/V2er/State/DataFlow/Model/FeedDetailInfo.swift +++ b/V2er/State/DataFlow/Model/FeedDetailInfo.swift @@ -217,6 +217,18 @@ struct FeedDetailInfo: BaseModel { userName == owner && owner != .empty } + /// 获取点赞数的整数值,用于排序 + var loveCount: Int { + // love 字段格式可能是 "♥ 3" 或 "3" 或空字符串 + let trimmed = love.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return 0 } + // 提取第一个连续的数字片段,避免将多个数字段拼接在一起 + let numberString = trimmed + .components(separatedBy: CharacterSet.decimalDigits.inverted) + .first(where: { !$0.isEmpty }) ?? "" + return Int(numberString) ?? 0 + } + static func == (lhs: Self, rhs: Self) -> Bool { lhs.floor == rhs.floor } diff --git a/V2er/State/DataFlow/State/FeedDetailState.swift b/V2er/State/DataFlow/State/FeedDetailState.swift index f67b7a8..b8000a6 100644 --- a/V2er/State/DataFlow/State/FeedDetailState.swift +++ b/V2er/State/DataFlow/State/FeedDetailState.swift @@ -8,6 +8,25 @@ import Foundation +enum ReplySortType: String, CaseIterable { + case byTime = "time" // 按时间排序(默认,即楼层顺序) + case byPopularity = "popularity" // 按热门排序(点赞数) + + var displayName: String { + switch self { + case .byTime: return "时间" + case .byPopularity: return "热门" + } + } + + var iconName: String { + switch self { + case .byTime: return "clock" + case .byPopularity: return "flame" + } + } +} + struct FeedDetailState: FluxState { var refCounts = 0 var reseted: Bool = false @@ -20,6 +39,7 @@ struct FeedDetailState: FluxState { var model: FeedDetailInfo = FeedDetailInfo() var ignored: Bool = false var replyContent: String = .empty + var replySortType: ReplySortType = .byTime } typealias FeedDetailStates=[String : FeedDetailState] diff --git a/V2er/View/FeedDetail/FeedDetailPage.swift b/V2er/View/FeedDetail/FeedDetailPage.swift index fb31ecc..009d3af 100644 --- a/V2er/View/FeedDetail/FeedDetailPage.swift +++ b/V2er/View/FeedDetail/FeedDetailPage.swift @@ -59,6 +59,23 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { !state.replyContent.isEmpty } + /// 根据当前排序方式返回排序后的回复列表 + private var sortedReplies: [FeedDetailInfo.ReplyInfo.Item] { + let items = state.model.replyInfo.items + switch state.replySortType { + case .byTime: + return items // 按时间排序(保持原始楼层顺序) + case .byPopularity: + // 按点赞数降序,相同点赞数按楼层升序 + return items.sorted { (a, b) in + if a.loveCount != b.loveCount { + return a.loveCount > b.loveCount + } + return a.floor < b.floor + } + } + } + private var isContentEmpty: Bool { let contentInfo = state.model.contentInfo return contentInfo == nil || contentInfo!.html.isEmpty @@ -167,8 +184,16 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { .listRowBackground(Color.itemBg) } + // Reply Section Header with Sort Toggle + if !state.model.replyInfo.items.isEmpty { + replySectionHeader + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.itemBg) + } + // Reply Section - ForEach(state.model.replyInfo.items) { item in + ForEach(sortedReplies, id: \.floor) { item in ReplyItemView(info: item, topicId: id) .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) @@ -291,6 +316,55 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { } } + /// 选中项的文字颜色(与 tintColor 背景形成对比) + private var segmentSelectedTextColor: Color { + Color.dynamicHex(light: 0xFFFFFF, dark: 0x1C1C1E) + } + + @ViewBuilder + private var replySectionHeader: some View { + HStack { + Text("回复") + .font(.subheadline.weight(.medium)) + .foregroundColor(.primaryText) + + Spacer() + + // Segmented sort picker + HStack(spacing: 0) { + ForEach(ReplySortType.allCases, id: \.self) { sortType in + Button { + store.appState.feedDetailStates[instanceId]?.replySortType = sortType + } label: { + HStack(spacing: 3) { + Image(systemName: sortType.iconName) + .font(.caption2) + Text(sortType.displayName) + .font(.caption) + } + .foregroundColor(state.replySortType == sortType ? segmentSelectedTextColor : .secondaryText) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + state.replySortType == sortType + ? Color.tintColor + : Color.clear + ) + } + } + } + .background(Color.secondaryBackground) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.border, lineWidth: 0.5) + ) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.lightGray.opacity(0.5)) + } + @ViewBuilder private var actionBar: some View { HStack (spacing: 10) {