diff --git a/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate b/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate index 2a4b61b..145f50a 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 d5e6deb..359836a 100644 --- a/wake/View/Components/Upload/Avatar.swift +++ b/wake/View/Components/Upload/Avatar.swift @@ -1,18 +1,39 @@ import SwiftUI import PhotosUI +/// 照片选择器,封装了系统相册选择功能 struct PhotoPicker: UIViewControllerRepresentable { + // MARK: - 属性 + + /// 绑定的已选图片数组 @Binding var selectedImages: [UIImage] + + /// 最多可选图片数量 let selectionLimit: Int + + /// 图片过滤器,默认为图片类型 let filter: PHPickerFilter + // MARK: - 初始化方法 + + /// 初始化照片选择器 + /// - Parameters: + /// - selectedImages: 绑定的已选图片数组 + /// - selectionLimit: 最多可选图片数量,默认为1 + /// - filter: 图片过滤器,默认为.images init(selectedImages: Binding<[UIImage]>, selectionLimit: Int = 1, filter: PHPickerFilter = .images) { self._selectedImages = selectedImages self.selectionLimit = selectionLimit self.filter = filter } + // MARK: - UIViewControllerRepresentable 协议方法 + + /// 创建并返回PHPickerViewController实例 + /// - Parameter context: 上下文 + /// - Returns: 配置好的PHPickerViewController func makeUIViewController(context: Context) -> PHPickerViewController { + // 配置照片选择器 var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) configuration.filter = filter configuration.selectionLimit = selectionLimit @@ -23,31 +44,53 @@ struct PhotoPicker: UIViewControllerRepresentable { return picker } + /// 更新视图控制器 + /// - Parameters: + /// - uiViewController: 要更新的视图控制器 + /// - context: 上下文 func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + /// 创建协调器 + /// - Returns: 协调器实例 func makeCoordinator() -> Coordinator { Coordinator(self) } + // MARK: - 协调器类 + + /// 协调器,处理PHPickerViewController的代理方法 class Coordinator: NSObject, PHPickerViewControllerDelegate { + /// 父视图引用 let parent: PhotoPicker + /// 初始化方法 + /// - Parameter parent: 父视图 init(_ parent: PhotoPicker) { self.parent = parent } + /// 用户完成图片选择时调用 + /// - Parameters: + /// - picker: 图片选择器 + /// - results: 选中的图片结果 func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + // 清空之前选中的图片 parent.selectedImages.removeAll() + // 使用DispatchGroup管理异步图片加载 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) { (image, error) in if let image = image as? UIImage { + // 保存加载的图片,保持原始顺序 loadedImages[index] = image } group.leave() @@ -57,39 +100,55 @@ struct PhotoPicker: UIViewControllerRepresentable { } } + // 所有图片加载完成后的处理 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) - // Dismiss the picker + // 关闭图片选择器 picker.dismiss(animated: true) } } } } -// MARK: - Avatar Uploader Component +// MARK: - 头像上传组件 + +/// 头像上传视图,提供头像选择功能 struct AvatarUploader: View { + // MARK: - 属性 + + /// 当前选中的头像图片 @Binding var selectedImage: UIImage? + + /// 头像尺寸 let size: CGFloat + // MARK: - 状态 + + /// 是否显示图片选择器 @State private var isImagePickerPresented = false + // MARK: - 视图 + var body: some View { 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( @@ -104,17 +163,22 @@ struct AvatarUploader: View { } } .frame(width: size, height: size) - .contentShape(Rectangle()) // Make the entire area tappable + // 使整个区域可点击 + .contentShape(Rectangle()) } - .buttonStyle(PlainButtonStyle()) // Remove button highlight effect + // 移除按钮默认高亮效果 + .buttonStyle(PlainButtonStyle()) + // 显示图片选择器 .sheet(isPresented: $isImagePickerPresented) { PhotoPicker( + // 绑定选中的图片 selectedImages: Binding( get: { [selectedImage].compactMap { $0 } }, set: { images in selectedImage = images.first } ), + // 限制只能选择一张图片 selectionLimit: 1 ) } diff --git a/wake/View/Components/Upload/Upload.swift b/wake/View/Components/Upload/Upload.swift new file mode 100644 index 0000000..402ac0f --- /dev/null +++ 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