feat: 上传图片
This commit is contained in:
parent
1e6305ec35
commit
95f4d7c52e
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
168
wake/View/Components/Upload/MediaPickerWithLogger.swift
Normal file
168
wake/View/Components/Upload/MediaPickerWithLogger.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user