diff --git a/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate b/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate index 79e8ee0..314d409 100644 Binary files a/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate and b/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/wake/View/Components/Upload/Avatar.swift b/wake/View/Components/Upload/Avatar.swift index e85ba83..07f115d 100644 --- a/wake/View/Components/Upload/Avatar.swift +++ b/wake/View/Components/Upload/Avatar.swift @@ -1,52 +1,60 @@ import SwiftUI import PhotosUI -/// 上传结果,包含原图和压缩图的上传信息 -struct UploadResults { - let original: ImageUploaderGetID.UploadResult - let compressed: ImageUploaderGetID.UploadResult +// MARK: - Data Models + +/// 上传进度信息 +public struct UploadProgress { + public let current: Int + public let total: Int + public let progress: Double + public let isOriginal: Bool + + public init(current: Int, total: Int, progress: Double, isOriginal: Bool) { + self.current = current + self.total = total + self.progress = progress + self.isOriginal = isOriginal + } } +/// 上传结果,包含原图和压缩图的上传信息 +public struct UploadResults { + public let original: ImageUploaderGetID.UploadResult + public let compressed: ImageUploaderGetID.UploadResult + + public init(original: ImageUploaderGetID.UploadResult, + compressed: ImageUploaderGetID.UploadResult) { + self.original = original + self.compressed = compressed + } +} + +// MARK: - Photo Picker + /// 照片选择器,封装了系统相册选择功能 -/// 使用UIViewControllerRepresentable包装PHPickerViewController,提供SwiftUI兼容的图片选择界面 struct PhotoPicker: UIViewControllerRepresentable { // MARK: - Properties - - /// 绑定的已选图片数组,用于存储用户选择的图片 @Binding var selectedImages: [UIImage] - - /// 最多可选图片数量,默认为1 let selectionLimit: Int - - /// 图片过滤器,默认为图片类型,可过滤特定类型的媒体 let filter: PHPickerFilter - - /// 图片上传完成回调,返回上传结果或错误 var onImageUploaded: ((Result) -> Void)? + var onUploadProgress: ((UploadProgress) -> Void)? // MARK: - Initialization - - /// 初始化照片选择器 - /// - Parameters: - /// - selectedImages: 绑定的图片数组,用于接收用户选择的图片 - /// - selectionLimit: 最多可选图片数量,默认为1 - /// - filter: 媒体类型过滤器,默认为图片 - /// - onImageUploaded: 图片上传完成后的回调闭包 - init( - selectedImages: Binding<[UIImage]>, - selectionLimit: Int = 1, - filter: PHPickerFilter = .images, - onImageUploaded: ((Result) -> Void)? = nil - ) { + init(selectedImages: Binding<[UIImage]>, + selectionLimit: Int = 1, + filter: PHPickerFilter = .images, + onImageUploaded: ((Result) -> Void)? = nil, + onUploadProgress: ((UploadProgress) -> Void)? = nil) { self._selectedImages = selectedImages self.selectionLimit = selectionLimit self.filter = filter self.onImageUploaded = onImageUploaded + self.onUploadProgress = onUploadProgress } // MARK: - UIViewControllerRepresentable - - /// 创建并返回配置好的PHPickerViewController func makeUIViewController(context: Context) -> PHPickerViewController { var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) configuration.filter = filter @@ -58,42 +66,30 @@ struct PhotoPicker: UIViewControllerRepresentable { return picker } - /// 更新视图控制器(空实现,因为不需要更新) func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} - /// 创建协调器,用于处理PHPickerViewController的代理方法 func makeCoordinator() -> Coordinator { Coordinator(self) } // MARK: - Coordinator - - /// 协调器类,处理PHPickerViewController的代理方法 class Coordinator: NSObject, PHPickerViewControllerDelegate { - /// 对父视图的弱引用 let parent: PhotoPicker - - /// 图片上传器实例 private let uploader = ImageUploaderGetID() init(_ parent: PhotoPicker) { self.parent = parent } - /// 当用户完成图片选择时调用 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - // 清空已选图片 parent.selectedImages.removeAll() - - // 使用DispatchGroup管理多个异步任务 let group = DispatchGroup() - var loadedImages: [Int: UIImage] = [:] // 用于保持图片顺序的字典 + var loadedImages: [Int: UIImage] = [:] var uploadResults: [Int: (original: ImageUploaderGetID.UploadResult?, - compressed: ImageUploaderGetID.UploadResult?)] = [:] + compressed: ImageUploaderGetID.UploadResult?)] = [:] - // 遍历所有选中的图片 for (index, result) in results.enumerated() { - group.enter() // 进入组 + group.enter() if result.itemProvider.canLoadObject(ofClass: UIImage.self) { result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] (image, error) in @@ -102,134 +98,138 @@ struct PhotoPicker: UIViewControllerRepresentable { return } - // 1. 保存原始图片 loadedImages[index] = image - // 2. 压缩图片(质量压缩到50%) guard let compressedImage = image.jpegData(compressionQuality: 0.5).flatMap(UIImage.init(data:)) else { group.leave() return } - // 3. 上传原图 - self.uploader.uploadImage(image) { [weak self] originalResult in - guard let self = self else { - group.leave() - return - } - - switch originalResult { - case .success(let originalUploadResult): - // 4. 原图上传成功后上传压缩图 - self.uploader.uploadImage(compressedImage) { compressedResult in - defer { group.leave() } - - switch compressedResult { - case .success(let compressedUploadResult): - // 保存两个上传结果 - uploadResults[index] = (originalUploadResult, compressedUploadResult) - print("✅ 原图和压缩图上传成功!") - print("📂 原图信息:") - print(" - 文件ID: \(originalUploadResult.fileId)") - print(" - 文件大小: \(originalUploadResult.fileSize) 字节") - print("📦 压缩图信息:") - print(" - 文件ID: \(compressedUploadResult.fileId)") - print(" - 文件大小: \(compressedUploadResult.fileSize) 字节") - - // 使用MaterialService上传文件信息到后端 - MaterialService.shared.uploadMaterialInfo( - fileId: originalUploadResult.fileId, - previewFileId: compressedUploadResult.fileId - ) { success, errorMessage in - if success { - print("✅ 文件信息上传成功 素材上传成功!!!!!") - } else if let errorMessage = errorMessage { - print("❌ 文件信息上传失败: \(errorMessage)") - } - } - - case .failure(let error): - print("❌ 压缩图上传失败: \(error.localizedDescription)") - uploadResults[index] = (originalUploadResult, nil) - } + self.uploader.uploadImage( + image, + progress: { [weak self] progress in + let progressInfo = UploadProgress( + current: Int(progress * 100), + total: 100, + progress: progress, + isOriginal: true + ) + DispatchQueue.main.async { + self?.parent.onUploadProgress?(progressInfo) + } + print("📤 原图上传进度: \(Int(progress * 100))%") + }, + completion: { [weak self] originalResult in + guard let self = self else { + group.leave() + return } - case .failure(let error): - print("❌ 原图上传失败: \(error.localizedDescription)") - group.leave() + switch originalResult { + case .success(let originalUploadResult): + self.uploader.uploadImage( + compressedImage, + progress: { [weak self] progress in + let progressInfo = UploadProgress( + current: Int(progress * 100), + total: 100, + progress: progress, + isOriginal: false + ) + DispatchQueue.main.async { + self?.parent.onUploadProgress?(progressInfo) + } + print("📊 压缩图上传进度: \(Int(progress * 100))%") + }, + completion: { compressedResult in + defer { group.leave() } + + switch compressedResult { + case .success(let compressedUploadResult): + uploadResults[index] = (originalUploadResult, compressedUploadResult) + print("✅ 原图和压缩图上传成功!") + print("📂 原图信息:") + print(" - 文件ID: \(originalUploadResult.fileId)") + print(" - 文件大小: \(originalUploadResult.fileSize) 字节") + print("📦 压缩图信息:") + print(" - 文件ID: \(compressedUploadResult.fileId)") + print(" - 文件大小: \(compressedUploadResult.fileSize) 字节") + + MaterialService.shared.uploadMaterialInfo( + fileId: originalUploadResult.fileId, + previewFileId: compressedUploadResult.fileId + ) { success, errorMessage in + if success { + print("✅ 文件信息上传成功 素材上传成功!!!!!") + } else if let errorMessage = errorMessage { + print("❌ 文件信息上传失败: \(errorMessage)") + } + } + + case .failure(let error): + print("❌ 压缩图上传失败: \(error.localizedDescription)") + uploadResults[index] = (originalUploadResult, nil) + } + } + ) + + case .failure(let error): + print("❌ 原图上传失败: \(error.localizedDescription)") + group.leave() + } } - } + ) } } else { group.leave() } } - // 所有上传任务完成后的处理 group.notify(queue: .main) { [weak self] in guard let self = self else { return } - // 1. 更新选中的图片(只显示原图) let sortedImages = loadedImages.sorted { $0.key < $1.key }.map { $0.value } self.parent.selectedImages.append(contentsOf: sortedImages) - // 2. 检查是否所有上传都成功 if let firstResult = uploadResults.first?.value, let original = firstResult.original, let compressed = firstResult.compressed { - // 3. 如果成功,返回上传结果 let results = UploadResults(original: original, compressed: compressed) self.parent.onImageUploaded?(.success(results)) } else { - // 4. 如果失败,返回错误 - self.parent.onImageUploaded?(.failure(NSError(domain: "com.wake.upload", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "上传过程中出现错误"]))) + self.parent.onImageUploaded?(.failure(NSError( + domain: "com.wake.upload", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "上传过程中出现错误"] + ))) } - // 5. 关闭图片选择器 picker.dismiss(animated: true) } } } } -// MARK: - AvatarUploader +// MARK: - Avatar Uploader /// 头像上传视图,提供头像选择功能 -/// 封装了头像显示和选择逻辑,支持点击选择新头像 struct AvatarUploader: View { - // MARK: - Properties - - /// 绑定的当前选中头像图片 @Binding var selectedImage: UIImage? - - /// 头像显示尺寸 let size: CGFloat - - /// 上传完成回调,返回上传结果或错误 var onUploadComplete: ((Result) -> Void)? - // MARK: - State - - /// 控制图片选择器的显示状态 @State private var isImagePickerPresented = false - // MARK: - Body - var body: some View { - // 头像按钮,点击后显示图片选择器 Button(action: { isImagePickerPresented = true }) { ZStack { if let selectedImage = selectedImage { - // 显示已选中的头像 Image(uiImage: selectedImage) .resizable() .scaledToFill() .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: size * 0.1)) } else { - // 默认头像占位视图 Color.gray.opacity(0.1) .frame(width: size, height: size) .overlay( @@ -244,11 +244,10 @@ struct AvatarUploader: View { } } .frame(width: size, height: size) - .contentShape(Rectangle()) // 确保整个区域都可点击 + .contentShape(Rectangle()) } - .buttonStyle(PlainButtonStyle()) // 使用无样式按钮 + .buttonStyle(PlainButtonStyle()) .sheet(isPresented: $isImagePickerPresented) { - // 显示图片选择器 PhotoPicker( selectedImages: Binding( get: { [selectedImage].compactMap { $0 } }, @@ -256,10 +255,12 @@ struct AvatarUploader: View { selectedImage = images.first } ), - selectionLimit: 1, // 限制只能选择一张图片 + selectionLimit: 1, onImageUploaded: { result in - // 图片上传完成后的处理 onUploadComplete?(result) + }, + onUploadProgress: { progress in + print("上传进度:\(progress.current)/\(progress.total),进度:\(progress.progress * 100)%") } ) } diff --git a/wake/View/Components/Upload/Compression.swift b/wake/View/Components/Upload/ImageUploader.swift similarity index 100% rename from wake/View/Components/Upload/Compression.swift rename to wake/View/Components/Upload/ImageUploader.swift diff --git a/wake/View/Components/Upload/ImageUploaderGetID.swift b/wake/View/Components/Upload/ImageUploaderGetID.swift index 4ea82f4..6eb056b 100644 --- a/wake/View/Components/Upload/ImageUploaderGetID.swift +++ b/wake/View/Components/Upload/ImageUploaderGetID.swift @@ -69,8 +69,13 @@ public class ImageUploaderGetID: ObservableObject { /// 上传图片到服务器 /// - Parameters: /// - image: 要上传的图片 - /// - completion: 完成回调,返回Result类型的结果 - public func uploadImage(_ image: UIImage, completion: @escaping (Result) -> Void) { + /// - progress: 上传进度回调 (0.0 到 1.0) + /// - completion: 完成回调 + public func uploadImage( + _ image: UIImage, + progress: @escaping (Double) -> Void, + completion: @escaping (Result) -> Void + ) { print("🔄 开始准备上传图片...") // 1. 转换图片为Data @@ -85,11 +90,37 @@ public class ImageUploaderGetID: ObservableObject { getUploadURL(for: imageData) { [weak self] result in switch result { case .success((let fileId, let uploadURL)): - // 3. 确认上传 - self?.confirmUpload(fileId: fileId, fileName: "avatar_\(UUID().uuidString).jpg", fileSize: imageData.count) { confirmResult in - completion(confirmResult) - } + print("📤 获取到上传URL,开始上传文件...") + + // 3. 上传文件 + _ = self?.uploadFile( + fileData: imageData, + to: uploadURL, + mimeType: "image/jpeg", + onProgress: { uploadProgress in + print("📊 上传进度: \(Int(uploadProgress * 100))%") + progress(uploadProgress) + }, + completion: { uploadResult in + switch uploadResult { + case .success: + // 4. 确认上传 + self?.confirmUpload( + fileId: fileId, + fileName: "avatar_\(UUID().uuidString).jpg", + fileSize: imageData.count, + completion: completion + ) + + case .failure(let error): + print("❌ 文件上传失败: \(error.localizedDescription)") + completion(.failure(error)) + } + } + ) + case .failure(let error): + print("❌ 获取上传URL失败: \(error.localizedDescription)") completion(.failure(error)) } } @@ -211,6 +242,182 @@ public class ImageUploaderGetID: ObservableObject { task.resume() } + + /// 上传文件到指定URL + /// - Parameters: + /// - fileData: 要上传的文件数据 + /// - uploadURL: 上传URL + /// - mimeType: 文件MIME类型 + /// - onProgress: 进度回调,0.0 到 1.0 + /// - completion: 完成回调 + public func uploadFile( + fileData: Data, + to uploadURL: URL, + mimeType: String = "application/octet-stream", + onProgress: @escaping (Double) -> Void, + completion: @escaping (Result) -> Void + ) -> URLSessionUploadTask { + var request = URLRequest(url: uploadURL) + request.httpMethod = "PUT" + request.setValue(mimeType, forHTTPHeaderField: "Content-Type") + + let task = session.uploadTask( + with: request, + from: fileData + ) { _, response, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + completion(.failure(UploadError.serverError("上传失败,状态码: \(statusCode)"))) + return + } + + completion(.success(())) + } + + // 添加进度观察 + if #available(iOS 11.0, *) { + let progressObserver = task.progress.observe(\.fractionCompleted) { (progressValue, _) in + DispatchQueue.main.async { + onProgress(progressValue.fractionCompleted) + } + } + + task.addCompletionHandler { [weak task] in + progressObserver.invalidate() + task?.progress.cancel() + } + } else { + // Fallback for earlier iOS versions + var lastProgress: Double = 0 + let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in + let bytesSent = task.countOfBytesSent + let totalBytes = task.countOfBytesExpectedToSend + let currentProgress = totalBytes > 0 ? Double(bytesSent) / Double(totalBytes) : 0 + + // 只有当进度有显著变化时才回调,避免频繁更新UI + if abs(currentProgress - lastProgress) > 0.01 || currentProgress >= 1.0 { + lastProgress = currentProgress + DispatchQueue.main.async { + onProgress(min(currentProgress, 1.0)) + } + } + + if currentProgress >= 1.0 { + timer.invalidate() + } + } + + task.addCompletionHandler { + timer.invalidate() + } + } + + task.resume() + return task + } + + // MARK: - 文件上传状态 + + /// 文件上传状态 + public struct FileStatus { + public let file: Data + public var status: UploadStatus + public var progress: Double + + public enum UploadStatus { + case pending + case uploading + case completed + case failed(Error) + } + + public init(file: Data, status: UploadStatus = .pending, progress: Double = 0) { + self.file = file + self.status = status + self.progress = progress + } + } +} + +// MARK: - URLSessionTask 扩展 + +private class TaskObserver: NSObject { + private weak var task: URLSessionTask? + private var handlers: [() -> Void] = [] + + init(task: URLSessionTask) { + self.task = task + super.init() + task.addObserver(self, forKeyPath: #keyPath(URLSessionTask.state), options: .new, context: nil) + } + + func addHandler(_ handler: @escaping () -> Void) { + handlers.append(handler) + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { + guard keyPath == #keyPath(URLSessionTask.state), + let task = task, + task.state == .completed else { + return + } + + // 调用所有完成处理器 + DispatchQueue.main.async { [weak self] in + self?.handlers.forEach { $0() } + self?.cleanup() + } + } + + private func cleanup() { + task?.removeObserver(self, forKeyPath: #keyPath(URLSessionTask.state)) + handlers.removeAll() + } + + deinit { + cleanup() + } +} + +private extension URLSessionTask { + private static var taskObserverKey: UInt8 = 0 + + private var taskObserver: TaskObserver? { + get { + return objc_getAssociatedObject(self, &Self.taskObserverKey) as? TaskObserver + } + set { + objc_setAssociatedObject(self, &Self.taskObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + func addCompletionHandler(_ handler: @escaping () -> Void) { + if #available(iOS 11.0, *) { + if let observer = taskObserver { + observer.addHandler(handler) + } else { + let observer = TaskObserver(task: self) + observer.addHandler(handler) + taskObserver = observer + } + } else { + // iOS 11 以下版本使用通知 + let name = NSNotification.Name("TaskCompleted\(self.taskIdentifier)") + NotificationCenter.default.addObserver( + forName: name, + object: self, + queue: .main + ) { _ in + handler() + } + } + } } // MARK: - 响应模型