feat: 上传图片

This commit is contained in:
jinyaqiu 2025-08-21 08:30:20 +08:00
parent 1e6305ec35
commit 95f4d7c52e
3 changed files with 366 additions and 131 deletions

View File

@ -173,49 +173,4 @@ struct MediaPicker: UIViewControllerRepresentable {
}
}
}
}
// MARK: -
struct MediaThumbnailView: View {
let media: MediaType
let onDelete: (() -> Void)?
var body: some View {
ZStack(alignment: .topTrailing) {
//
if let thumbnail = media.thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.clipped()
.cornerRadius(8)
} else {
Color.gray
.frame(width: 80, height: 80)
.cornerRadius(8)
}
//
if media.isVideo {
Image(systemName: "video.fill")
.foregroundColor(.white)
.padding(4)
.background(Color.black.opacity(0.6))
.clipShape(Circle())
.padding(4)
}
//
if let onDelete = onDelete {
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.background(Color.white)
.clipShape(Circle())
}
.offset(x: 8, y: -8)
}
}
}
}
}

View File

@ -0,0 +1,168 @@
import SwiftUI
import PhotosUI
import os.log
import AVKit
struct MediaPickerWithLogging: UIViewControllerRepresentable {
@Binding var selectedMedia: [MediaType]
let selectionLimit: Int
let onDismiss: (() -> Void)?
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared())
configuration.filter = .any(of: [.videos, .images])
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)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let parent: MediaPickerWithLogging
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.app", category: "MediaPickerWithLogging")
init(_ parent: MediaPickerWithLogging) {
self.parent = parent
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard !results.isEmpty else {
parent.onDismiss?()
return
}
var processedMedia: [MediaType] = []
let group = DispatchGroup()
for result in results {
let itemProvider = result.itemProvider
if itemProvider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
group.enter()
processImage(itemProvider: itemProvider) { media in
if let media = media {
processedMedia.append(media)
}
group.leave()
}
} else if itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) {
group.enter()
processVideo(itemProvider: itemProvider) { media in
if let media = media {
processedMedia.append(media)
}
group.leave()
}
}
}
group.notify(queue: .main) {
self.parent.selectedMedia = processedMedia
self.printMediaInfo(media: processedMedia)
self.parent.onDismiss?()
}
}
private func processImage(itemProvider: NSItemProvider, completion: @escaping (MediaType?) -> Void) {
itemProvider.loadObject(ofClass: UIImage.self) { (object, error) in
if let image = object as? UIImage {
completion(.image(image))
} else {
self.logger.error("Failed to load image: \(error?.localizedDescription ?? "Unknown error")")
completion(nil)
}
}
}
private func processVideo(itemProvider: NSItemProvider, completion: @escaping (MediaType?) -> Void) {
itemProvider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
guard let videoURL = url, error == nil else {
self.logger.error("Failed to load video: \(error?.localizedDescription ?? "Unknown error")")
completion(nil)
return
}
let tempDirectory = FileManager.default.temporaryDirectory
let targetURL = tempDirectory.appendingPathComponent("\(UUID().uuidString).\(videoURL.pathExtension)")
do {
if FileManager.default.fileExists(atPath: targetURL.path) {
try FileManager.default.removeItem(at: targetURL)
}
try FileManager.default.copyItem(at: videoURL, to: targetURL)
MediaUtils.extractFirstFrame(from: targetURL) { result in
switch result {
case .success(let thumbnail):
completion(.video(targetURL, thumbnail))
case .failure(let error):
self.logger.error("Failed to extract video thumbnail: \(error.localizedDescription)")
completion(.video(targetURL, nil))
}
}
} catch {
self.logger.error("Failed to copy video file: \(error.localizedDescription)")
completion(nil)
}
}
}
private func printMediaInfo(media: [MediaType]) {
print("=== Selected Media Information ===")
for (index, media) in media.enumerated() {
print("\nItem \(index + 1):")
switch media {
case .image(let image):
print("Type: Image")
print("Dimensions: \(Int(image.size.width))x\(Int(image.size.height))")
if let data = image.jpegData(compressionQuality: 1.0) {
print("File Size: \(formatFileSize(Int64(data.count)))")
}
case .video(let url, _):
print("Type: Video")
print("File Name: \(url.lastPathComponent)")
print("File Path: \(url.path)")
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let fileSize = attributes[.size] as? Int64 {
print("File Size: \(formatFileSize(fileSize))")
}
let asset = AVURLAsset(url: url)
let duration = asset.duration.seconds
print("Duration: \(formatTimeInterval(duration))")
if let track = asset.tracks(withMediaType: .video).first {
let size = track.naturalSize
print("Video Dimensions: \(Int(size.width))x\(Int(size.height))")
}
}
}
print("================================\n")
}
private func formatFileSize(_ bytes: Int64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
private func formatTimeInterval(_ interval: TimeInterval) -> String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second]
formatter.zeroFormattingBehavior = .pad
return formatter.string(from: interval) ?? "00:00"
}
}
}

