import SwiftUI import PhotosUI import AVKit import CoreTransferable import CoreImage.CIFilterBuiltins extension Notification.Name { static let didAddFirstMedia = Notification.Name("didAddFirstMedia") } /// 主上传视图 /// 提供媒体选择、预览和上传功能 @MainActor struct MediaUploadView: View { // MARK: - 属性 /// 上传管理器,负责处理上传逻辑 @StateObject private var uploadManager = MediaUploadManager() /// 控制媒体选择器的显示/隐藏 @State private var showMediaPicker = false /// 当前选中的媒体项 @State private var selectedMedia: MediaType? = nil /// 当前选中的媒体索引集合 @State private var selectedIndices: Set = [] @State private var mediaPickerSelection: [MediaType] = [] // 添加这个状态变量 /// 上传完成状态 @State private var uploadComplete = false /// 上传完成的文件ID列表 @State private var uploadedFileIds: [[String: String]] = [] // MARK: - 视图主体 var body: some View { VStack(spacing: 0) { // 顶部导航栏 topNavigationBar // 上传提示信息 uploadHintView Spacer() .frame(height: uploadManager.selectedMedia.isEmpty ? 80 : 40) // 主上传区域 MainUploadArea( uploadManager: uploadManager, showMediaPicker: $showMediaPicker, selectedMedia: $selectedMedia ) .id("mainUploadArea\(uploadManager.selectedMedia.count)") Spacer() // // 上传结果展示 // if uploadComplete && !uploadedFileIds.isEmpty { // VStack(alignment: .leading) { // Text("上传完成!") // .font(.headline) // ScrollView { // ForEach(Array(uploadedFileIds.enumerated()), id: \.offset) { index, fileInfo in // VStack(alignment: .leading) { // Text("文件 \(index + 1):") // .font(.subheadline) // Text("ID: \(fileInfo["file_id"] ?? "")") // .font(.caption) // .foregroundColor(.gray) // } // .padding() // .frame(maxWidth: .infinity, alignment: .leading) // .background(Color.gray.opacity(0.1)) // .cornerRadius(8) // } // } // .frame(height: 200) // } // .padding() // } // 继续按钮 continueButton .padding(.bottom, 24) } .background(Color.themeTextWhiteSecondary) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .sheet(isPresented: $showMediaPicker) { // 媒体选择器 mediaPickerView } .onChange(of: uploadManager.uploadResults) { newResults in handleUploadCompletion(results: newResults) } } // MARK: - 子视图 /// 顶部导航栏 private var topNavigationBar: some View { HStack { // 返回按钮 Button(action: { Router.shared.pop() }) { Image(systemName: "chevron.left") .font(.system(size: 17, weight: .semibold)) .foregroundColor(.themeTextMessageMain) } .padding(.leading, 16) Spacer() // 标题 Text("Complete Your Profile") .font(Typography.font(for: .title2, family: .quicksandBold)) .foregroundColor(.themeTextMessageMain) Spacer() // 右侧占位视图(保持布局平衡) Color.clear .frame(width: 24, height: 24) .padding(.trailing, 16) } .background(Color.themeTextWhiteSecondary) // .padding(.horizontal) .zIndex(1) // 确保导航栏显示在最上层 } /// 上传提示视图 private var uploadHintView: some View { HStack (spacing: 6) { SVGImage(svgName: "Tips") .frame(width: 16, height: 16) .padding(.leading,6) Text("The upload process will take approximately 2 minutes. Thank you for your patience.") .font(.caption) .foregroundColor(.black) .frame(maxWidth: .infinity, alignment: .leading) .padding(3) } .background( Color.themeTextWhite .cornerRadius(6) ) .padding(.vertical, 8) .padding(.horizontal) } /// 继续按钮 private var continueButton: some View { Button(action: handleContinue) { Text("Continue") .font(.headline) .foregroundColor(uploadManager.selectedMedia.isEmpty ? Color.themeTextMessage : Color.themeTextMessageMain) .frame(maxWidth: .infinity) .frame(height: 56) .background(uploadManager.selectedMedia.isEmpty ? Color.white : Color.themePrimary) .cornerRadius(28) .padding(.horizontal, 24) } .buttonStyle(PlainButtonStyle()) .disabled(uploadManager.selectedMedia.isEmpty) } /// 媒体选择器视图 private var mediaPickerView: some View { MediaPicker( selectedMedia: Binding( get: { mediaPickerSelection }, set: { newSelections in print("🔄 开始处理用户选择的媒体文件") print("📌 新选择的媒体数量: \(newSelections.count)") // 1. 去重处理:过滤掉已经存在的媒体项 var uniqueNewMedia: [MediaType] = [] for newItem in newSelections { let isDuplicate = uploadManager.selectedMedia.contains { existingItem in switch (existingItem, newItem) { case (.image(let existingImage), .image(let newImage)): return existingImage.pngData() == newImage.pngData() case (.video(let existingURL, _), .video(let newURL, _)): return existingURL == newURL default: return false } } if !isDuplicate { uniqueNewMedia.append(newItem) } else { print("⚠️ 检测到重复文件,已跳过: \(newItem)") } } // 2. 添加新文件 if !uniqueNewMedia.isEmpty { print("✅ 添加 \(uniqueNewMedia.count) 个新文件") uploadManager.addMedia(uniqueNewMedia) // 如果没有当前选中的媒体,则选择第一个新添加的 if selectedMedia == nil, let firstNewItem = uniqueNewMedia.first { selectedMedia = firstNewItem } // 开始上传 uploadManager.startUpload() } else { print("ℹ️ 没有新文件需要添加,所有选择的文件都已存在") } } ), imageSelectionLimit: max(0, 20 - uploadManager.selectedMedia.filter { if case .image = $0 { return true } return false }.count), videoSelectionLimit: max(0, 5 - uploadManager.selectedMedia.filter { if case .video = $0 { return true } return false }.count), selectionMode: .multiple, onDismiss: handleMediaPickerDismiss, onUploadProgress: { index, progress in print("文件 \(index) 上传进度: \(progress * 100)%") } ) .onAppear { // 重置选择状态当选择器出现时 mediaPickerSelection = [] } } // MARK: - 私有方法 /// 处理媒体选择器关闭事件 private func handleMediaPickerDismiss() { showMediaPicker = false print("媒体选择器关闭 - 开始处理") // 如果有选中的媒体,开始上传 if !uploadManager.selectedMedia.isEmpty { // 不需要在这里开始上传,因为handleMediaChange会处理 } } /// 处理媒体变化 /// - Parameters: /// - newMedia: 新的媒体数组 /// - oldMedia: 旧的媒体数组 private func handleMediaChange(_ newMedia: [MediaType], oldMedia: [MediaType]) { print("开始处理媒体变化,新数量: \(newMedia.count), 原数量: \(oldMedia.count)") // 如果没有变化,直接返回 guard newMedia != oldMedia else { print("媒体未发生变化,跳过处理") return } // 在后台线程处理媒体变化 DispatchQueue.global(qos: .userInitiated).async { [self] in // 找出新增的媒体(在newMedia中但不在oldMedia中的项) let newItems = newMedia.filter { newItem in !oldMedia.contains { $0.id == newItem.id } } print("检测到\(newItems.count)个新增媒体项") // 如果有新增媒体 if !newItems.isEmpty { print("准备添加\(newItems.count)个新项...") // 在主线程更新UI DispatchQueue.main.async { [self] in // 创建新的数组,包含原有媒体和新媒体 var updatedMedia = uploadManager.selectedMedia updatedMedia.append(contentsOf: newItems) // 更新选中的媒体 uploadManager.clearAllMedia() uploadManager.addMedia(updatedMedia) // 如果当前没有选中的媒体,则选中第一个新增的媒体 if selectedIndices.isEmpty && !newItems.isEmpty { selectedIndices = [oldMedia.count] // 选择第一个新增项的索引 selectedMedia = newItems.first } // 开始上传新添加的媒体 uploadManager.startUpload() print("媒体添加完成,总数量: \(uploadManager.selectedMedia.count)") } } } } /// 检查是否有正在上传的文件 /// - Returns: 是否正在上传 private func isUploading() -> Bool { return uploadManager.uploadStatus.values.contains { status in if case .uploading = status { return true } return false } } /// 处理上传完成 private func handleUploadCompletion(results: [String: MediaUploadManager.UploadResult]) { // 转换为需要的格式 let formattedResults = results.map { (_, result) -> [String: String] in return [ "file_id": result.fileId, "preview_file_id": result.thumbnailId ?? result.fileId ] } uploadedFileIds = formattedResults uploadComplete = !uploadedFileIds.isEmpty } /// 处理继续按钮点击 private func handleContinue() { // 获取所有已上传文件的结果 let uploadResults = uploadManager.uploadResults guard !uploadResults.isEmpty else { print("⚠️ 没有可用的文件ID") return } // 准备请求参数 let files = uploadResults.map { (_, result) -> [String: String] in return [ "file_id": result.fileId, "preview_file_id": result.thumbnailId ?? result.fileId ] } // 提交素材,并利用返回的素材id数组,创建第二个盲盒 Task { do { let materialIds = try await MaterialUpload.shared.addMaterials(files: files) print("🚀 素材ID: \(materialIds ?? [])") // 创建盲盒 if let materialIds = materialIds { let result = try await BlindBoxApi.shared.generateBlindBox(boxType: "Second", materialIds: materialIds) print("🎉 盲盒结果: \(result ?? nil)") if let result = result { let blindBoxId = result.id ?? "" print("🎉 盲盒ID: \(blindBoxId)") // 导航到盲盒首页等待盲盒开启 Router.shared.navigate(to: .blindBox(mediaType: .video, blindBoxId: blindBoxId)) } } } catch { print("❌ 添加素材失败: \(error)") } } } } // MARK: - 主上传区域 /// 主上传区域视图 /// 显示上传提示、媒体预览和添加更多按钮 struct MainUploadArea: View { // MARK: - 属性 /// 上传管理器 @ObservedObject var uploadManager: MediaUploadManager /// 控制媒体选择器的显示/隐藏 @Binding var showMediaPicker: Bool /// 当前选中的媒体 @Binding var selectedMedia: MediaType? // MARK: - 视图主体 var body: some View { VStack() { Spacer() .frame(height: 30) // 标题 Text("Click to upload 5+ videos to generate your next blind box.") .font(Typography.font(for: .title2, family: .quicksandBold)) .fontWeight(.bold) .foregroundColor(.black) .multilineTextAlignment(.center) .padding(.horizontal) Spacer() .frame(height: 50) // 主显示区域 if let mediaToDisplay = selectedMedia ?? uploadManager.selectedMedia.first { Button(action: { showMediaPicker = true }) { MediaPreview(media: mediaToDisplay) .id(mediaToDisplay.id) .frame(width: 225, height: 225) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(Color.themePrimary, lineWidth: 5) ) .cornerRadius(16) .padding(.horizontal) .transition(.opacity) } } else { UploadPromptView(showMediaPicker: $showMediaPicker) } // 媒体预览区域 mediaPreviewSection Spacer() .frame(height: 10) } .onAppear { print("MainUploadArea appeared") print("Selected media count: \(uploadManager.selectedMedia.count)") if selectedMedia == nil, let firstMedia = uploadManager.selectedMedia.first { print("Selecting first media: \(firstMedia.id)") selectedMedia = firstMedia } } .onReceive(NotificationCenter.default.publisher(for: .didAddFirstMedia)) { notification in if let media = notification.userInfo?["media"] as? MediaType, selectedMedia == nil { selectedMedia = media } } .background(Color.white) .cornerRadius(18) .animation(.default, value: selectedMedia?.id) } // MARK: - 子视图 /// 媒体预览区域 private var mediaPreviewSection: some View { Group { if !uploadManager.selectedMedia.isEmpty { VStack(spacing: 4) { // 横向滚动的缩略图列表 ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 10) { ForEach(Array(uploadManager.selectedMedia.enumerated()), id: \.element.id) { index, media in mediaItemView(for: media, at: index) } // 当没有选择媒体时显示添加更多按钮 if !uploadManager.selectedMedia.isEmpty { addMoreButton } } .padding(.horizontal) } .frame(height: 70) } .padding(.top, 10) } } } /// 单个媒体项视图 /// - Parameters: /// - media: 媒体项 /// - index: 索引 /// - Returns: 媒体项视图 private func mediaItemView(for media: MediaType, at index: Int) -> some View { ZStack(alignment: .topTrailing) { // 媒体预览 - 始终使用本地资源 MediaPreview(media: media) .frame(width: 58, height: 58) .cornerRadius(8) .shadow(radius: 1) .overlay( // 左上角序号 ZStack(alignment: .topLeading) { Path { path in let radius: CGFloat = 4 let width: CGFloat = 14 let height: CGFloat = 10 // 从左上角开始(带圆角) path.move(to: CGPoint(x: 0, y: radius)) path.addQuadCurve(to: CGPoint(x: radius, y: 0), control: CGPoint(x: 0, y: 0)) // 上边缘(右上角保持直角) path.addLine(to: CGPoint(x: width, y: 0)) // 右边缘(右下角保持直角) path.addLine(to: CGPoint(x: width, y: height - radius)) // 右下角圆角 path.addQuadCurve(to: CGPoint(x: width - radius, y: height), control: CGPoint(x: width, y: height)) // 下边缘(左下角保持直角) path.addLine(to: CGPoint(x: 0, y: height)) // 闭合路径 path.closeSubpath() } .fill(Color(hex: "BEBEBE").opacity(0.6)) .frame(width: 14, height: 10) .overlay( Text("\(index + 1)") .font(.system(size: 8, weight: .bold)) .foregroundColor(.black) .frame(width: 14, height: 10) .offset(y: -1), alignment: .topLeading ) .padding([.top, .leading], 2) // 右下角视频时长 if case .video(let url, _) = media, let videoURL = url as? URL { VStack { Spacer() HStack { Spacer() Text(getVideoDuration(url: videoURL)) .font(.system(size: 8, weight: .bold)) .foregroundColor(.black) .padding(.horizontal, 4) .frame(height: 10) .background(Color(hex: "BEBEBE").opacity(0.6)) .cornerRadius(2) } .padding([.trailing, .bottom], 0) } }else{ // 占位 VStack { Spacer() HStack { Spacer() Text("占位") .font(.system(size: 8, weight: .bold)) .foregroundColor(.black) .padding(.horizontal, 4) .frame(height: 10) .background(Color(hex: "BEBEBE").opacity(0.6)) .cornerRadius(2) } .padding([.trailing, .bottom], 0) } .opacity(0) } }, alignment: .topLeading ) .onTapGesture { print("点击了媒体项,索引: \(index)") withAnimation { selectedMedia = media } } .contentShape(Rectangle()) // 右上角关闭按钮 Button(action: { uploadManager.removeMedia(id: media.id) if selectedMedia == media { selectedMedia = nil } }) { Image(systemName: "xmark") .font(.system(size: 8, weight: .bold)) .foregroundColor(.black) .frame(width: 12, height: 12) .background( Circle() .fill(Color(hex: "BEBEBE").opacity(0.6)) .frame(width: 12, height: 12) ) } .offset(x: 6, y: -6) } .padding(.horizontal, 4) .contentShape(Rectangle()) } /// 添加更多按钮 private var addMoreButton: some View { Button(action: { showMediaPicker = true }) { Image(systemName: "plus") .font(.system(size: 8, weight: .bold)) .foregroundColor(.black) .frame(width: 58, height: 58) .background(Color.white) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .strokeBorder(style: StrokeStyle( lineWidth: 2, dash: [4, 4] )) .foregroundColor(Color.themePrimary) ) } } } // MARK: - 上传提示视图 /// 上传提示视图 /// 显示上传区域的占位图和提示 struct UploadPromptView: View { /// 控制媒体选择器的显示/隐藏 @Binding var showMediaPicker: Bool var body: some View { Button(action: { showMediaPicker = true }) { // 上传图标 SVGImageHtml(svgName: "IP") .frame(width: 225, height: 225) .contentShape(Rectangle()) .overlay( ZStack { RoundedRectangle(cornerRadius: 20) .stroke(style: StrokeStyle( lineWidth: 5, lineCap: .round, dash: [12, 8] )) .foregroundColor(Color.themePrimary) // Add plus icon in the center Image(systemName: "plus") .font(.system(size: 32, weight: .bold)) .foregroundColor(.black) } ) } } } // MARK: - 媒体预览视图 /// 媒体预览视图 /// 显示图片或视频的预览图,始终使用本地资源 struct MediaPreview: View { // MARK: - 属性 /// 媒体类型 let media: MediaType // MARK: - 计算属性 /// 获取要显示的图片 private var displayImage: UIImage? { switch media { case .image(let uiImage): return uiImage case .video(_, let thumbnail): return thumbnail } } // MARK: - 视图主体 var body: some View { ZStack { // 1. 显示图片或视频缩略图 if let image = displayImage { Image(uiImage: image) .resizable() .scaledToFill() .transition(.opacity.animation(.easeInOut(duration: 0.2))) } else { // 2. 加载中的占位图 Color.gray.opacity(0.1) } } .aspectRatio(1, contentMode: .fill) .clipped() .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.themePrimary.opacity(0.3), lineWidth: 1) ) } } private func getVideoDuration(url: URL) -> String { let asset = AVURLAsset(url: url) let durationInSeconds = CMTimeGetSeconds(asset.duration) guard durationInSeconds.isFinite else { return "0:00" } let minutes = Int(durationInSeconds) / 60 let seconds = Int(durationInSeconds) % 60 return String(format: "%d:%02d", minutes, seconds) } // MARK: - Response Types private struct EmptyResponse: Decodable { // Empty response type for endpoints that don't return data } // MARK: - 扩展 /// 扩展 MediaType 以支持 Identifiable 协议 extension MediaType: Identifiable { /// 唯一标识符 public var id: String { switch self { case .image(let uiImage): return "image_\(uiImage.hashValue)" case .video(let url, _): return "video_\(url.absoluteString.hashValue)" } } } extension TimeInterval { var formattedDuration: String { let minutes = Int(self) / 60 let seconds = Int(self) % 60 return String(format: "%d:%02d", minutes, seconds) } } // MARK: - 预览 struct MediaUploadView_Previews: PreviewProvider { static var previews: some View { NavigationView { MediaUploadView() } } }