feat: 确认上传

This commit is contained in:
jinyaqiu 2025-08-19 18:28:49 +08:00
parent b6391f0734
commit c828ff786a
5 changed files with 343 additions and 189 deletions

View File

@ -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"
]
}
}

View File

@ -1,190 +1,46 @@
import SwiftUI import SwiftUI
import PhotosUI 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<String, Error>) -> 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<String, Error>) -> 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()
}
}
/// ///
/// 使UIViewControllerRepresentablePHPickerViewControllerSwiftUI
struct PhotoPicker: UIViewControllerRepresentable { struct PhotoPicker: UIViewControllerRepresentable {
// MARK: - // MARK: - Properties
/// ///
@Binding var selectedImages: [UIImage] @Binding var selectedImages: [UIImage]
/// /// 1
let selectionLimit: Int let selectionLimit: Int
/// ///
let filter: PHPickerFilter let filter: PHPickerFilter
/// ///
private let uploader = ImageUploader() var onImageUploaded: ((Result<ImageUploaderGetID.UploadResult, Error>) -> 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<ImageUploaderGetID.UploadResult, Error>) -> Void)? = nil
) {
self._selectedImages = selectedImages self._selectedImages = selectedImages
self.selectionLimit = selectionLimit self.selectionLimit = selectionLimit
self.filter = filter self.filter = filter
self.onImageUploaded = onImageUploaded
} }
// MARK: - UIViewControllerRepresentable // MARK: - UIViewControllerRepresentable
/// PHPickerViewController
func makeUIViewController(context: Context) -> PHPickerViewController { func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
configuration.filter = filter configuration.filter = filter
@ -196,89 +52,131 @@ struct PhotoPicker: UIViewControllerRepresentable {
return picker return picker
} }
///
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
/// PHPickerViewController
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator(self) Coordinator(self)
} }
// MARK: - // MARK: - Coordinator
/// PHPickerViewController
class Coordinator: NSObject, PHPickerViewControllerDelegate { class Coordinator: NSObject, PHPickerViewControllerDelegate {
///
let parent: PhotoPicker let parent: PhotoPicker
///
private let uploader = ImageUploaderGetID()
init(_ parent: PhotoPicker) { init(_ parent: PhotoPicker) {
self.parent = parent self.parent = parent
} }
///
/// - Parameters:
/// - picker:
/// - results:
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
//
parent.selectedImages.removeAll() parent.selectedImages.removeAll()
// 使DispatchGroup
let group = DispatchGroup() let group = DispatchGroup()
var loadedImages: [Int: UIImage] = [:] var loadedImages: [Int: UIImage] = [:] //
//
for (index, result) in results.enumerated() { for (index, result) in results.enumerated() {
group.enter() group.enter() //
//
if result.itemProvider.canLoadObject(ofClass: UIImage.self) { 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 { if let image = image as? UIImage {
// //
loadedImages[index] = image 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 { } else {
group.leave() group.leave() //
} }
} }
//
group.notify(queue: .main) { group.notify(queue: .main) {
//
let sortedImages = loadedImages.sorted { $0.key < $1.key }.map { $0.value } let sortedImages = loadedImages.sorted { $0.key < $1.key }.map { $0.value }
self.parent.selectedImages.append(contentsOf: sortedImages) self.parent.selectedImages.append(contentsOf: sortedImages)
//
picker.dismiss(animated: true) picker.dismiss(animated: true)
} }
} }
} }
} }
// MARK: - // MARK: - AvatarUploader
/// ///
///
struct AvatarUploader: View { struct AvatarUploader: View {
// MARK: - // MARK: - Properties
/// ///
@Binding var selectedImage: UIImage? @Binding var selectedImage: UIImage?
/// ///
let size: CGFloat let size: CGFloat
// MARK: - ///
var onUploadComplete: ((Result<ImageUploaderGetID.UploadResult, Error>) -> Void)?
/// // MARK: - State
///
@State private var isImagePickerPresented = false @State private var isImagePickerPresented = false
// MARK: - // MARK: - Body
var body: some View { var body: some View {
Button(action: { //
isImagePickerPresented = true Button(action: { isImagePickerPresented = true }) {
}) {
ZStack { ZStack {
if let selectedImage = selectedImage { if let selectedImage = selectedImage {
//
Image(uiImage: selectedImage) Image(uiImage: selectedImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(width: size, height: size) .frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.1)) .clipShape(RoundedRectangle(cornerRadius: size * 0.1))
} else { } else {
//
Color.gray.opacity(0.1) Color.gray.opacity(0.1)
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay( .overlay(
@ -293,10 +191,11 @@ struct AvatarUploader: View {
} }
} }
.frame(width: size, height: size) .frame(width: size, height: size)
.contentShape(Rectangle()) .contentShape(Rectangle()) //
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle()) // 使
.sheet(isPresented: $isImagePickerPresented) { .sheet(isPresented: $isImagePickerPresented) {
//
PhotoPicker( PhotoPicker(
selectedImages: Binding( selectedImages: Binding(
get: { [selectedImage].compactMap { $0 } }, get: { [selectedImage].compactMap { $0 } },
@ -304,7 +203,11 @@ struct AvatarUploader: View {
selectedImage = images.first selectedImage = images.first
} }
), ),
selectionLimit: 1 selectionLimit: 1, //
onImageUploaded: { result in
//
onUploadComplete?(result)
}
) )
} }
} }

View File

@ -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<UploadResult, Error>) -> 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<UploadResult, Error>) -> 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"
}
}
}