354 lines
13 KiB
Swift
354 lines
13 KiB
Swift
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 |