// // ListView.swift // dimensionhub // // Created by 安礼成 on 2025/2/21. // import SwiftUI import Observation @Observable final class DetailModel { struct Episode: Codable, Identifiable { let id = UUID().uuidString let name: String let num_name: String let thumb: String let play: String enum CodingKeys: String, CodingKey { case name, num_name, thumb, play } } // 渠道 struct Channel: Codable { let name: String let episodes: [Episode] } struct DramaDetailResponse: Codable { let name: String let summary: String let thumb: String let status: [String] let channels: [Channel] } // 状态信息 struct DramaStatus { struct DisplayConfig { let name: String let bgColor: Color let fontColor: Color } let status: String let config: DisplayConfig init?(_ rawValue: String) { switch rawValue { case "first_row": self.status = rawValue self.config = DisplayConfig(name: "前排占位", bgColor: .black, fontColor: .white) case "following": self.status = rawValue self.config = DisplayConfig(name: "♡ 追番", bgColor: .black, fontColor: .white) case "catching_up": self.status = rawValue self.config = DisplayConfig(name: "补番", bgColor: .black, fontColor: .white) case "dropping": self.status = rawValue self.config = DisplayConfig(name: "弃番", bgColor: .white, fontColor: Color(hex: "#333333")) case "finished": self.status = rawValue self.config = DisplayConfig(name: "补完", bgColor: .black, fontColor: .white) default: return nil } } } enum FollowResult { case success case error(String, String) } var name: String = "" var summary: String = "" var thumb: String = "" var statuses: [DramaStatus] = [] var channels: [Channel] = [] // 当前选中的channel var selectedChannelIdx: Int = 0 var selectedEpisodes: [Episode] = [] func loadData(userId: String, id: Int) async { let response = await API.getDramaDetail(userId: userId, id: id, as: DramaDetailResponse.self) switch response { case .error(let code, let message): print(code) print(message) case .result(let detail): await MainActor.run { self.name = detail.name self.summary = Utils.converHtmlToString(html: detail.summary) ?? "" self.thumb = detail.thumb self.statuses = detail.status.flatMap({ s in if let status = DramaStatus(s) { return [status] } else { return [] } }) self.channels = detail.channels self.selectedChannelIdx = 0 self.selectedEpisodes = detail.channels[0].episodes } } } func toggleChannel(channelIdx: Int) { self.selectedChannelIdx = channelIdx self.selectedEpisodes = self.channels[channelIdx].episodes } func onTapFollowButton(userId: String, id: Int, status: String) async -> FollowResult { let response = await API.followDrama(userId: userId, id: id, status: status, as: [String].self) switch response { case .error(_, let message): return .error("错误", message) case .result(let newStatuses): self.statuses = newStatuses.flatMap({ s in if let status = DramaStatus(s) { return [status] } else { return [] } }) return .success } } } struct DetailView: View { @AppStorage("userId") private var userId: String = Utils.defaultUserId() @State var detailModel = DetailModel() @State var showAllSummary: Bool = false // 错误提示信息 @State var showAlert: Bool = false @State var errorInfo: (String, String) = ("", "") let id: Int var body: some View { VStack(alignment: .center) { VStack(alignment: .leading, spacing: 10) { HStack { Text(detailModel.name) .font(.system(size: 28)) .fontWeight(.bold) .foregroundColor(Color(hex: "#333333")) Spacer() } if showAllSummary { Text(detailModel.summary) .lineLimit(nil) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) .onTapGesture { withAnimation { self.showAllSummary = false } } } else { Text(detailModel.summary) .lineLimit(3) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) .onTapGesture { withAnimation { self.showAllSummary = true } } } } .padding(10) .background(Color(hex: "#F2F2F2"), ignoresSafeAreaEdges: [.top]) VStack(alignment: .center) { HStack(alignment: .center, spacing: 10) { Spacer() ForEach(detailModel.statuses, id: \.status) { status in FollowButtonView(dramaStatus: status) { followStatus in Task { let result = await detailModel.onTapFollowButton(userId: self.userId, id: id, status: followStatus) switch result { case .success: () case .error(let title, let message): DispatchQueue.main.async { self.errorInfo = (title, message) self.showAlert = true } } } } } } // 渠道列表 HStack(alignment: .center, spacing: 15) { ForEach(Array(detailModel.channels.enumerated()), id: \.offset) { idx, channel in Text(channel.name) .font(.system(size: 13)) .foregroundColor(idx == detailModel.selectedChannelIdx ? Color(hex: "#169BD5") : Color(hex: "#666666")) .onTapGesture { DispatchQueue.main.async { detailModel.toggleChannel(channelIdx: idx) } } } Spacer() } .padding(.leading, 10) // 渠道相关的数据列表 ScrollView(.horizontal, showsIndicators: false) { LazyHStack(alignment: .center) { ForEach(detailModel.selectedEpisodes) { episode in EpisodeView(episode: episode) } } .frame(height: 100) } if detailModel.selectedEpisodes.count >= 5 { HStack(alignment: .center) { NavigationLink(destination: ListView(id: self.id)) { Rectangle() .frame(width: 200, height: 25) .foregroundColor(Color(hex: "#F2F2F2")) .overlay { Text("展开全部剧集") .font(.system(size: 13)) .foregroundColor(Color(hex: "#999999")) .fontWeight(.regular) } } } } Spacer() } .frame(width: 370, alignment: .center) } .alert(isPresented: $showAlert) { Alert(title: Text(self.errorInfo.0), message: Text(self.errorInfo.1), dismissButton: .default(Text("OK"))) } .task { await detailModel.loadData(userId: self.userId, id: self.id) print(detailModel.summary) } } } extension DetailView { struct FollowButtonView: View { let dramaStatus: DetailModel.DramaStatus let onTap: (String) -> Void var body: some View { Rectangle() .frame(width: 140, height: 40) .foregroundColor(dramaStatus.config.bgColor) .cornerRadius(5) .overlay { RoundedRectangle(cornerRadius: 5) .stroke(Color.black, lineWidth: 1) Text(dramaStatus.config.name) .font(.system(size: 13)) .foregroundColor(dramaStatus.config.fontColor) .fontWeight(.regular) } .onTapGesture { onTap(dramaStatus.status) } } } struct EpisodeView: View { let episode: DetailModel.Episode var body: some View { VStack(alignment: .center) { AsyncImage(url: URL(string: episode.thumb)) { phase in switch phase { case .empty: ProgressView() case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .frame(width: 90, height: 70) .clipped() default: Image("ph_img_small") .resizable() .aspectRatio(contentMode: .fill) .clipped() } } .frame(width: 90, height: 70) .overlay(alignment: .topLeading) { if !episode.num_name.isEmpty { Text(episode.num_name) .font(.system(size: 12)) .foregroundColor(.white) .padding(3) .background(Color.black.opacity(0.5)) .cornerRadius(3) .padding(3) } else { EmptyView() } } Text(episode.name) .font(.system(size: 12)) .foregroundColor(Color(hex: "#333333")) .lineLimit(1) } .frame(width: 90) .onTapGesture { if let playUrl = URL(string: episode.play) { UIApplication.shared.open(playUrl) } } } } } #Preview { DetailView(id: 19625) }