From 03d8288833a2f5e9b3b372a0cab81017dabfd6a7 Mon Sep 17 00:00:00 2001 From: jinyaqiu Date: Mon, 25 Aug 2025 18:58:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9A=82=E6=8F=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wake/Utils/NetworkService.swift | 87 +- .../Upload/ImageUploadService.swift | 20 +- .../Upload/ImageUploaderGetID.swift | 681 +++++------ wake/View/Upload/MediaUploadView.swift | 1023 ++++++++++------- wake/View/Welcome/SplashView.swift | 2 +- 5 files changed, 1006 insertions(+), 807 deletions(-) diff --git a/wake/Utils/NetworkService.swift b/wake/Utils/NetworkService.swift index ac67c70..a26f7c3 100644 --- a/wake/Utils/NetworkService.swift +++ b/wake/Utils/NetworkService.swift @@ -18,7 +18,84 @@ private struct RequestIdentifier { } } -enum NetworkError: Error { +public protocol NetworkServiceProtocol { + func postWithToken( + path: String, + parameters: [String: Any], + completion: @escaping (Result) -> Void + ) + + @discardableResult + func upload( + request: URLRequest, + fileData: Data, + onProgress: @escaping (Double) -> Void, + completion: @escaping (Result<(Data?, URLResponse), Error>) -> Void + ) -> URLSessionUploadTask? +} + +extension NetworkService: NetworkServiceProtocol { + public func postWithToken( + path: String, + parameters: [String: Any], + completion: @escaping (Result) -> Void + ) { + var headers = [String: String]() + if let token = KeychainHelper.getAccessToken() { + headers["Authorization"] = "Bearer \(token)" + } + + post(path: path, parameters: parameters, headers: headers, completion: completion) + } + + @discardableResult + public func upload( + request: URLRequest, + fileData: Data, + onProgress: @escaping (Double) -> Void, + completion: @escaping (Result<(Data?, URLResponse), Error>) -> Void + ) -> URLSessionUploadTask? { + var request = request + + // Set content length header if not already set + if request.value(forHTTPHeaderField: "Content-Length") == nil { + request.setValue("\(fileData.count)", forHTTPHeaderField: "Content-Length") + } + + var progressObserver: NSKeyValueObservation? + + let task = URLSession.shared.uploadTask(with: request, from: fileData) { [weak self] data, response, error in + // Invalidate the progress observer when the task completes + progressObserver?.invalidate() + + if let error = error { + completion(.failure(error)) + return + } + + guard let response = response else { + completion(.failure(NetworkError.invalidURL)) + return + } + + completion(.success((data, response))) + } + + // Add progress tracking if available + if #available(iOS 11.0, *) { + progressObserver = task.progress.observe(\.fractionCompleted) { progressValue, _ in + DispatchQueue.main.async { + onProgress(progressValue.fractionCompleted) + } + } + } + + task.resume() + return task + } +} + +public enum NetworkError: Error { case invalidURL case noData case decodingError(Error) @@ -28,14 +105,14 @@ enum NetworkError: Error { case networkError(Error) case unknownError(Error) - var localizedDescription: String { + public var localizedDescription: String { switch self { case .invalidURL: return "无效的URL" case .noData: - return "没有收到数据" + return "没有接收到数据" case .decodingError(let error): - return "数据解析错误: \(error.localizedDescription)" + return "解码错误: \(error.localizedDescription)" case .serverError(let message): return "服务器错误: \(message)" case .unauthorized: @@ -43,7 +120,7 @@ enum NetworkError: Error { case .other(let error): return error.localizedDescription case .networkError(let error): - return "网络请求错误: \(error.localizedDescription)" + return "网络错误: \(error.localizedDescription)" case .unknownError(let error): return "未知错误: \(error.localizedDescription)" } diff --git a/wake/View/Components/Upload/ImageUploadService.swift b/wake/View/Components/Upload/ImageUploadService.swift index b2f0ab0..e8537bb 100644 --- a/wake/View/Components/Upload/ImageUploadService.swift +++ b/wake/View/Components/Upload/ImageUploadService.swift @@ -28,7 +28,7 @@ public class ImageUploadService { public func uploadImage( _ image: UIImage, progress progressHandler: @escaping (ImageUploadService.UploadProgress) -> Void, - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) { uploader.uploadImage( image, @@ -61,10 +61,10 @@ public class ImageUploadService { _ image: UIImage, compressionQuality: CGFloat = 0.5, progress progressHandler: @escaping (ImageUploadService.UploadProgress) -> Void, - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) { guard let compressedImage = image.jpegData(compressionQuality: compressionQuality).flatMap(UIImage.init(data:)) else { - completion(.failure(NSError(domain: "com.wake.upload", code: -1, userInfo: [NSLocalizedDescriptionKey: "图片压缩失败"]))) + completion(.failure(ImageUploaderGetID.UploadError.invalidImageData)) return } @@ -85,7 +85,7 @@ public class ImageUploadService { _ image: UIImage, compressionQuality: CGFloat = 0.5, progress progressHandler: @escaping (ImageUploadService.UploadProgress) -> Void, - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) { // 上传原图 uploadImage(image, progress: { progress in @@ -145,7 +145,7 @@ public class ImageUploadService { public func uploadMedia( _ media: MediaType, progress: @escaping (UploadProgress) -> Void, - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) { switch media { case .image(let image): @@ -217,14 +217,14 @@ public class ImageUploadService { } ) } else { - let error = NSError(domain: "ImageUploadService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to compress thumbnail"]) + let error = ImageUploaderGetID.UploadError.invalidImageData print("❌ 视频缩略图压缩失败") completion(.failure(error)) } case .failure(let error): print("❌ 视频缩略图提取失败: \(error.localizedDescription)") - completion(.failure(error)) + completion(.failure(ImageUploaderGetID.UploadError.uploadFailed(error))) } } @@ -243,13 +243,13 @@ public class ImageUploadService { existingThumbnail: UIImage?, compressionQuality: CGFloat, progress progressHandler: @escaping (UploadProgress) -> Void, - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) { // 1. 提取视频缩略图 func processThumbnail(_ thumbnail: UIImage) { // 2. 压缩缩略图 guard let compressedThumbnail = thumbnail.jpegData(compressionQuality: compressionQuality).flatMap(UIImage.init(data:)) else { - let error = NSError(domain: "com.wake.upload", code: -1, userInfo: [NSLocalizedDescriptionKey: "缩略图压缩失败"]) + let error = ImageUploaderGetID.UploadError.invalidImageData completion(.failure(error)) return } @@ -325,7 +325,7 @@ public class ImageUploadService { case .success(let thumbnail): processThumbnail(thumbnail) case .failure(let error): - completion(.failure(error)) + completion(.failure(ImageUploaderGetID.UploadError.uploadFailed(error))) } } } diff --git a/wake/View/Components/Upload/ImageUploaderGetID.swift b/wake/View/Components/Upload/ImageUploaderGetID.swift index 21c4c7a..0dd43b6 100644 --- a/wake/View/Components/Upload/ImageUploaderGetID.swift +++ b/wake/View/Components/Upload/ImageUploaderGetID.swift @@ -1,10 +1,55 @@ import SwiftUI import PhotosUI +import CommonCrypto /// 处理图片上传到远程服务器的类 /// 支持上传图片并获取服务器返回的file_id public class ImageUploaderGetID: ObservableObject { // MARK: - 类型定义 + private let session: URLSession + private let networkService: NetworkServiceProtocol + private let networkHandler: (_ path: String, _ parameters: [String: Any], _ completion: @escaping (Result) -> Void) -> Void + + public init(session: URLSession = .shared, networkService: NetworkServiceProtocol? = nil) { + self.session = session + let service = networkService ?? NetworkService.shared + self.networkService = service + self.networkHandler = { path, parameters, completion in + service.postWithToken( + path: path, + parameters: parameters, + completion: { (result: Result) in + switch result { + case .success(let response): + // Convert the response back to Data for the completion handler + do { + let data = try JSONEncoder().encode(response) + completion(.success(data)) + } catch { + completion(.failure(.invalidResponseData)) + } + case .failure(let error): + completion(.failure(.networkError(error))) + } + } + ) + } + } + + /// 测试专用的初始化方法 + /// - Parameters: + /// - session: 用于测试的URLSession + /// - networkService: 用于测试的NetworkService + /// - networkHandler: 用于测试的网络请求处理器 + internal init( + session: URLSession, + networkService: NetworkServiceProtocol? = nil, + networkHandler: @escaping (String, [String: Any], @escaping (Result) -> Void) -> Void + ) { + self.session = session + self.networkService = networkService ?? NetworkService.shared + self.networkHandler = networkHandler + } /// 上传结果 public struct UploadResult: Codable { @@ -30,6 +75,9 @@ public class ImageUploaderGetID: ObservableObject { case uploadFailed(Error?) case invalidFileId case invalidResponseData + case networkError(Error) + case decodingError(Error) + case unauthorized public var errorDescription: String? { switch self { @@ -47,41 +95,26 @@ public class ImageUploaderGetID: ObservableObject { return "无效的文件ID" case .invalidResponseData: return "无效的响应数据" + case .networkError(let error): + return "网络错误: \(error.localizedDescription)" + case .decodingError(let error): + return "解码错误: \(error.localizedDescription)" + case .unauthorized: + return "认证失败,需要重新登录" } } } - // 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: 要上传的图片 - /// - progress: 上传进度回调 (0.0 到 1.0) - /// - completion: 完成回调 public func uploadImage( _ image: UIImage, progress: @escaping (Double) -> Void, - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) { print("🔄 开始准备上传图片...") - // 1. 转换图片为Data guard let imageData = image.jpegData(compressionQuality: 0.7) else { let error = UploadError.invalidImageData print("❌ 错误:\(error.localizedDescription)") @@ -89,14 +122,21 @@ public class ImageUploaderGetID: ObservableObject { return } - // 2. 获取上传URL - getUploadURL(for: imageData) { [weak self] result in + requestUploadURL(fileName: "image_\(UUID().uuidString).jpg", fileData: imageData) { [weak self] result in + guard let self = self else { return } + switch result { - case .success((let fileId, let uploadURL)): + case .success(let response): print("📤 获取到上传URL,开始上传文件...") - // 3. 上传文件 - _ = self?.uploadFile( + guard let uploadURL = URL(string: response.data.uploadUrl) else { + let error = UploadError.invalidURL + print("❌ [ImageUploader] 上传URL格式无效: \(response.data.uploadUrl)") + completion(.failure(error)) + return + } + + self.uploadFile( fileData: imageData, to: uploadURL, mimeType: "image/jpeg", @@ -104,87 +144,73 @@ public class ImageUploaderGetID: ObservableObject { print("📊 上传进度: \(Int(uploadProgress * 100))%") progress(uploadProgress) }, - completion: { uploadResult in + completion: { [weak self] uploadResult in switch uploadResult { case .success: - // 4. 确认上传 self?.confirmUpload( - fileId: fileId, - fileName: "avatar_\(UUID().uuidString).jpg", + fileId: response.data.fileId, + fileName: "image_\(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)) } } } - // MARK: - Video Upload - /// 上传视频文件到服务器 /// - Parameters: - /// - videoURL: 要上传的视频文件URL + /// - videoURL: 视频文件的本地URL /// - progress: 上传进度回调 (0.0 到 1.0) - /// - completion: 完成回调 + /// - completion: 完成回调,返回上传结果或错误 public func uploadVideo( _ videoURL: URL, progress: @escaping (Double) -> Void, - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) { - print("🔄 开始准备上传视频...") + print("🎥 开始准备上传视频...") - // 1. 读取视频文件数据 do { let videoData = try Data(contentsOf: videoURL) - let fileExtension = videoURL.pathExtension.lowercased() - let mimeType: String + let fileName = "video_\(UUID().uuidString).mp4" - // 根据文件扩展名设置MIME类型 - switch fileExtension { - case "mp4": - mimeType = "video/mp4" - case "mov": - mimeType = "video/quicktime" - case "m4v": - mimeType = "video/x-m4v" - case "avi": - mimeType = "video/x-msvideo" - default: - mimeType = "video/mp4" // 默认使用mp4 - } - - // 2. 获取上传URL - getUploadURL( - for: videoData, - mimeType: mimeType, - originalFilename: videoURL.lastPathComponent + requestUploadURL( + fileName: fileName, + fileData: videoData ) { [weak self] result in + guard let self = self else { return } + switch result { - case .success((let fileId, let uploadURL)): + case .success(let response): print("📤 获取到视频上传URL,开始上传文件...") - // 3. 上传文件 - _ = self?.uploadFile( + guard let uploadURL = URL(string: response.data.uploadUrl) else { + let error = UploadError.invalidURL + print("❌ [ImageUploader] 视频上传URL格式无效: \(response.data.uploadUrl)") + completion(.failure(error)) + return + } + + self.uploadFile( fileData: videoData, to: uploadURL, - mimeType: mimeType, + mimeType: "video/mp4", onProgress: progress, - completion: { result in - switch result { + completion: { [weak self] uploadResult in + guard let self = self else { return } + + switch uploadResult { case .success: - // 4. 确认上传完成 - self?.confirmUpload( - fileId: fileId, - fileName: videoURL.lastPathComponent, + self.confirmUpload( + fileId: response.data.fileId, + fileName: fileName, fileSize: videoData.count, completion: completion ) @@ -200,221 +226,55 @@ public class ImageUploaderGetID: ObservableObject { } } catch { print("❌ 读取视频文件失败: \(error.localizedDescription)") - completion(.failure(error)) + completion(.failure(.uploadFailed(error))) } } // MARK: - 私有方法 - /// 获取上传URL - private func getUploadURL( - for imageData: Data, - completion: @escaping (Result<(fileId: String, uploadURL: URL), Error>) -> Void + /// 发起网络请求 + private func request( + path: String, + parameters: [String: Any], + completion: @escaping (Result) -> Void + ) { + networkHandler(path, parameters) { (result: Result) in + switch result { + case .success(let data): + do { + let decoded = try JSONDecoder().decode(T.self, from: data) + completion(.success(decoded)) + } catch { + print("❌ [ImageUploader] 解析响应失败: \(error)") + completion(.failure(.invalidResponseData)) + } + case .failure(let error): + print("❌ [ImageUploader] 网络请求失败: \(error)") + completion(.failure(error)) + } + } + } + + /// 请求上传URL + private func requestUploadURL( + fileName: String, + fileData: Data, + completion: @escaping (Result) -> 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() - } - - /// 获取上传URL - private func getUploadURL( - for fileData: Data, - mimeType: String, - originalFilename: String? = nil, - completion: @escaping (Result<(fileId: String, uploadURL: URL), Error>) -> Void - ) { - let fileName = originalFilename ?? "file_\(UUID().uuidString)" - let parameters: [String: Any] = [ - "filename": fileName, - "content_type": mimeType, "file_size": fileData.count ] - let urlString = "\(apiConfig.baseURL)/file/generate-upload-url" - guard let url = URL(string: urlString) else { - completion(.failure(UploadError.invalidURL)) - return - } + print(""" + 📝 [ImageUploader] 开始请求上传URL + 文件名: \(fileName) + 📏 文件大小: \(fileData.count) 字节 + 📋 参数: \(parameters) + """) - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.allHTTPHeaderFields = apiConfig.authHeaders - - do { - request.httpBody = try JSONSerialization.data(withJSONObject: parameters) - print("📤 准备上传请求,文件名: \(fileName), 大小: \(Double(fileData.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 { - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - guard let code = json?["code"] as? Int, code == 0, - 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 endpoint = "\(apiConfig.baseURL)/file/confirm-upload" - guard let url = URL(string: endpoint) else { - completion(.failure(UploadError.invalidURL)) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.allHTTPHeaderFields = apiConfig.authHeaders - - let body: [String: Any] = [ - "file_id": fileId, - "file_name": fileName, - "file_size": fileSize - ] - - do { - request.httpBody = try JSONSerialization.data(withJSONObject: body) - print("📤 确认上传请求,fileId: \(fileId), 文件名: \(fileName)") - } catch { - print("❌ 序列化确认上传参数失败: \(error.localizedDescription)") - completion(.failure(error)) - return - } - - let task = session.dataTask(with: request) { data, response, error in - if let error = error { - print("❌ 确认上传请求失败: \(error.localizedDescription)") - completion(.failure(UploadError.uploadFailed(error))) - return - } - - guard let httpResponse = response as? HTTPURLResponse else { - print("❌ 无效的服务器响应") - completion(.failure(UploadError.invalidResponse)) - return - } - - guard (200...299).contains(httpResponse.statusCode) else { - let statusCode = httpResponse.statusCode - let errorMessage = "确认上传失败,状态码: \(statusCode)" - print("❌ \(errorMessage)") - completion(.failure(UploadError.serverError(errorMessage))) - return - } - - // 创建上传结果 - let uploadResult = UploadResult( - fileUrl: "\(self.apiConfig.baseURL)/files/\(fileId)", - fileName: fileName, - fileSize: fileSize, - fileId: fileId - ) - - print("✅ 图片上传并确认成功,fileId: \(fileId)") - completion(.success(uploadResult)) - } - - task.resume() + request(path: "/file/generate-upload-url", parameters: parameters, completion: completion) } /// 上传文件到指定URL @@ -423,96 +283,203 @@ public class ImageUploaderGetID: ObservableObject { to uploadURL: URL, mimeType: String = "application/octet-stream", onProgress: @escaping (Double) -> Void, - completion: @escaping (Result) -> Void - ) -> URLSessionUploadTask { + completion: @escaping (Result) -> Void + ) -> URLSessionUploadTask? { + print(""" + ⬆️ [ImageUploader] 开始上传文件 + 🔗 目标URL: \(uploadURL.absoluteString) + 📏 文件大小: \(fileData.count) 字节 + 📋 MIME类型: \(mimeType) + """) + + // 创建请求 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 else { - completion(.failure(UploadError.invalidResponse)) - return - } - - guard (200...299).contains(httpResponse.statusCode) else { - let statusCode = httpResponse.statusCode - completion(.failure(UploadError.serverError("上传失败,状态码: \(statusCode)"))) - return - } - - completion(.success(())) - } + // 设置请求头 + var headers: [String: String] = [ + "Content-Type": mimeType, + "Content-Length": String(fileData.count) + ] - // 添加进度观察 - 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() - } + // 添加认证头 + if let token = KeychainHelper.getAccessToken() { + headers["Authorization"] = "Bearer \(token)" + print("🔑 [ImageUploader] 添加认证头,Token: \(token.prefix(10))...") } else { - 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() - } + print("⚠️ [ImageUploader] 未找到认证Token") } - task.resume() + // 设置所有请求头 + headers.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + + // 创建上传任务 + let task = networkService.upload( + request: request, + fileData: fileData, + onProgress: onProgress, + completion: { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let data, let response): + guard let httpResponse = response as? HTTPURLResponse else { + print("❌ [ImageUploader] 无效的响应") + completion(.failure(.invalidResponse)) + return + } + + let statusCode = httpResponse.statusCode + let responseBody = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" + + print(""" + 📡 [ImageUploader] 服务器响应 + 🔢 状态码: \(statusCode) + 🔗 请求URL: \(uploadURL.absoluteString) + 📦 响应: \(responseBody) + """) + + switch statusCode { + case 200...299: + print("✅ [ImageUploader] 文件上传成功") + completion(.success(())) + + case 401: + print("🔑 [ImageUploader] 认证失败,需要重新登录") + completion(.failure(.unauthorized)) + + case 403: + let errorMessage = "访问被拒绝 (403) - URL: \(uploadURL.absoluteString)" + print("❌ [ImageUploader] \(errorMessage)") + completion(.failure(.serverError(errorMessage))) + + default: + let errorMessage = "服务器返回错误: \(statusCode)" + print("❌ [ImageUploader] \(errorMessage)") + completion(.failure(.serverError(errorMessage))) + } + + case .failure(let error): + print("❌ [ImageUploader] 上传失败: \(error.localizedDescription)") + completion(.failure(.networkError(error))) + } + } + ) + + // 开始上传 + task?.resume() return task } - // MARK: - 文件上传状态 - - /// 文件上传状态 - public struct FileStatus { - public let file: Data - public var status: UploadStatus - public var progress: Double + /// 确认上传完成 + private func confirmUpload( + fileId: String, + fileName: String, + fileSize: Int, + completion: @escaping (Result) -> Void + ) { + let parameters: [String: Any] = [ + "file_id": fileId, + "file_name": fileName, + "file_size": fileSize + ] - public enum UploadStatus { - case pending - case uploading - case completed - case failed(Error) + print(""" + 📨 [ImageUploader] 开始确认上传完成 + 📁 文件ID: \(fileId) + 📝 文件名: \(fileName) + 📏 文件大小: \(fileSize) 字节 + 📋 参数: \(parameters) + """) + + struct ConfirmUploadResponse: Codable { + let code: Int + let message: String + let data: [String: String]? } - public init(file: Data, status: UploadStatus = .pending, progress: Double = 0) { - self.file = file - self.status = status - self.progress = progress + request(path: "/file/confirm-upload", parameters: parameters) { (result: Result) in + switch result { + case .success(_): + let uploadResult = UploadResult( + fileUrl: "\(APIConfig.baseURL)/files/\(fileId)", + fileName: fileName, + fileSize: fileSize, + fileId: fileId + ) + print(""" + ✅ [ImageUploader] 文件上传确认成功 + 📁 文件ID: \(fileId) + 🔗 文件URL: \(uploadResult.fileUrl) + 📝 文件名: \(fileName) + 📏 文件大小: \(fileSize) 字节 + """) + completion(.success(uploadResult)) + + case .failure(let error): + print("❌ [ImageUploader] 文件上传确认失败: \(error.localizedDescription)") + completion(.failure(error)) + } + } + } + + // MARK: - 辅助方法 + + private func calculateSpeed(bytes: Int, seconds: TimeInterval) -> String { + guard seconds > 0 else { return "0 KB/s" } + let bytesPerSecond = Double(bytes) / seconds + if bytesPerSecond >= 1024 * 1024 { + return String(format: "%.1f MB/s", bytesPerSecond / (1024 * 1024)) + } else { + return String(format: "%.1f KB/s", bytesPerSecond / 1024) } } } +// MARK: - Data Extension for MD5 + +extension Data { + func md5Base64EncodedString() -> String? { + #if canImport(CommonCrypto) + var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH)) + _ = self.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in + if let baseAddress = ptr.baseAddress, ptr.count > 0 { + CC_MD5(baseAddress, CC_LONG(self.count), &digest) + } + } + return Data(digest).base64EncodedString() + #else + return nil + #endif + } +} + +// MARK: - 响应模型 + +private struct UploadURLResponse: Codable { + let code: Int + let message: String? + let data: UploadData + + struct UploadData: Codable { + let fileId: String + let filePath: String + let uploadUrl: String + let expiresIn: Int + + enum CodingKeys: String, CodingKey { + case fileId = "file_id" + case filePath = "file_path" + case uploadUrl = "upload_url" + case expiresIn = "expires_in" + } + } +} + +private struct EmptyResponse: Codable {} + // MARK: - URLSessionTask 扩展 private class TaskObserver: NSObject { @@ -541,7 +508,6 @@ private class TaskObserver: NSObject { return } - // 调用所有完成处理器 DispatchQueue.main.async { [weak self] in self?.handlers.forEach { $0() } self?.cleanup() @@ -580,7 +546,6 @@ private extension URLSessionTask { taskObserver = observer } } else { - // iOS 11 以下版本使用通知 let name = NSNotification.Name("TaskCompleted\(self.taskIdentifier)") NotificationCenter.default.addObserver( forName: name, @@ -592,21 +557,3 @@ private extension URLSessionTask { } } } - -// 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" - } - } -} diff --git a/wake/View/Upload/MediaUploadView.swift b/wake/View/Upload/MediaUploadView.swift index 7f44090..99f787e 100644 --- a/wake/View/Upload/MediaUploadView.swift +++ b/wake/View/Upload/MediaUploadView.swift @@ -1,67 +1,31 @@ import SwiftUI -// MARK: - Main View +/// 主上传视图 +/// 提供媒体选择、预览和上传功能 @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 = [] + // MARK: - 视图主体 + var body: some View { - VStack() { - // 固定的顶部导航栏 - 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) - .padding(.bottom, -20) - .zIndex(1) // 确保导航栏在最上层 - - HStack() { - Text("The upload process will take approximately 2 minutes. Thank you for your patience. ") - .font(Typography.font(for: .caption)) - .foregroundColor(.black) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(6) - .background( - LinearGradient( - gradient: Gradient(colors: [ - Color(red: 1.0, green: 0.97, blue: 0.87), - .white, - Color(red: 1.0, green: 0.97, blue: 0.84) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - } - .padding() - - Spacer() - .frame(height: 20) - + VStack(spacing: 0) { + // 顶部导航栏 + topNavigationBar + + // 上传提示信息 + uploadHintView + + // 主上传区域 MainUploadArea( uploadManager: uploadManager, showMediaPicker: $showMediaPicker, @@ -70,114 +34,211 @@ struct MediaUploadView: View { ) .padding() .id("mainUploadArea\(uploadManager.selectedMedia.count)") - + Spacer() - // Navigation button - Button(action: { - // Router.shared.navigate(to: .avatarBox) - }) { - 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()) - - Spacer() + // 继续按钮 + continueButton + .padding(.bottom, 24) } .background(Color.themeTextWhiteSecondary) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .sheet(isPresented: $showMediaPicker) { - MediaPicker( - selectedMedia: $uploadManager.selectedMedia, - imageSelectionLimit: 20, - videoSelectionLimit: 5, - onDismiss: { - // 只处理界面相关的逻辑 - showMediaPicker = false - }, - onUploadProgress: { index, progress in - print("File \(index) upload progress: \(progress * 100)%") - } - ) + // 媒体选择器 + mediaPickerView } - .onChange(of: uploadManager.selectedMedia) { newMedia in - print("onChange1111111", uploadManager.selectedMedia) - // 在这里处理媒体变化 - if !newMedia.isEmpty { - // 当有新的媒体时开始上传 - uploadManager.startUpload() + .onChange(of: uploadManager.selectedMedia) { [oldMedia = uploadManager.selectedMedia] newMedia in + handleMediaChange(newMedia, oldMedia: oldMedia) + } + } + + // 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) // 确保导航栏显示在最上层 } - // MARK: - Private Methods + /// 上传提示视图 + private var uploadHintView: some View { + HStack { + Text("The upload process will take approximately 2 minutes. Thank you for your patience.") + .font(.caption) + .foregroundColor(.black) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background( + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 1.0, green: 0.97, blue: 0.87), + .white, + Color(red: 1.0, green: 0.97, blue: 0.84) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .cornerRadius(8) + ) + .padding(.horizontal) + } + .padding(.vertical, 8) + } + /// 继续按钮 + private var continueButton: some View { + Button(action: { + // 处理继续操作 + // Router.shared.navigate(to: .avatarBox) + }) { + 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: $uploadManager.selectedMedia, + imageSelectionLimit: 20, + videoSelectionLimit: 5, + onDismiss: handleMediaPickerDismiss, + onUploadProgress: { index, progress in + print("文件 \(index) 上传进度: \(progress * 100)%") + } + ) + } + + // MARK: - 私有方法 + + /// 处理媒体选择器关闭事件 private func handleMediaPickerDismiss() { - self.uploadManager.startUpload() - print("handleMediaPickerDismiss1111111", uploadManager.selectedMedia) - - // showMediaPicker = false + showMediaPicker = false + print("媒体选择器关闭 - 开始处理") - // // 确保选择器完全关闭后再开始上传 - // DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - // if !self.uploadManager.selectedMedia.isEmpty { - // self.selectedMedia = self.uploadManager.selectedMedia.first - // self.selectedIndices = [0] - // // 在选择器完全关闭后再开始上传 - // self.uploadManager.startUpload() - // } - // } - } - - private func handleMediaChange(_ newMedia: [MediaType]) { - if newMedia.isEmpty { - selectedMedia = nil - selectedIndices = [] - return - } - - // 只在需要时更新 - if selectedIndices.isEmpty || selectedIndices.first! >= newMedia.count { - selectedMedia = newMedia.first - selectedIndices = [0] - } else if let selectedIndex = selectedIndices.first, selectedIndex < newMedia.count { - selectedMedia = newMedia[selectedIndex] - } - - // 当有新媒体时自动开始上传 - if !newMedia.isEmpty && !isUploading() { + // 如果有选中的媒体,开始上传 + if !uploadManager.selectedMedia.isEmpty { uploadManager.startUpload() } } - - // 添加辅助方法检查上传状态 + + /// 处理媒体变化 + /// - 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 { + let startTime = Date() + + // 找出新增的媒体(在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 { + // 如果当前没有选中的媒体,则选中第一个新增的媒体 + if self.selectedIndices.isEmpty && !newItems.isEmpty { + self.selectedIndices = [self.uploadManager.selectedMedia.count] // 选择第一个新增项的索引 + self.selectedMedia = newItems.first + } + + // 开始上传新添加的媒体 + self.uploadManager.startUpload() + print("媒体添加完成,总数量: \(self.uploadManager.selectedMedia.count)") + } + } else if newMedia.isEmpty { + // 清空选择 + DispatchQueue.main.async { + self.selectedIndices = [] + self.selectedMedia = nil + print("媒体已清空,重置选择状态") + } + } + + print("媒体变化处理完成,总耗时: \(String(format: "%.3f", Date().timeIntervalSince(startTime)))s") + } + } + + /// 检查是否有正在上传的文件 + /// - Returns: 是否正在上传 private func isUploading() -> Bool { return uploadManager.uploadStatus.values.contains { status in - if case .uploading = status { - return true - } + if case .uploading = status { return true } return false } } } -// MARK: - Main Upload Area +// MARK: - 主上传区域 +/// 主上传区域视图 +/// 显示上传提示、媒体预览和添加更多按钮 struct MainUploadArea: View { + // MARK: - 属性 + + /// 上传管理器 @ObservedObject var uploadManager: MediaUploadManager + /// 控制媒体选择器的显示/隐藏 @Binding var showMediaPicker: Bool + /// 当前选中的媒体 @Binding var selectedMedia: MediaType? + /// 当前选中的媒体索引 @Binding var selectedIndices: Set + // MARK: - 视图主体 + var body: some View { VStack(spacing: 16) { + // 标题 Text("Click to upload 20 images and 5 videos to generate your next blind box.") .font(Typography.font(for: .title2, family: .quicksandBold)) .fontWeight(.bold) @@ -185,325 +246,439 @@ struct MainUploadArea: View { .multilineTextAlignment(.center) .padding(.horizontal) - if !uploadManager.selectedMedia.isEmpty { - // 显示媒体预览网格 - ScrollView { - LazyVGrid(columns: [ - GridItem(.flexible(), spacing: 16), - GridItem(.flexible(), spacing: 16) - ], spacing: 16) { - ForEach(0.. some View { + VStack(spacing: 4) { + // 媒体预览 + MediaPreview(media: media, uploadManager: uploadManager) + .frame(width: 80, height: 80) + .cornerRadius(8) + .shadow(radius: 1) + .onTapGesture { + // 更新选中的媒体 + selectedIndices = [index] + selectedMedia = media + } + + // 上传状态指示器 + uploadStatusView(for: index) + } + .padding(4) + } + + /// 上传状态视图 + /// - Parameter index: 媒体索引 + /// - Returns: 状态视图 + @ViewBuilder + private func uploadStatusView(for index: Int) -> some View { + if let status = uploadManager.uploadStatus["\(index)"] { + switch status { + case .uploading(let progress): + // 上传中,显示进度条 + VStack(alignment: .center, spacing: 2) { + Text("\(Int(progress * 100))%") + .font(.caption2) + .foregroundColor(.gray) + ProgressView(value: progress, total: 1.0) + .progressViewStyle(LinearProgressViewStyle()) + .frame(height: 3) + .tint(Color.themePrimary) + } + .frame(width: 60) + + case .completed: + // 上传完成,显示完成图标 + Image(systemName: "checkmark.circle.fill") + .font(.caption) + .foregroundColor(.green) + + case .failed: + // 上传失败,显示错误图标 + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.red) + + default: + EmptyView() + } + } + } + + /// 添加更多按钮 + private var addMoreButton: some View { + Button(action: { showMediaPicker = true }) { + VStack(spacing: 8) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 30)) + .foregroundColor(.themePrimary) + + Text("Add More") + .font(.subheadline) + .foregroundColor(.gray) + } + .frame(width: 80, height: 80) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(style: StrokeStyle( + lineWidth: 2, + dash: [8, 4] + )) + .foregroundColor(.gray.opacity(0.5)) + ) + .padding(4) + } } } -// MARK: - Upload Prompt View +// MARK: - 上传提示视图 +/// 上传提示视图 +/// 显示上传区域的占位图和提示 struct UploadPromptView: View { + /// 控制媒体选择器的显示/隐藏 @Binding var showMediaPicker: Bool var body: some View { - Button(action: { - showMediaPicker = true - }) { + Button(action: { showMediaPicker = true }) { + // 上传图标 SVGImage(svgName: "IP") .frame(width: 225, height: 225) .contentShape(Rectangle()) .overlay( RoundedRectangle(cornerRadius: 20) - .stroke(style: StrokeStyle( - lineWidth: 5, - lineCap: .round, - dash: [12, 8] - )) - .foregroundColor(Color.themePrimary) - ) - } - } -} - -// MARK: - Thumbnail Scroll View - -@MainActor -struct ThumbnailScrollView: View { - @ObservedObject var uploadManager: MediaUploadManager - @Binding var selectedIndices: Set - @Binding var selectedMedia: MediaType? - - // Track the currently selected index directly for faster access - @State private var selectedIndex: Int = 0 - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(0.. Void - - var body: some View { - Button(action: onTap) { - ZStack(alignment: .topTrailing) { - // Main thumbnail content - ZStack { - Group { - if let thumbnail = media.thumbnail { - Image(uiImage: thumbnail) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 80, height: 80) - .clipped() - } else { - Color.gray - .frame(width: 80, height: 80) - } - } - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? Color.themePrimary : Color.clear, lineWidth: 2) - ) - - // Upload progress border - if let uploadManager = uploadManager, - let index = uploadManager.selectedMedia.firstIndex(where: { $0 == media }) { - let status = uploadManager.uploadStatus["\(index)"] - if case .uploading(let progress) = status, progress > 0 && progress < 1 { - ZStack { - Circle() - .stroke( - Color.themePrimary.opacity(0.3), - style: StrokeStyle(lineWidth: 2, lineCap: .round) - ) - .rotationEffect(.degrees(-90)) - .padding(2) - - Circle() - .trim(from: 0, to: progress) - .stroke( - Color.themePrimary, - style: StrokeStyle(lineWidth: 2, lineCap: .round) - ) - .rotationEffect(.degrees(-90)) - .animation(.linear, value: progress) - .padding(2) - } - .frame(width: 20, height: 20) - .offset(x: 30, y: -30) - } - } - } - - // Selection checkmark - if isSelected && showCheckmark { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 20)) - .foregroundColor(.white) - .background(Circle().fill(Color.themePrimary)) - .offset(x: 6, y: -6) - .zIndex(1) - } - - // Video icon - if media.isVideo { - Image(systemName: "video.fill") - .font(.caption) - .foregroundColor(.white) - .padding(4) - .background(Color.black.opacity(0.6)) - .clipShape(Circle()) - .padding(4) - .offset(x: -4, y: 4) - } - } - .frame(width: 80, height: 80) - .contentShape(Rectangle()) - .padding(4) - } - .buttonStyle(PlainButtonStyle()) -} -} - -// MARK: - Add More Button - -struct AddMoreButton: View { - @Binding var showMediaPicker: Bool - - var body: some View { - Button(action: { showMediaPicker = true }) { - Image(systemName: "plus") - .font(.title2) - .foregroundColor(.gray) - .frame(width: 80, height: 80) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(style: StrokeStyle( - lineWidth: 1, - dash: [5, 3] - )) - .foregroundColor(.gray) - ) - } - } -} - -// MARK: - Media Preview - -struct MediaPreview: View { - let media: MediaType - @ObservedObject var uploadManager: MediaUploadManager - - private var uploadProgress: Double { - guard let index = uploadManager.selectedMedia.firstIndex(where: { $0 == media }), - case .uploading(let progress) = uploadManager.uploadStatus["\(index)"] else { - return 0 - } - return progress - } - - var body: some View { - ZStack { - // 媒体内容 - Group { - switch media { - case .image(let uiImage): - Image(uiImage: uiImage) - .resizable() - .scaledToFill() - case .video(_, let thumbnail): - if let thumbnail = thumbnail { - Image(uiImage: thumbnail) - .resizable() - .scaledToFill() - .overlay( - Image(systemName: "play.circle.fill") - .font(.system(size: 36)) - .foregroundColor(.white) - .shadow(radius: 8) - ) - } else { - Color.gray - } - } - } - .aspectRatio(1, contentMode: .fill) - .clipped() - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.themePrimary.opacity(0.3), lineWidth: 1) + .stroke(style: StrokeStyle( + lineWidth: 5, + lineCap: .round, + dash: [12, 8] + )) + .foregroundColor(Color.themePrimary) ) } } } -// MARK: - Preview +// MARK: - 媒体预览视图 + +/// 媒体预览视图 +/// 显示图片或视频的预览图 +struct MediaPreview: View { + // MARK: - 属性 + + /// 图片处理队列 + private let imageProcessingQueue = DispatchQueue( + label: "com.yourapp.imageprocessing", + qos: .userInitiated, + attributes: .concurrent + ) + + /// 媒体类型 + let media: MediaType + /// 上传管理器 + @ObservedObject var uploadManager: MediaUploadManager + + // MARK: - 状态 + + /// 加载的图片 + @State private var image: UIImage? + + /// 加载状态 + enum LoadState { + /// 加载成功 + case success(UIImage) + /// 加载失败 + case failure(Error) + } + + /// 当前加载状态 + @State private var loadState: LoadState? + + // MARK: - 图片缓存 + + /// 图片缓存 + private struct ImageCache { + static let shared = NSCache() + } + + // MARK: - 计算属性 + + /// 上传进度 + private var uploadProgress: Double { + guard let index = uploadManager.selectedMedia.firstIndex(where: { $0.id == media.id }) else { + return 0 + } + + if case .uploading(let progress) = uploadManager.uploadStatus["\(index)"] { + return progress + } else if case .completed = uploadManager.uploadStatus["\(index)"] { + return 1.0 + } + return 0 + } + + /// 是否正在上传 + private var isUploading: Bool { + guard let index = uploadManager.selectedMedia.firstIndex(where: { $0.id == media.id }) else { + return false + } + + if case .uploading = uploadManager.uploadStatus["\(index)"] { + return true + } + return false + } + + // MARK: - 视图主体 + + var body: some View { + ZStack { + // 显示图片或错误状态 + if let image = image { + loadedImageView(image) + + // 视频播放按钮 + if case .video = media { + playButton + } + + // 上传进度指示器(仅在上传时显示) + if isUploading || uploadProgress > 0 { + loadingOverlay + } + } else if case .failure(let error) = loadState { + // 加载失败状态 + errorView(error: error) + } else { + // 初始加载时显示占位图 + placeholderView + .onAppear { + loadImage() + } + } + } + .aspectRatio(1, contentMode: .fill) + .clipped() + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.themePrimary.opacity(0.3), lineWidth: 1) + ) + } + + // MARK: - 子视图 + + /// 加载中的占位图 + private var placeholderView: some View { + Color.gray.opacity(0.1) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + ) + } + + /// 加载完成的图片视图 + private func loadedImageView(_ image: UIImage) -> some View { + Image(uiImage: image) + .resizable() + .scaledToFill() + .transition(.opacity.animation(.easeInOut(duration: 0.2))) + } + + /// 播放按钮 + private var playButton: some View { + Image(systemName: "play.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.white) + .shadow(radius: 4) + } + + /// 错误视图 + private func errorView(error: Error) -> some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 20)) + .foregroundColor(.orange) + + Text("加载失败") + .font(.caption2) + .foregroundColor(.secondary) + + Button(action: { + loadState = nil + loadImage() + }) { + Image(systemName: "arrow.clockwise") + .font(.caption) + .padding(4) + .background(Color.gray.opacity(0.2)) + .clipShape(Circle()) + } + .padding(.top, 4) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + + /// 上传进度遮罩 + private var loadingOverlay: some View { + ZStack { + // 半透明黑色背景 + Color.black.opacity(0.3) + + // 进度条 + VStack { + Spacer() + + // 圆形进度指示器 + ZStack { + Circle() + .stroke( + Color.white.opacity(0.3), + lineWidth: 4 + ) + + Circle() + .trim(from: 0.0, to: uploadProgress) + .stroke( + Color.white, + style: StrokeStyle( + lineWidth: 4, + lineCap: .round + ) + ) + .rotationEffect(.degrees(-90)) + + Text("\(Int(uploadProgress * 100))%") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + } + .frame(width: 40, height: 40) + .padding(.bottom, 8) + } + } + .cornerRadius(8) + } + + // MARK: - 私有方法 + + /// 加载图片 + private func loadImage() { + let cacheKey = "\(media.id)" as NSString + + // 检查缓存 + if let cachedImage = ImageCache.shared.object(forKey: cacheKey) { + self.image = cachedImage + self.loadState = .success(cachedImage) + return + } + + // 使用专用的图片处理队列 + imageProcessingQueue.async { + do { + let imageToCache: UIImage + + switch self.media { + case .image(let uiImage): + imageToCache = uiImage + + case .video(_, let thumbnail): + guard let thumbnail = thumbnail else { + throw NSError( + domain: "com.yourapp.media", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "视频缩略图加载失败"] + ) + } + imageToCache = thumbnail + } + + // 缓存图片 + ImageCache.shared.setObject(imageToCache, forKey: cacheKey) + + // 更新UI + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + self.image = imageToCache + self.loadState = .success(imageToCache) + } + } + + } catch { + print("图片加载失败: \(error.localizedDescription)") + DispatchQueue.main.async { + self.loadState = .failure(error) + } + } + } + } +} + +// 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)" + } + } +} + +// MARK: - 预览 struct MediaUploadView_Previews: PreviewProvider { static var previews: some View { diff --git a/wake/View/Welcome/SplashView.swift b/wake/View/Welcome/SplashView.swift index 463e6c4..6eb0a22 100644 --- a/wake/View/Welcome/SplashView.swift +++ b/wake/View/Welcome/SplashView.swift @@ -19,7 +19,7 @@ struct SplashView: View { ) .edgesIgnoringSafeArea(.all) VStack(spacing: 50) { - FilmAnimation() + // FilmAnimation() } .padding() }