2025-08-19 20:36:00 +08:00

354 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String, Error>) -> 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<String, Error>) -> 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<String, Error>) -> Void)? = nil //
private let uploader = ImageUploader() //
// MARK: -
init(selectedImages: Binding<[UIImage]>,
selectionLimit: Int = 1,
filter: PHPickerFilter = .images,
onUploadComplete: ((Result<String, Error>) -> 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<String, Error>) {
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