diff --git a/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate b/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate index ce3ab0a..0f10014 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 2757c5d..b084772 100644 --- a/wake/View/Components/Upload/Avatar.swift +++ b/wake/View/Components/Upload/Avatar.swift @@ -5,22 +5,15 @@ import PhotosUI /// 照片选择器,封装了系统相册选择功能 struct PhotoPicker: UIViewControllerRepresentable { - // MARK: - Properties - @Binding var selectedImages: [UIImage] + + /// 最多可选图片数量 let selectionLimit: Int + + /// 图片过滤器,默认为图片类型 let filter: PHPickerFilter - var onImageUploaded: ((Result) -> Void)? - var onUploadProgress: ((ImageUploadService.UploadProgress) -> Void)? - @Environment(\.presentationMode) private var presentationMode - // MARK: - Initialization - - init(selectedImages: Binding<[UIImage]>, - selectionLimit: Int = 1, - filter: PHPickerFilter = .images, - onImageUploaded: ((Result) -> Void)? = nil, - onUploadProgress: ((ImageUploadService.UploadProgress) -> Void)? = nil) { + init(selectedImages: Binding<[UIImage]>, selectionLimit: Int = 1, filter: PHPickerFilter = .images) { self._selectedImages = selectedImages self.selectionLimit = selectionLimit self.filter = filter @@ -30,7 +23,13 @@ struct PhotoPicker: UIViewControllerRepresentable { // MARK: - UIViewControllerRepresentable + // MARK: - UIViewControllerRepresentable 协议方法 + + /// 创建并返回PHPickerViewController实例 + /// - Parameter context: 上下文 + /// - Returns: 配置好的PHPickerViewController func makeUIViewController(context: Context) -> PHPickerViewController { + // 配置照片选择器 var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) configuration.filter = filter configuration.selectionLimit = selectionLimit @@ -41,139 +40,109 @@ struct PhotoPicker: UIViewControllerRepresentable { return picker } + /// 更新视图控制器 + /// - Parameters: + /// - uiViewController: 要更新的视图控制器 + /// - context: 上下文 func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + /// 创建协调器 + /// - Returns: 协调器实例 func makeCoordinator() -> Coordinator { Coordinator(self) } // MARK: - Coordinator + // MARK: - 协调器类 + + /// 协调器,处理PHPickerViewController的代理方法 class Coordinator: NSObject, PHPickerViewControllerDelegate { + /// 父视图引用 let parent: PhotoPicker private let uploadService = ImageUploadService.shared + /// 初始化方法 + /// - Parameter parent: 父视图 init(_ parent: PhotoPicker) { self.parent = parent } + /// 用户完成图片选择时调用 + /// - Parameters: + /// - picker: 图片选择器 + /// - results: 选中的图片结果 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { - guard !results.isEmpty else { - parent.presentationMode.wrappedValue.dismiss() - return - } - parent.selectedImages.removeAll() + let group = DispatchGroup() var loadedImages: [Int: UIImage] = [:] var uploadResults: [Int: ImageUploadService.UploadResults] = [:] var lastError: Error? + // 遍历所有选中的图片 for (index, result) in results.enumerated() { group.enter() + // 检查是否支持加载图片 if result.itemProvider.canLoadObject(ofClass: UIImage.self) { + // 异步加载图片 result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in - if let error = error { - lastError = error - group.leave() - return + if let image = image as? UIImage { + loadedImages[index] = image } - - guard let image = image as? UIImage else { - lastError = NSError(domain: "com.wake.upload", code: -2, userInfo: [NSLocalizedDescriptionKey: "Failed to load image"]) - group.leave() - return - } - - loadedImages[index] = image - - // Upload the image - self.uploadService.uploadOriginalAndCompressedImage( - image, - compressionQuality: 0.5, - progress: { progress in - DispatchQueue.main.async { - self.parent.onUploadProgress?(progress) - } - }, - completion: { result in - defer { group.leave() } - - switch result { - case .success(let results): - uploadResults[index] = results - - // Upload file info to backend - MaterialService.shared.uploadMaterialInfo( - fileId: results.original.fileId, - previewFileId: results.compressed.fileId - ) { success, errorMessage in - if success { - print("✅ 文件信息上传成功") - } else if let errorMessage = errorMessage { - print("❌ 文件信息上传失败: \(errorMessage)") - } - } - - case .failure(let error): - lastError = error - print("❌ 图片上传失败: \(error.localizedDescription)") - } - } - ) + group.leave() } } else { group.leave() } } - group.notify(queue: .main) { [weak self] in - guard let self = self else { return } + group.notify(queue: .main) { + // Sort the images by their original index to maintain selection order + let sortedImages = loadedImages.sorted { $0.key < $1.key }.map { $0.value } + self.parent.selectedImages.append(contentsOf: sortedImages) - if let error = lastError { - self.parent.onImageUploaded?(.failure(error)) - } else { - let sortedImages = loadedImages.sorted { $0.key < $1.key }.map { $0.value } - self.parent.selectedImages.append(contentsOf: sortedImages) - - if let firstResult = uploadResults.first?.value { - self.parent.onImageUploaded?(.success(firstResult)) - } else { - self.parent.onImageUploaded?(.failure(NSError( - domain: "com.wake.upload", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "上传过程中出现错误"] - ))) - } - } - - self.parent.presentationMode.wrappedValue.dismiss() + // Dismiss the picker + picker.dismiss(animated: true) } } } } -// MARK: - Avatar Uploader - -/// 头像上传视图,提供头像选择功能 +// MARK: - Avatar Uploader Component struct AvatarUploader: View { + // MARK: - 属性 + + /// 当前选中的头像图片 @Binding var selectedImage: UIImage? + + /// 头像尺寸 let size: CGFloat var onUploadComplete: ((Result) -> Void)? + // MARK: - 状态 + + /// 是否显示图片选择器 @State private var isImagePickerPresented = false + // MARK: - 视图 + var body: some View { - Button(action: { isImagePickerPresented = true }) { + Button(action: { + isImagePickerPresented = true + }) { ZStack { + // Avatar Image or Placeholder if let selectedImage = selectedImage { + // 已选择的头像图片 Image(uiImage: selectedImage) .resizable() .scaledToFill() .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: size * 0.1)) } else { + // Default avatar container Color.gray.opacity(0.1) .frame(width: size, height: size) .overlay( @@ -188,24 +157,19 @@ struct AvatarUploader: View { } } .frame(width: size, height: size) - .contentShape(Rectangle()) + .contentShape(Rectangle()) // Make the entire area tappable } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(PlainButtonStyle()) // Remove button highlight effect .sheet(isPresented: $isImagePickerPresented) { PhotoPicker( + // 绑定选中的图片 selectedImages: Binding( get: { [selectedImage].compactMap { $0 } }, set: { images in selectedImage = images.first } ), - selectionLimit: 1, - onImageUploaded: { result in - onUploadComplete?(result) - }, - onUploadProgress: { progress in - print("上传进度:\(progress.current)/\(progress.total),进度:\(Int(progress.progress * 100))%") - } + selectionLimit: 1 ) } } diff --git a/wake/View/Components/Upload/Upload.swift b/wake/View/Components/Upload/Upload.swift index e69de29..402ac0f 100644 --- a/wake/View/Components/Upload/Upload.swift +++ b/wake/View/Components/Upload/Upload.swift @@ -0,0 +1,354 @@ +import SwiftUI +import PhotosUI + +/// 上传响应数据结构体,用于解析服务器返回的上传URL和表单字段 +struct UploadResponse: Codable { + let url: String // 上传文件的目标URL + let fields: [String: String] // 上传所需的表单字段 +} + +/// 图片上传管理器,负责处理图片上传相关逻辑 +class ImageUploader: ObservableObject { + // MARK: - 发布属性 + @Published var isUploading = false // 是否正在上传 + @Published var uploadProgress: Double = 0 // 上传进度 + @Published var error: Error? // 上传错误信息 + + // 基础API地址 + private let baseURL = "https://api.memorywake.com/api/v1" + + /// 上传图片到服务器 + /// - Parameters: + /// - image: 要上传的图片 + /// - completion: 完成回调,返回上传结果 + func uploadImage(_ image: UIImage, completion: @escaping (Result) -> Void) { + // 1. 将图片转换为JPEG数据 + guard let imageData = image.jpegData(compressionQuality: 0.8) else { + completion(.failure(NSError(domain: "", code: -1, + userInfo: [NSLocalizedDescriptionKey: "图片数据转换失败"]))) + return + } + + // 2. 构建获取上传URL的请求 + guard let url = URL(string: "\(baseURL)/iam/file/generate-upload-url") else { + completion(.failure(NSError(domain: "", code: -1, + userInfo: [NSLocalizedDescriptionKey: "无效的URL"]))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + // 3. 发起获取上传URL的请求 + URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + guard let self = self else { return } + + // 处理网络错误 + if let error = error { + DispatchQueue.main.async { completion(.failure(error)) } + return + } + + // 检查响应数据 + guard let data = data else { + let error = NSError(domain: "", code: -1, + userInfo: [NSLocalizedDescriptionKey: "未收到服务器响应"]) + DispatchQueue.main.async { completion(.failure(error)) } + return + } + + do { + // 4. 解析上传URL响应 + let uploadResponse = try JSONDecoder().decode(UploadResponse.self, from: data) + // 5. 开始实际上传文件 + self.uploadFile(to: uploadResponse.url, + with: uploadResponse.fields, + imageData: imageData, + completion: completion) + } catch { + DispatchQueue.main.async { completion(.failure(error)) } + } + }.resume() + } + + /// 实际上传文件到指定的URL + /// - Parameters: + /// - url: 上传URL + /// - fields: 上传所需的表单字段 + /// - imageData: 图片数据 + /// - completion: 完成回调 + private func uploadFile(to url: String, + with fields: [String: String], + imageData: Data, + completion: @escaping (Result) -> Void) { + + // 验证上传URL + guard let uploadURL = URL(string: url) else { + completion(.failure(NSError(domain: "", code: -1, + userInfo: [NSLocalizedDescriptionKey: "无效的上传URL"]))) + return + } + + // 配置上传请求 + var request = URLRequest(url: uploadURL) + request.httpMethod = "POST" + + // 设置multipart/form-data边界 + let boundary = "Boundary-\(UUID().uuidString)" + request.setValue("multipart/form-data; boundary=\(boundary)", + forHTTPHeaderField: "Content-Type") + + // 构建请求体 + var body = Data() + + // 添加表单字段 + for (key, value) in fields { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + body.append("\(value)\r\n".data(using: .utf8)!) + } + + // 添加图片数据 + let filename = "\(UUID().uuidString).jpg" + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!) + body.append(imageData) + body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) + + request.httpBody = body + + // 执行上传任务 + URLSession.shared.dataTask(with: request) { _, _, error in + DispatchQueue.main.async { + if let error = error { + completion(.failure(error)) + } else { + completion(.success("上传成功")) + } + } + }.resume() + } +} + +/// 照片选择器,封装了系统相册选择功能 +struct PhotoPicker: UIViewControllerRepresentable { + // MARK: - 属性 + @Binding var selectedImages: [UIImage] // 绑定的已选图片数组 + let selectionLimit: Int // 最多可选图片数量 + let filter: PHPickerFilter // 图片过滤器 + var onUploadComplete: ((Result) -> Void)? = nil // 上传完成回调 + private let uploader = ImageUploader() // 图片上传器 + + // MARK: - 初始化方法 + init(selectedImages: Binding<[UIImage]>, + selectionLimit: Int = 1, + filter: PHPickerFilter = .images, + onUploadComplete: ((Result) -> Void)? = nil) { + self._selectedImages = selectedImages + self.selectionLimit = selectionLimit + self.filter = filter + self.onUploadComplete = onUploadComplete + } + + // MARK: - UIViewControllerRepresentable 协议方法 + + /// 创建并返回PHPickerViewController实例 + func makeUIViewController(context: Context) -> PHPickerViewController { + var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) + configuration.filter = filter + configuration.selectionLimit = selectionLimit + configuration.preferredAssetRepresentationMode = .current + + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = context.coordinator + return picker + } + + /// 更新视图控制器 + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + /// 创建协调器 + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + // MARK: - 协调器类 + + /// 协调器,处理PHPickerViewController的代理方法 + class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: PhotoPicker + + init(_ parent: PhotoPicker) { + self.parent = parent + } + + /// 用户完成图片选择时调用 + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + parent.selectedImages.removeAll() + + let group = DispatchGroup() + var loadedImages: [Int: UIImage] = [:] + + // 加载选中的图片 + for (index, result) in results.enumerated() { + group.enter() + + // 检查是否支持加载图片 + if result.itemProvider.canLoadObject(ofClass: UIImage.self) { + result.itemProvider.loadObject(ofClass: UIImage.self) { [weak self] (image, error) in + if let image = image as? UIImage { + // 保存加载的图片 + loadedImages[index] = image + + // 立即上传图片 + self?.parent.uploader.uploadImage(image) { result in + self?.parent.onUploadComplete?(result) + } + } + group.leave() + } + } else { + group.leave() + } + } + + // 所有图片加载完成后的处理 + group.notify(queue: .main) { + // 按原始顺序排序图片 + let sortedImages = loadedImages.sorted { $0.key < $1.key }.map { $0.value } + self.parent.selectedImages.append(contentsOf: sortedImages) + + // 关闭选择器 + picker.dismiss(animated: true) + } + } + } +} + +// MARK: - 头像上传组件 + +/// 头像上传视图,提供头像选择、上传和显示功能 +struct AvatarUploader: View { + // MARK: - 属性 + @Binding var selectedImage: UIImage? // 当前选中的头像图片 + let size: CGFloat // 头像尺寸 + + // MARK: - 状态 + @State private var isImagePickerPresented = false // 是否显示图片选择器 + @State private var isUploading = false // 是否正在上传 + @State private var uploadError: Error? // 上传错误信息 + + // MARK: - 视图 + var body: some View { + Button(action: showImagePicker) { + 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( + SVGImage(svgName: "Avatar") + .frame(width: size * 0.8, height: size * 0.8) + ) + .clipShape(RoundedRectangle(cornerRadius: size * 0.1)) + .overlay( + RoundedRectangle(cornerRadius: size * 0.1) + .stroke(Color.gray.opacity(0.3), lineWidth: 1) + ) + } + + // 上传加载指示器 + if isUploading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: size * 0.1)) + } + } + .frame(width: size, height: size) + .contentShape(Rectangle()) // 使整个区域可点击 + } + .buttonStyle(PlainButtonStyle()) // 移除按钮默认样式 + .alert("上传失败", isPresented: .constant(uploadError != nil)) { + Button("确定") { + uploadError = nil + } + } message: { + if let error = uploadError { + Text(error.localizedDescription) + } else { + Text("请重试") + } + } + .sheet(isPresented: $isImagePickerPresented) { + // 图片选择器 + PhotoPicker( + selectedImages: Binding( + get: { [selectedImage].compactMap { $0 } }, + set: { images in + selectedImage = images.first + } + ), + selectionLimit: 1, + onUploadComplete: handleUploadResult + ) + .onAppear { + isUploading = true + } + } + } + + // MARK: - 私有方法 + + /// 显示图片选择器 + private func showImagePicker() { + isImagePickerPresented = true + } + + /// 处理上传结果 + private func handleUploadResult(_ result: Result) { + isUploading = false + + switch result { + case .success(let message): + print("上传成功: \(message)") + case .failure(let error): + uploadError = error + print("上传失败: \(error.localizedDescription)") + } + } +} + +// MARK: - 预览提供程序 +#if DEBUG +struct AvatarUploader_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 20) { + // 默认状态预览 + AvatarUploader(selectedImage: .constant(nil), size: 100) + .padding() + .previewDisplayName("默认状态") + + // 已选择图片状态预览 + AvatarUploader( + selectedImage: .constant(UIImage(systemName: "person.crop.circle.fill")), + size: 120 + ) + .padding() + .previewDisplayName("已选择图片") + } + } +} +#endif \ No newline at end of file