feat: 上传组件

This commit is contained in:
jinyaqiu 2025-08-21 09:00:24 +08:00
parent 093f9048f9
commit 1a2c1bf959
4 changed files with 412 additions and 147 deletions

View File

@ -4,11 +4,11 @@ import AVFoundation
import os.log import os.log
/// ///
enum MediaType: Equatable { public enum MediaType: Equatable {
case image(UIImage) case image(UIImage)
case video(URL, UIImage?) // URL UIImage case video(URL, UIImage?) // URL UIImage
var thumbnail: UIImage? { public var thumbnail: UIImage? {
switch self { switch self {
case .image(let image): case .image(let image):
return image return image
@ -17,14 +17,14 @@ enum MediaType: Equatable {
} }
} }
var isVideo: Bool { public var isVideo: Bool {
if case .video = self { if case .video = self {
return true return true
} }
return false return false
} }
static func == (lhs: MediaType, rhs: MediaType) -> Bool { public static func == (lhs: MediaType, rhs: MediaType) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case (.image(let lhsImage), .image(let rhsImage)): case (.image(let lhsImage), .image(let rhsImage)):
return lhsImage.pngData() == rhsImage.pngData() return lhsImage.pngData() == rhsImage.pngData()

View File

@ -0,0 +1,184 @@
import SwiftUI
struct MediaUploadDemo: View {
@StateObject private var uploadManager = MediaUploadManager()
@State private var showMediaPicker = false
@State private var showUploadAlert = false
@State private var isUploading = false
var body: some View {
NavigationView {
VStack(spacing: 20) {
//
Button(action: {
showMediaPicker = true
}) {
Label("添加图片或视频", systemImage: "plus.circle.fill")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
.padding(.horizontal)
.sheet(isPresented: $showMediaPicker) {
MediaPickerWithLogging(
selectedMedia: $uploadManager.selectedMedia,
selectionLimit: 10,
onDismiss: {
showMediaPicker = false
//
if !uploadManager.selectedMedia.isEmpty {
isUploading = true
uploadManager.startUpload()
}
}
)
}
//
if uploadManager.selectedMedia.isEmpty {
VStack(spacing: 16) {
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 60))
.foregroundColor(.gray)
Text("暂无媒体文件")
.font(.headline)
.foregroundColor(.gray)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 10)], spacing: 10) {
ForEach(0..<uploadManager.selectedMedia.count, id: \.self) { index in
MediaItemView(
media: uploadManager.selectedMedia[index],
status: uploadManager.uploadStatus["\(index)"] ?? .pending
)
.onTapGesture {
//
if case .video = uploadManager.selectedMedia[index] {
//
print("Play video at index \(index)")
}
}
}
}
.padding()
}
}
//
if isUploading {
VStack {
//
if let progress = uploadManager.uploadStatus.values.compactMap({ status -> Double? in
if case .uploading(let progress) = status { return progress }
return nil
}).first {
ProgressView(value: progress, total: 1.0)
.padding(.horizontal)
Text("上传中 \(Int(progress * 100))%")
.font(.subheadline)
.foregroundColor(.gray)
} else {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.scaleEffect(1.5)
Text("正在准备上传...")
.font(.subheadline)
.foregroundColor(.gray)
.padding(.top, 8)
}
}
.frame(maxWidth: .infinity)
.padding()
}
}
.alert(isPresented: $showUploadAlert) {
Alert(
title: Text(uploadManager.isAllUploaded ? "上传完成" : "上传状态"),
message: Text(uploadManager.isAllUploaded ?
"所有文件上传完成!" :
"正在处理上传..."),
dismissButton: .default(Text("确定"))
)
}
.onChange(of: uploadManager.uploadStatus) { _ in
//
let allFinished = uploadManager.uploadStatus.values.allSatisfy { status in
if case .completed = status { return true }
if case .failed = status { return true }
return false
}
if allFinished && !uploadManager.uploadStatus.isEmpty {
isUploading = false
showUploadAlert = true
}
}
}
}
}
//
struct MediaItemView: View {
let media: MediaType
let status: MediaUploadStatus
var body: some View {
ZStack(alignment: .bottom) {
//
if let thumbnail = media.thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
.frame(width: 120, height: 120)
.cornerRadius(8)
.clipped()
//
if media.isVideo {
Image(systemName: "play.circle.fill")
.font(.system(size: 30))
.foregroundColor(.white)
.shadow(radius: 5)
}
//
VStack {
Spacer()
if case .uploading(let progress) = status {
ProgressView(value: progress, total: 1.0)
.progressViewStyle(LinearProgressViewStyle())
.frame(height: 4)
.padding(.horizontal, 4)
} else if case .completed = status {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.padding(4)
.background(Circle().fill(Color.white))
} else if case .failed = status {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(.red)
.padding(4)
.background(Circle().fill(Color.white))
}
}
.padding(4)
}
}
.frame(width: 120, height: 120)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
}
//
#Preview {
MediaUploadDemo()
.environmentObject(AuthState.shared)
}

