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
///
enum MediaType: Equatable {
public enum MediaType: Equatable {
case image(UIImage)
case video(URL, UIImage?) // URL UIImage
var thumbnail: UIImage? {
public var thumbnail: UIImage? {
switch self {
case .image(let image):
return image
@ -17,14 +17,14 @@ enum MediaType: Equatable {
}
}
var isVideo: Bool {
public var isVideo: Bool {
if case .video = self {
return true
}
return false
}
static func == (lhs: MediaType, rhs: MediaType) -> Bool {
public static func == (lhs: MediaType, rhs: MediaType) -> Bool {
switch (lhs, rhs) {
case (.image(let lhsImage), .image(let rhsImage)):
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 os.log
///
struct ExampleView: View {
///
@State private var showMediaPicker = false
///
@State private var selectedMedia: [MediaType] = []
///
@State private var uploadStatus: [String: UploadStatus] = [:]
///
private let uploader = ImageUploadService()
///
public enum MediaUploadStatus: Equatable {
case pending
case uploading(progress: Double)
case completed(fileId: String)
case failed(Error)
///
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
}
}
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 static func == (lhs: MediaUploadStatus, rhs: MediaUploadStatus) -> 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
case (.failed, .failed):
return false // Errors don't need to be equatable
default:
return false
}
}
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 {
NavigationView {
VStack(spacing: 20) {
//
Button(action: {
showMediaPicker = true
}) {
Button(action: { showMediaPicker = true }) {
Label("选择媒体", systemImage: "photo.on.rectangle")
.font(.headline)
.frame(maxWidth: .infinity)
@ -59,121 +166,95 @@ struct ExampleView: View {
}
.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)
//
MediaSelectionView(uploadManager: uploadManager)
//
Button(action: { uploadManager.startUpload() }) {
Text("开始上传")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(uploadManager.selectedMedia.isEmpty ? Color.gray : Color.green)
.foregroundColor(.white)
.cornerRadius(10)
}
.padding(.horizontal)
.disabled(uploadManager.selectedMedia.isEmpty)
Spacer()
}
.navigationTitle("媒体上传")
.sheet(isPresented: $showMediaPicker) {
MediaPickerWithLogging(
selectedMedia: $selectedMedia,
selectedMedia: $uploadManager.selectedMedia,
selectionLimit: 5,
onDismiss: {
//
showMediaPicker = false
//
uploadAllMedia()
}
onDismiss: { showMediaPicker = false }
)
}
.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 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 {
private func statusColor(_ status: MediaUploadStatus) -> Color {
switch status {
case .pending: return .secondary
case .uploading: return .blue
@ -212,6 +293,6 @@ private struct MediaThumbnailView: View {
}
#Preview {
ExampleView()
MediaUploadExample()
.environmentObject(AuthState.shared)
}

View File

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