View File

@ -1,99 +1,211 @@
import SwiftUI
import os.log
public struct ExampleView: View {
@StateObject private var mediaUploader = MediaUploader(
maxSelection: 5,
onUploadComplete: { _ in },
uploadFunction: { media, progress in
//
for i in 0...10 {
try await Task.sleep(nanoseconds: 100_000_000) // 0.1
progress(Double(i) / 10.0)
}
//
switch media {
case .image(let image):
//
guard let url = URL(string: "https://example.com/images/\(UUID().uuidString).jpg") else {
throw NSError(domain: "com.example.app", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
}
return url
case .video(let url, _):
//
guard let url = URL(string: "https://example.com/videos/\(UUID().uuidString).mp4") else {
throw NSError(domain: "com.example.app", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"])
}
return url
///
struct ExampleView: View {
///
@State private var showMediaPicker = false
///
@State private var selectedMedia: [MediaType] = []
///
@State private var uploadStatus: [String: UploadStatus] = [:]
///
private let uploader = ImageUploadService()
///
private enum UploadStatus: Equatable {
case pending
case uploading(progress: Double)
case completed(fileId: String)
case failed(Error)
static func == (lhs: UploadStatus, rhs: UploadStatus) -> Bool {
switch (lhs, rhs) {
case (.pending, .pending):
return true
case (.uploading(let lhsProgress), .uploading(let rhsProgress)):
return lhsProgress == rhsProgress
case (.completed(let lhsId), .completed(let rhsId)):
return lhsId == rhsId
default:
return false
}
}
)
@State private var uploadedURLs: [URL] = []
@EnvironmentObject private var authState: AuthState
public init() {}
public var body: some View {
NavigationView {
VStack {
//
Button(action: {
mediaUploader.showPicker()
}) {
HStack {
Image(systemName: "plus.circle.fill")
Text("添加媒体")
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.padding(.horizontal)
//
MediaUploaderView(mediaUploader: mediaUploader)
.onChange(of: mediaUploader.selectedMedia) { newValue in
if !newValue.isEmpty {
Task {
if let results = await mediaUploader.startUpload() {
let urls = results.map { $0.1 }
uploadedURLs.append(contentsOf: urls)
}
}
}
}
// URL
List(uploadedURLs, id: \.self) { url in
Text(url.absoluteString)
.font(.caption)
.padding(.vertical, 4)
}
.listStyle(PlainListStyle())
var description: String {
switch self {
case .pending: return "等待上传"
case .uploading(let progress): return "上传中 \(Int(progress * 100))%"
case .completed(let fileId): return "上传完成 (ID: \(fileId.prefix(8))...)"
case .failed(let error): return "上传失败: \(error.localizedDescription)"
}
.navigationTitle("媒体上传示例")
}
}
//
private func uploadMedia(_ media: MediaType, progress: @escaping (Double) -> Void) async throws -> URL {
//
for i in 0...10 {
try await Task.sleep(nanoseconds: 100_000_000) // 0.1
progress(Double(i) / 10.0)
var body: some View {
NavigationView {
VStack(spacing: 20) {
//
Button(action: {
showMediaPicker = true
}) {
Label("选择媒体", systemImage: "photo.on.rectangle")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.padding(.horizontal)
//
if !selectedMedia.isEmpty {
VStack(spacing: 10) {
Text("已选择 \(selectedMedia.count) 个媒体文件")
.font(.headline)
//
List {
ForEach(0..<selectedMedia.count, id: \.self) { index in
let media = selectedMedia[index]
let mediaId = "\(index)"
let status = uploadStatus[mediaId] ?? .pending
HStack {
//
MediaThumbnailView(media: media, onDelete: nil)
.frame(width: 60, height: 60)
VStack(alignment: .leading, spacing: 4) {
Text(media.isVideo ? "视频" : "图片")
.font(.subheadline)
//
Text(status.description)
.font(.caption)
.foregroundColor(statusColor(status))
//
if case .uploading(let progress) = status {
ProgressView(value: progress, total: 1.0)
.progressViewStyle(LinearProgressViewStyle())
.frame(height: 4)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 8)
}
}
.frame(height: 300)
}
.padding(.top)
} else {
Text("未选择任何媒体")
.foregroundColor(.secondary)
.padding(.top, 50)
}
Spacer()
}
.navigationTitle("媒体上传")
.sheet(isPresented: $showMediaPicker) {
MediaPickerWithLogging(
selectedMedia: $selectedMedia,
selectionLimit: 5,
onDismiss: {
//
showMediaPicker = false
//
uploadAllMedia()
}
)
}
.onChange(of: selectedMedia) { _ in
// selectedMedia
uploadStatus.removeAll()
}
}
}
//
private func uploadMedia(_ media: MediaType, id: String) {
guard case .image(let image) = media else {
print("❌ 暂不支持上传视频文件")
uploadStatus[id] = .failed(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "暂不支持上传视频文件"]))
return
}
//
switch media {
case .image(let image):
//
return URL(string: "https://example.com/images/\(UUID().uuidString).jpg")!
case .video(let url, _):
//
return URL(string: "https://example.com/videos/\(UUID().uuidString).mp4")!
print("🔄 开始压缩并上传媒体: \(id)")
uploadStatus[id] = .uploading(progress: 0)
uploader.uploadCompressedImage(
image,
compressionQuality: 0.7, // 0.7
progress: { progress in
print("📊 上传进度 (\(id)): \(progress.current)%")
uploadStatus[id] = .uploading(progress: progress.progress)
},
completion: { result in
switch result {
case .success(let uploadResult):
print("✅ 上传成功 (\(id)): \(uploadResult.fileId)")
uploadStatus[id] = .completed(fileId: uploadResult.fileId)
case .failure(let error):
print("❌ 上传失败 (\(id)): \(error.localizedDescription)")
uploadStatus[id] = .failed(error)
}
}
)
}
//
private func uploadAllMedia() {
print("🔄 开始批量上传 \(selectedMedia.count) 个文件")
for (index, media) in selectedMedia.enumerated() {
let id = "\(index)"
if case .pending = uploadStatus[id] ?? .pending {
uploadMedia(media, id: id)
}
}
}
//
private func statusColor(_ status: UploadStatus) -> Color {
switch status {
case .pending: return .secondary
case .uploading: return .blue
case .completed: return .green
case .failed: return .red
}
}
}
///
private struct MediaThumbnailView: View {
let media: MediaType
let onDelete: (() -> Void)?
var body: some View {
ZStack(alignment: .topTrailing) {
if let thumbnail = media.thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80)
.cornerRadius(8)
.clipped()
if media.isVideo {
Image(systemName: "video.fill")
.foregroundColor(.white)
.padding(4)
.background(Color.black.opacity(0.6))
.clipShape(Circle())
.padding(4)
}
}
}
}
}