View File

@ -1,54 +1,161 @@
import SwiftUI import SwiftUI
import os.log import os.log
/// ///
struct ExampleView: View { public enum MediaUploadStatus: Equatable {
/// case pending
@State private var showMediaPicker = false case uploading(progress: Double)
/// case completed(fileId: String)
@State private var selectedMedia: [MediaType] = [] case failed(Error)
///
@State private var uploadStatus: [String: UploadStatus] = [:]
///
private let uploader = ImageUploadService()
/// public static func == (lhs: MediaUploadStatus, rhs: MediaUploadStatus) -> Bool {
private enum UploadStatus: Equatable { switch (lhs, rhs) {
case pending case (.pending, .pending):
case uploading(progress: Double) return true
case completed(fileId: String) case (.uploading(let lhsProgress), .uploading(let rhsProgress)):
case failed(Error) return lhsProgress == rhsProgress
case (.completed(let lhsId), .completed(let rhsId)):
static func == (lhs: UploadStatus, rhs: UploadStatus) -> Bool { return lhsId == rhsId
switch (lhs, rhs) { case (.failed, .failed):
case (.pending, .pending): return false // Errors don't need to be equatable
return true default:
case (.uploading(let lhsProgress), .uploading(let rhsProgress)): return false
return lhsProgress == rhsProgress
case (.completed(let lhsId), .completed(let rhsId)):
return lhsId == rhsId
default:
return false
}
}
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)"
}
} }
} }
public 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)"
}
}
}
///
public class MediaUploadManager: ObservableObject {
///
@Published public var selectedMedia: [MediaType] = []
///
@Published public var uploadStatus: [String: MediaUploadStatus] = [:]
private let uploader = ImageUploadService()
public init() {}
///
public func addMedia(_ media: [MediaType]) {
selectedMedia.append(contentsOf: media)
}
///
public func removeMedia(at index: Int) {
guard index < selectedMedia.count else { return }
selectedMedia.remove(at: index)
//
var newStatus: [String: MediaUploadStatus] = [:]
uploadStatus.forEach { key, value in
if let keyInt = Int(key), keyInt < index {
newStatus[key] = value
} else if let keyInt = Int(key), keyInt > index {
newStatus["\(keyInt - 1)"] = value
}
}
uploadStatus = newStatus
}
///
public func clearAllMedia() {
selectedMedia.removeAll()
uploadStatus.removeAll()
}
///
public func startUpload() {
print("🔄 开始批量上传 \(selectedMedia.count) 个文件")
//
uploadStatus.removeAll()
for (index, media) in selectedMedia.enumerated() {
let id = "\(index)"
uploadStatus[id] = .pending
// Convert MediaType to ImageUploadService.MediaType
let uploadMediaType: ImageUploadService.MediaType
switch media {
case .image(let image):
uploadMediaType = .image(image)
case .video(let url, let thumbnail):
uploadMediaType = .video(url, thumbnail)
}
uploadMedia(uploadMediaType, id: id)
}
}
///
public func getUploadResults() -> [String: String] {
var results: [String: String] = [:]
for (id, status) in uploadStatus {
if case .completed(let fileId) = status {
results[id] = fileId
}
}
return results
}
///
public var isAllUploaded: Bool {
guard !selectedMedia.isEmpty else { return false }
return uploadStatus.allSatisfy { _, status in
if case .completed = status { return true }
return false
}
}
// MARK: - Private Methods
private func uploadMedia(_ media: ImageUploadService.MediaType, id: String) {
print("🔄 开始处理媒体: \(id)")
uploadStatus[id] = .uploading(progress: 0)
uploader.uploadMedia(
media,
progress: { progress in
print("📊 上传进度 (\(id)): \(progress.current)%")
DispatchQueue.main.async {
self.uploadStatus[id] = .uploading(progress: progress.progress)
}
},
completion: { [weak self] result in
guard let self = self else { return }
DispatchQueue.main.async {
switch result {
case .success(let uploadResult):
print("✅ 上传成功 (\(id)): \(uploadResult.fileId)")
self.uploadStatus[id] = .completed(fileId: uploadResult.fileId)
case .failure(let error):
print("❌ 上传失败 (\(id)): \(error.localizedDescription)")
self.uploadStatus[id] = .failed(error)
}
}
}
)
}
}
// MARK: - Preview Helper
/// 使 MediaUploadManager
struct MediaUploadExample: View {
@StateObject private var uploadManager = MediaUploadManager()
@State private var showMediaPicker = false
var body: some View { var body: some View {
NavigationView { NavigationView {
VStack(spacing: 20) { VStack(spacing: 20) {
// //
Button(action: { Button(action: { showMediaPicker = true }) {
showMediaPicker = true
}) {
Label("选择媒体", systemImage: "photo.on.rectangle") Label("选择媒体", systemImage: "photo.on.rectangle")
.font(.headline) .font(.headline)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -59,121 +166,95 @@ struct ExampleView: View {
} }
.padding(.horizontal) .padding(.horizontal)
// //
if !selectedMedia.isEmpty { MediaSelectionView(uploadManager: uploadManager)
VStack(spacing: 10) {
Text("已选择 \(selectedMedia.count) 个媒体文件") //
.font(.headline) Button(action: { uploadManager.startUpload() }) {
Text("开始上传")
// .font(.headline)
List { .frame(maxWidth: .infinity)
ForEach(0..<selectedMedia.count, id: \.self) { index in .padding()
let media = selectedMedia[index] .background(uploadManager.selectedMedia.isEmpty ? Color.gray : Color.green)
let mediaId = "\(index)" .foregroundColor(.white)
let status = uploadStatus[mediaId] ?? .pending .cornerRadius(10)
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)
} }
.padding(.horizontal)
.disabled(uploadManager.selectedMedia.isEmpty)
Spacer() Spacer()
} }
.navigationTitle("媒体上传") .navigationTitle("媒体上传")
.sheet(isPresented: $showMediaPicker) { .sheet(isPresented: $showMediaPicker) {
MediaPickerWithLogging( MediaPickerWithLogging(
selectedMedia: $selectedMedia, selectedMedia: $uploadManager.selectedMedia,
selectionLimit: 5, selectionLimit: 5,
onDismiss: { onDismiss: { showMediaPicker = false }
//
showMediaPicker = false
//
uploadAllMedia()
}
) )
} }
.onChange(of: selectedMedia) { _ in }
// selectedMedia }
uploadStatus.removeAll() }
///
struct MediaSelectionView: View {
@ObservedObject var uploadManager: MediaUploadManager
var body: some View {
if !uploadManager.selectedMedia.isEmpty {
VStack(spacing: 10) {
Text("已选择 \(uploadManager.selectedMedia.count) 个媒体文件")
.font(.headline)
//
List {
ForEach(0..<uploadManager.selectedMedia.count, id: \.self) { index in
let media = uploadManager.selectedMedia[index]
let mediaId = "\(index)"
let status = uploadManager.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)
}
.onDelete { indexSet in
indexSet.forEach { index in
uploadManager.removeMedia(at: index)
}
}
}
.frame(height: 300)
} }
.padding(.top)
} else {
Text("未选择任何媒体")
.foregroundColor(.secondary)
.padding(.top, 50)
} }
} }
// private func statusColor(_ status: MediaUploadStatus) -> Color {
private func uploadMedia(_ media: ImageUploadService.MediaType, id: String) {
print("🔄 开始处理媒体: \(id)")
uploadStatus[id] = .uploading(progress: 0)
uploader.uploadMedia(
media,
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 {
// Convert MediaType to ImageUploadService.MediaType
let uploadMediaType: ImageUploadService.MediaType
switch media {
case .image(let image):
uploadMediaType = .image(image)
case .video(let url, let thumbnail):
uploadMediaType = .video(url, thumbnail)
}
uploadMedia(uploadMediaType, id: id)
}
}
}
//
private func statusColor(_ status: UploadStatus) -> Color {
switch status { switch status {
case .pending: return .secondary case .pending: return .secondary
case .uploading: return .blue case .uploading: return .blue
@ -212,6 +293,6 @@ private struct MediaThumbnailView: View {
} }
#Preview { #Preview {
ExampleView() MediaUploadExample()
.environmentObject(AuthState.shared) .environmentObject(AuthState.shared)
} }

View File

@ -46,7 +46,7 @@ struct WakeApp: App {
// userInfo // userInfo
// UserInfo() // UserInfo()
// .environmentObject(authState) // .environmentObject(authState)
ExampleView() MediaUploadDemo()
.environmentObject(authState) .environmentObject(authState)
} else { } else {
// //