diff --git a/wake/Utils/ApiClient/BlindBoxApi.swift b/wake/Utils/ApiClient/BlindBoxApi.swift index 7e9ab8b..3a7d846 100644 --- a/wake/Utils/ApiClient/BlindBoxApi.swift +++ b/wake/Utils/ApiClient/BlindBoxApi.swift @@ -59,6 +59,28 @@ class BlindBoxApi { ) } + /// 使用 async/await 生成盲盒 + /// - Parameters: + /// - boxType: 盲盒类型 (如 "First") + /// - materialIds: 素材ID数组 + /// - Returns: 盲盒数据 + @available(iOS 13.0, *) + func generateBlindBox(boxType: String, materialIds: [String]) async throws -> BlindBoxData? { + let parameters: [String: Any] = [ + "box_type": boxType, + "material_ids": materialIds + ] + let response: GenerateBlindBoxResponse = try await NetworkService.shared.postWithToken( + path: "/blind_box/generate", + parameters: parameters + ) + if response.code == 0 { + return response.data + } else { + throw NetworkError.serverError("服务器返回错误码: \(response.code)") + } + } + /// 获取盲盒信息 /// - Parameters: /// - boxId: 盲盒ID diff --git a/wake/Utils/MaterialUpload.swift b/wake/Utils/ApiClient/MaterialUpload.swift similarity index 57% rename from wake/Utils/MaterialUpload.swift rename to wake/Utils/ApiClient/MaterialUpload.swift index be39171..08ba3bd 100644 --- a/wake/Utils/MaterialUpload.swift +++ b/wake/Utils/ApiClient/MaterialUpload.swift @@ -63,4 +63,50 @@ class MaterialUpload { } ) } + + /// 使用 async/await 方式添加素材到服务器 + /// - Parameters: + /// - fileId: 文件ID + /// - previewFileId: 预览文件ID + /// - Returns: 结果ID数组(可为空) + /// - Throws: NetworkError 或其他错误 + func addMaterial( + fileId: String, + previewFileId: String + ) async throws -> [String]? { + // 创建请求数据(数组结构,与现有接口保持一致) + let materials: [[String: String]] = [[ + "file_id": fileId, + "preview_file_id": previewFileId + ]] + + // 调试信息 + print("🔍(async) 准备发送的参数: \(materials)") + + // 直接使用 async/await 版本的 post + let response: AddMaterialResponse = try await NetworkService.shared.post( + path: "/material", + parameters: materials + ) + + // 按业务约定检查 code + if response.code == 0 { + return response.data + } else { + throw NetworkError.serverError("服务器返回错误码: \(response.code)") + } + } + + func addMaterials(files: [[String: String]]) async throws -> [String]? { + let response: AddMaterialResponse = try await NetworkService.shared.post( + path: "/material", + parameters: files + ) + if response.code == 0 { + return response.data + } else { + throw NetworkError.serverError("服务器返回错误码: \(response.code)") + } + } } + diff --git a/wake/Utils/NetworkService.swift b/wake/Utils/NetworkService.swift index 75eb87c..c3aa9a7 100644 --- a/wake/Utils/NetworkService.swift +++ b/wake/Utils/NetworkService.swift @@ -143,6 +143,42 @@ extension NetworkService { } } } + + /// 使用 async/await 的 POST 请求(支持数组或字典参数) + public func post( + path: String, + parameters: Any? = nil, + headers: [String: String]? = nil + ) async throws -> T { + return try await withCheckedThrowingContinuation { continuation in + post(path: path, parameters: parameters, headers: headers) { (result: Result) in + switch result { + case .success(let value): + continuation.resume(returning: value) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// 使用 async/await 的 POST 请求(带Token,支持数组或字典参数) + public func postWithToken( + path: String, + parameters: Any? = nil, + headers: [String: String]? = nil + ) async throws -> T { + return try await withCheckedThrowingContinuation { continuation in + postWithToken(path: path, parameters: parameters, headers: headers) { (result: Result) in + switch result { + case .success(let value): + continuation.resume(returning: value) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } } public enum NetworkError: Error { diff --git a/wake/View/OnBoarding/MediaUploadView.swift b/wake/View/OnBoarding/MediaUploadView.swift new file mode 100644 index 0000000..8beff4b --- /dev/null +++ b/wake/View/OnBoarding/MediaUploadView.swift @@ -0,0 +1,722 @@ +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() + } + } +}