diff --git a/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate b/wake.xcodeproj/project.xcworkspace/xcuserdata/elliwood.xcuserdatad/UserInterfaceState.xcuserstate index 3ac497c..8deb0a0 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/Utils/APIConfig.swift b/wake/Utils/APIConfig.swift new file mode 100644 index 0000000..d92ff1d --- /dev/null +++ b/wake/Utils/APIConfig.swift @@ -0,0 +1,19 @@ +import Foundation + +/// API 配置信息 +public enum APIConfig { + /// API 基础 URL + public static let baseURL = "https://api.memorywake.com/api/v1" + + /// 认证 token - 生产环境中应该存储在 Keychain 中 + public static let authToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJqdGkiOjczNjM0ODY2MTE1MDc2NDY0NjQsImlkZW50aXR5IjoiNzM1MDQzOTY2MzExNjYxOTg4OCIsImV4cCI6MTc1NjE5NjgxNX0.hRC_So6LHuR6Gx-bDyO8aliVOd-Xumul8M7cydi2pTxHPweBx4421AfZ5BjGoEEwRZPIXJ5z7a1aDB7qvjpLCA" + + /// 认证请求头 + public static var authHeaders: [String: String] { + return [ + "Authorization": "Bearer \(authToken)", + "Content-Type": "application/json", + "Accept": "application/json" + ] + } +} diff --git a/wake/View/Components/Upload/Avatar.swift b/wake/View/Components/Upload/Avatar.swift index 5f25271..bf7e0f9 100644 --- a/wake/View/Components/Upload/Avatar.swift +++ b/wake/View/Components/Upload/Avatar.swift @@ -1,190 +1,46 @@ import SwiftUI import PhotosUI -/// 上传管理器,处理图片上传 -/// 图片上传管理器 -class ImageUploader: ObservableObject { - private let baseURL = "https://api.memorywake.com/api/v1/file/generate-upload-url" - - func uploadImage(_ image: UIImage, completion: @escaping (Result) -> Void) { - print("🔄 开始准备上传图片...") - // 1. 将图片转换为Data - guard let imageData = image.jpegData(compressionQuality: 0.7) else { - let error = NSError(domain: "ImageError", code: -1, userInfo: [NSLocalizedDescriptionKey: "图片数据转换失败"]) - print("❌ 错误:\(error.localizedDescription)") - completion(.failure(error)) - return - } - - // 2. 检查URL是否有效 - guard let url = URL(string: baseURL) else { - let error = NSError(domain: "URLError", code: -1, userInfo: [NSLocalizedDescriptionKey: "无效的URL"]) - print("❌ 错误:\(error.localizedDescription)") - completion(.failure(error)) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJqdGkiOjczNjM0ODY2MTE1MDc2NDY0NjQsImlkZW50aXR5IjoiNzM1MDQzOTY2MzExNjYxOTg4OCIsImV4cCI6MTc1NjE5NjgxNX0.hRC_So6LHuR6Gx-bDyO8aliVOd-Xumul8M7cydi2pTxHPweBx4421AfZ5BjGoEEwRZPIXJ5z7a1aDB7qvjpLCA", forHTTPHeaderField: "Authorization") - - // 3. 准备请求参数 - let fileName = "avatar_\(UUID().uuidString).jpg" - let parameters: [String: Any] = [ - "filename": fileName, - "content_type": "image/jpeg", - "file_size": imageData.count - ] - - do { - request.httpBody = try JSONSerialization.data(withJSONObject: parameters) - print("📤 准备上传请求,文件名: \(fileName), 大小: \(Double(imageData.count) / 1024.0) KB") - print("📡 请求参数: \(parameters)") - } catch { - print("❌ 序列化请求参数失败: \(error.localizedDescription)") - completion(.failure(error)) - return - } - - // 4. 打印调试信息 - print("🌐 请求URL: \(url.absoluteString)") - print("📋 请求头: \(request.allHTTPHeaderFields ?? [:])") - - // 5. 发起获取上传URL的请求 - print("🌐 正在获取上传链接...") - let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in - // 切换到主线程处理响应 - DispatchQueue.main.async { - if let error = error { - print("❌ 请求失败: \(error.localizedDescription)") - completion(.failure(error)) - return - } - - guard let httpResponse = response as? HTTPURLResponse else { - let error = NSError(domain: "NetworkError", code: -1, userInfo: [NSLocalizedDescriptionKey: "无效的服务器响应"]) - print("❌ 错误:\(error.localizedDescription)") - completion(.failure(error)) - return - } - - print("📊 响应状态码: \(httpResponse.statusCode)") - print("📦 响应头: \(httpResponse.allHeaderFields)") - - guard let data = data else { - let error = NSError(domain: "NetworkError", code: -1, userInfo: [NSLocalizedDescriptionKey: "没有接收到数据"]) - print("❌ 错误:\(error.localizedDescription)") - completion(.failure(error)) - return - } - - // 打印原始响应数据 - if let responseString = String(data: data, encoding: .utf8) { - print("📡 原始响应数据: \(responseString)") - } - - // 5. 解析获取上传URL的响应 - do { - guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw NSError(domain: "ParseError", code: -1, userInfo: [NSLocalizedDescriptionKey: "响应数据格式错误"]) - } - - print("📋 解析后的JSON: \(json)") - - // 根据实际API响应结构调整这里的键名 - if let uploadUrlString = json["url"] as? String, - let uploadUrl = URL(string: uploadUrlString) { - print("✅ 成功获取上传链接: \(uploadUrlString)") - self?.uploadImageData(imageData, to: uploadUrl, fileName: fileName, completion: completion) - } else { - throw NSError(domain: "APIError", code: -1, userInfo: [NSLocalizedDescriptionKey: "无法获取上传链接: \(json)"]) - } - } catch { - print("❌ 解析响应数据失败: \(error.localizedDescription)") - completion(.failure(error)) - } - } - } - - task.resume() - } - - private func uploadImageData(_ data: Data, to url: URL, fileName: String, completion: @escaping (Result) -> Void) { - var request = URLRequest(url: url) - request.httpMethod = "PUT" - request.setValue("image/jpeg", forHTTPHeaderField: "Content-Type") - request.httpBody = data - - print("🚀 开始上传图片数据...") - - let task = URLSession.shared.dataTask(with: request) { _, response, error in - DispatchQueue.main.async { - if let error = error { - print("❌ 上传失败: \(error.localizedDescription)") - completion(.failure(error)) - return - } - - // 处理上传响应 - if let httpResponse = response as? HTTPURLResponse { - print("📊 上传响应状态码: \(httpResponse.statusCode)") - - if (200...299).contains(httpResponse.statusCode) { - let fileUrl = "https://your-cdn-domain.com/\(fileName)" // 替换为实际的CDN域名 - print("✅ 上传成功!文件URL: \(fileUrl)") - completion(.success(fileUrl)) - } else { - let errorMessage = "上传失败,状态码: \(httpResponse.statusCode)" - print("❌ \(errorMessage)") - let error = NSError(domain: "UploadError", - code: httpResponse.statusCode, - userInfo: [NSLocalizedDescriptionKey: errorMessage]) - completion(.failure(error)) - } - } else { - let errorMessage = "无效的服务器响应" - print("❌ \(errorMessage)") - let error = NSError(domain: "UploadError", - code: -1, - userInfo: [NSLocalizedDescriptionKey: errorMessage]) - completion(.failure(error)) - } - } - } - - task.resume() - } -} - /// 照片选择器,封装了系统相册选择功能 +/// 使用UIViewControllerRepresentable包装PHPickerViewController,提供SwiftUI兼容的图片选择界面 struct PhotoPicker: UIViewControllerRepresentable { - // MARK: - 属性 + // MARK: - Properties - /// 绑定的已选图片数组 + /// 绑定的已选图片数组,用于存储用户选择的图片 @Binding var selectedImages: [UIImage] - /// 最多可选图片数量 + /// 最多可选图片数量,默认为1 let selectionLimit: Int - /// 图片过滤器,默认为图片类型 + /// 图片过滤器,默认为图片类型,可过滤特定类型的媒体 let filter: PHPickerFilter - /// 图片上传管理器 - private let uploader = ImageUploader() + /// 图片上传完成回调,返回上传结果或错误 + var onImageUploaded: ((Result) -> Void)? - // MARK: - 初始化方法 + // MARK: - Initialization /// 初始化照片选择器 - init(selectedImages: Binding<[UIImage]>, selectionLimit: Int = 1, filter: PHPickerFilter = .images) { + /// - Parameters: + /// - selectedImages: 绑定的图片数组,用于接收用户选择的图片 + /// - selectionLimit: 最多可选图片数量,默认为1 + /// - filter: 媒体类型过滤器,默认为图片 + /// - onImageUploaded: 图片上传完成后的回调闭包 + init( + selectedImages: Binding<[UIImage]>, + selectionLimit: Int = 1, + filter: PHPickerFilter = .images, + onImageUploaded: ((Result) -> Void)? = nil + ) { self._selectedImages = selectedImages self.selectionLimit = selectionLimit self.filter = filter + self.onImageUploaded = onImageUploaded } - // MARK: - UIViewControllerRepresentable 协议方法 + // MARK: - UIViewControllerRepresentable + /// 创建并返回配置好的PHPickerViewController func makeUIViewController(context: Context) -> PHPickerViewController { var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) configuration.filter = filter @@ -196,89 +52,131 @@ struct PhotoPicker: UIViewControllerRepresentable { return picker } + /// 更新视图控制器(空实现,因为不需要更新) func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + /// 创建协调器,用于处理PHPickerViewController的代理方法 func makeCoordinator() -> Coordinator { Coordinator(self) } - // MARK: - 协调器类 + // MARK: - Coordinator + /// 协调器类,处理PHPickerViewController的代理方法 class Coordinator: NSObject, PHPickerViewControllerDelegate { + /// 对父视图的弱引用 let parent: PhotoPicker + /// 图片上传器实例 + private let uploader = ImageUploaderGetID() + 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] = [:] + var loadedImages: [Int: UIImage] = [:] // 用于保持图片顺序的字典 + // 遍历所有选中的图片 for (index, result) in results.enumerated() { - group.enter() + group.enter() // 进入组 + // 检查是否可以加载图片 if result.itemProvider.canLoadObject(ofClass: UIImage.self) { - result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in + // 异步加载图片 + 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?.uploader.uploadImage(image) { result in + // 在主线程中调用上传完成回调 + DispatchQueue.main.async { + switch result { + case .success(let uploadResult): + print("✅ 上传成功!fileId: \(uploadResult.fileId)") + print("📂 文件信息:") + print(" - 文件名: \(uploadResult.fileName)") + print(" - 文件大小: \(uploadResult.fileSize) 字节") + print(" - 文件URL: \(uploadResult.fileUrl)") + + // 调用上传完成回调 + self?.parent.onImageUploaded?(.success(uploadResult)) + + case .failure(let error): + print("❌ 上传失败: \(error.localizedDescription)") + // 调用上传完成回调,传递错误 + self?.parent.onImageUploaded?(.failure(error)) + } + } } } - group.leave() + group.leave() // 离开组 } } else { - group.leave() + 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: - 头像上传组件 +// MARK: - AvatarUploader /// 头像上传视图,提供头像选择功能 +/// 封装了头像显示和选择逻辑,支持点击选择新头像 struct AvatarUploader: View { - // MARK: - 属性 + // MARK: - Properties - /// 当前选中的头像图片 + /// 绑定的当前选中头像图片 @Binding var selectedImage: UIImage? - /// 头像尺寸 + /// 头像显示尺寸 let size: CGFloat - // MARK: - 状态 + /// 上传完成回调,返回上传结果或错误 + var onUploadComplete: ((Result) -> Void)? - /// 是否显示图片选择器 + // MARK: - State + + /// 控制图片选择器的显示状态 @State private var isImagePickerPresented = false - // MARK: - 视图 + // MARK: - Body var body: some View { - Button(action: { - isImagePickerPresented = true - }) { + // 头像按钮,点击后显示图片选择器 + 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( @@ -293,10 +191,11 @@ 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 } }, @@ -304,7 +203,11 @@ struct AvatarUploader: View { selectedImage = images.first } ), - selectionLimit: 1 + selectionLimit: 1, // 限制只能选择一张图片 + onImageUploaded: { result in + // 图片上传完成后的处理 + onUploadComplete?(result) + } ) } } diff --git a/wake/View/Components/Upload/Compression.swift b/wake/View/Components/Upload/Compression.swift new file mode 100644 index 0000000..e69de29 diff --git a/wake/View/Components/Upload/ImageUploaderGetID.swift b/wake/View/Components/Upload/ImageUploaderGetID.swift new file mode 100644 index 0000000..4ea82f4 --- /dev/null +++ b/wake/View/Components/Upload/ImageUploaderGetID.swift @@ -0,0 +1,232 @@ +import SwiftUI +import PhotosUI + +/// 处理图片上传到远程服务器的类 +/// 支持上传图片并获取服务器返回的file_id +public class ImageUploaderGetID: ObservableObject { + // MARK: - 类型定义 + + /// 上传结果 + public struct UploadResult { + public let fileUrl: String + public let fileName: String + public let fileSize: Int + public let fileId: String + + public init(fileUrl: String, fileName: String, fileSize: Int, fileId: String) { + self.fileUrl = fileUrl + self.fileName = fileName + self.fileSize = fileSize + self.fileId = fileId + } + } + + /// 上传过程中可能发生的错误 + public enum UploadError: LocalizedError { + case invalidImageData + case invalidURL + case serverError(String) + case invalidResponse + case uploadFailed(Error?) + case invalidFileId + + public var errorDescription: String? { + switch self { + case .invalidImageData: + return "无效的图片数据" + case .invalidURL: + return "无效的URL" + case .serverError(let message): + return "服务器错误: \(message)" + case .invalidResponse: + return "无效的服务器响应" + case .uploadFailed(let error): + return "上传失败: \(error?.localizedDescription ?? "未知错误")" + case .invalidFileId: + return "无效的文件ID" + } + } + } + + // MARK: - 属性 + + private let session: URLSession + private let apiConfig: APIConfig.Type + + // MARK: - 初始化方法 + + /// 初始化方法 + /// - Parameters: + /// - session: 可选的URLSession,用于测试依赖注入 + /// - apiConfig: 可选的API配置,用于测试依赖注入 + public init(session: URLSession = .shared, apiConfig: APIConfig.Type = APIConfig.self) { + self.session = session + self.apiConfig = apiConfig + } + + // MARK: - 公开方法 + + /// 上传图片到服务器 + /// - Parameters: + /// - image: 要上传的图片 + /// - completion: 完成回调,返回Result类型的结果 + public func uploadImage(_ image: UIImage, completion: @escaping (Result) -> Void) { + print("🔄 开始准备上传图片...") + + // 1. 转换图片为Data + guard let imageData = image.jpegData(compressionQuality: 0.7) else { + let error = UploadError.invalidImageData + print("❌ 错误:\(error.localizedDescription)") + completion(.failure(error)) + return + } + + // 2. 获取上传URL + 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) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + // MARK: - 私有方法 + + /// 获取上传URL + private func getUploadURL(for imageData: Data, completion: @escaping (Result<(fileId: String, uploadURL: URL), Error>) -> Void) { + let fileName = "avatar_\(UUID().uuidString).jpg" + let parameters: [String: Any] = [ + "filename": fileName, + "content_type": "image/jpeg", + "file_size": imageData.count + ] + + let urlString = "\(apiConfig.baseURL)/file/generate-upload-url" + guard let url = URL(string: urlString) else { + completion(.failure(UploadError.invalidURL)) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = apiConfig.authHeaders + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: parameters) + print("📤 准备上传请求,文件名: \(fileName), 大小: \(Double(imageData.count) / 1024.0) KB") + } catch { + print("❌ 序列化请求参数失败: \(error.localizedDescription)") + completion(.failure(error)) + return + } + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(UploadError.uploadFailed(error))) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + completion(.failure(UploadError.invalidResponse)) + return + } + + guard let data = data else { + completion(.failure(UploadError.invalidResponse)) + return + } + + // 打印调试信息 + if let responseString = String(data: data, encoding: .utf8) { + print("📥 获取上传URL响应: \(responseString)") + } + + do { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataDict = json["data"] as? [String: Any], + let fileId = dataDict["file_id"] as? String, + let uploadURLString = dataDict["upload_url"] as? String, + let uploadURL = URL(string: uploadURLString) else { + throw UploadError.invalidResponse + } + + completion(.success((fileId: fileId, uploadURL: uploadURL))) + } catch { + completion(.failure(UploadError.invalidResponse)) + } + } + + task.resume() + } + + /// 确认上传 + private func confirmUpload(fileId: String, fileName: String, fileSize: Int, completion: @escaping (Result) -> Void) { + let urlString = "\(apiConfig.baseURL)/file/confirm-upload" + guard let url = URL(string: urlString) else { + completion(.failure(UploadError.invalidURL)) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = apiConfig.authHeaders + + let requestBody: [String: Any] = ["file_id": fileId] + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: requestBody) + } catch { + completion(.failure(error)) + return + } + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + completion(.failure(UploadError.uploadFailed(error))) + return + } + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + completion(.failure(UploadError.serverError("确认上传失败,状态码: \((response as? HTTPURLResponse)?.statusCode ?? -1)"))) + return + } + + // 创建上传结果 + let uploadResult = UploadResult( + fileUrl: "\(self.apiConfig.baseURL)/files/\(fileId)", + fileName: fileName, + fileSize: fileSize, + fileId: fileId + ) + + print("✅ 图片上传并确认成功,fileId: \(fileId)") + completion(.success(uploadResult)) + } + + task.resume() + } +} + +// MARK: - 响应模型 + +struct UploadURLResponse: Codable { + let code: Int + let message: String + let data: UploadData + + struct UploadData: Codable { + let fileId: String + let uploadUrl: String + + enum CodingKeys: String, CodingKey { + case fileId = "file_id" + case uploadUrl = "upload_url" + } + } +}