447 lines
17 KiB
Swift
447 lines
17 KiB
Swift
import SwiftUI
|
||
import os.log
|
||
|
||
/// 媒体上传状态
|
||
public enum MediaUploadStatus: Equatable, Identifiable {
|
||
case pending
|
||
case uploading(progress: Double)
|
||
case completed(fileId: String)
|
||
case failed(Error)
|
||
|
||
public var id: String {
|
||
switch self {
|
||
case .pending: return "pending"
|
||
case .uploading: return "uploading"
|
||
case .completed(let fileId): return "completed_\(fileId)"
|
||
case .failed: return "failed"
|
||
}
|
||
}
|
||
|
||
public static func == (lhs: MediaUploadStatus, rhs: MediaUploadStatus) -> Bool {
|
||
switch (lhs, rhs) {
|
||
case (.pending, .pending):
|
||
return true
|
||
case (.uploading(let lhsProgress), .uploading(let rhsProgress)):
|
||
return abs(lhsProgress - rhsProgress) < 0.01
|
||
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 var isCompleted: Bool {
|
||
if case .completed = self { return true }
|
||
return false
|
||
}
|
||
|
||
public var isUploading: Bool {
|
||
if case .uploading = self { return true }
|
||
return false
|
||
}
|
||
}
|
||
|
||
/// 媒体上传管理器
|
||
@MainActor
|
||
public class MediaUploadManager: ObservableObject {
|
||
/// 已选媒体文件
|
||
@Published public private(set) var selectedMedia: [MediaType] = []
|
||
/// 上传状态
|
||
@Published public private(set) var uploadStatus: [String: MediaUploadStatus] = [:]
|
||
/// 上传结果
|
||
@Published public private(set) var uploadResults: [String: UploadResult] = [:]
|
||
|
||
private let uploader = ImageUploadService() // Use ImageUploadService
|
||
private let logger = Logger(subsystem: "com.yourapp.media", category: "MediaUploadManager")
|
||
|
||
public init() {}
|
||
|
||
/// 添加上传媒体
|
||
public func addMedia(_ media: [MediaType]) {
|
||
let newMedia = media.filter { newItem in
|
||
!self.selectedMedia.contains { $0.id == newItem.id }
|
||
}
|
||
|
||
var updatedMedia = self.selectedMedia
|
||
for item in newMedia {
|
||
updatedMedia.append(item)
|
||
self.uploadStatus[item.id] = .pending
|
||
}
|
||
self.selectedMedia = updatedMedia
|
||
|
||
// 如果是第一次添加媒体,发送通知
|
||
if !newMedia.isEmpty, let firstMedia = newMedia.first, self.selectedMedia.count == newMedia.count {
|
||
NotificationCenter.default.post(
|
||
name: .didAddFirstMedia,
|
||
object: nil,
|
||
userInfo: ["media": firstMedia]
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 移除指定ID的媒体
|
||
public func removeMedia(id: String) {
|
||
Task { @MainActor in
|
||
self.selectedMedia.removeAll { $0.id == id }
|
||
self.uploadStatus.removeValue(forKey: id)
|
||
self.uploadResults.removeValue(forKey: id)
|
||
}
|
||
}
|
||
|
||
/// 清空所有媒体
|
||
public func clearAllMedia() {
|
||
selectedMedia.removeAll()
|
||
uploadStatus.removeAll()
|
||
uploadResults.removeAll()
|
||
}
|
||
|
||
/// 开始上传所有选中的媒体
|
||
public func startUpload() {
|
||
logger.info("🔄 开始批量上传 \(self.selectedMedia.count) 个文件")
|
||
|
||
// 只处理状态为pending或failed的媒体
|
||
let mediaToUpload = self.selectedMedia.filter { media in
|
||
guard let status = self.uploadStatus[media.id] else { return true }
|
||
return !status.isCompleted && !status.isUploading
|
||
}
|
||
|
||
// 清空之前的上传结果
|
||
self.uploadResults.removeAll()
|
||
|
||
for media in mediaToUpload {
|
||
self.uploadMedia(media)
|
||
}
|
||
}
|
||
|
||
/// 获取上传结果
|
||
public func getUploadResults() -> [String: UploadResult] {
|
||
return uploadResults
|
||
}
|
||
|
||
/// 检查是否所有上传都已完成
|
||
public var isAllUploaded: Bool {
|
||
guard !self.selectedMedia.isEmpty else { return false }
|
||
return self.selectedMedia.allSatisfy { media in
|
||
if case .completed = self.uploadStatus[media.id] { return true }
|
||
return false
|
||
}
|
||
}
|
||
|
||
// MARK: - Private Methods
|
||
|
||
/// 上传结果
|
||
public struct UploadResult: Codable, Equatable {
|
||
public let fileId: String
|
||
public let thumbnailId: String?
|
||
|
||
public init(fileId: String, thumbnailId: String? = nil) {
|
||
self.fileId = fileId
|
||
self.thumbnailId = thumbnailId
|
||
}
|
||
|
||
public static func == (lhs: UploadResult, rhs: UploadResult) -> Bool {
|
||
return lhs.fileId == rhs.fileId && lhs.thumbnailId == rhs.thumbnailId
|
||
}
|
||
}
|
||
|
||
private func uploadMedia(_ media: MediaType) {
|
||
logger.info("🔄 开始处理媒体: \(media.id)")
|
||
|
||
// 更新状态为上传中
|
||
updateStatus(for: media.id, status: .uploading(progress: 0))
|
||
|
||
// 转换媒体类型
|
||
let uploadMedia: ImageUploadService.MediaType
|
||
switch media {
|
||
case .image(let uiImage):
|
||
uploadMedia = .image(uiImage)
|
||
case .video(let url, let thumbnail):
|
||
uploadMedia = .video(url as URL, thumbnail)
|
||
}
|
||
|
||
// 上传媒体文件
|
||
uploader.uploadMedia(uploadMedia,
|
||
progress: { progress in
|
||
// 更新上传进度
|
||
Task { @MainActor in
|
||
self.updateStatus(for: media.id, status: .uploading(progress: progress.progress))
|
||
}
|
||
},
|
||
completion: { [weak self] result in
|
||
guard let self = self else { return }
|
||
|
||
Task { @MainActor in
|
||
switch result {
|
||
case .success(let uploadResult):
|
||
// 处理上传结果
|
||
let fileId: String
|
||
let thumbnailId: String?
|
||
|
||
switch uploadResult {
|
||
case .file(let result):
|
||
fileId = result.fileId
|
||
thumbnailId = nil
|
||
case .video(let video, let thumbnail):
|
||
fileId = video.fileId
|
||
thumbnailId = thumbnail?.fileId
|
||
}
|
||
|
||
// 保存上传结果
|
||
let result = UploadResult(fileId: fileId, thumbnailId: thumbnailId)
|
||
self.uploadResults[media.id] = result
|
||
self.logger.info("✅ 上传成功 (\(media.id)): \(fileId), 缩略图ID: \(thumbnailId ?? "无")")
|
||
self.updateStatus(for: media.id, status: .completed(fileId: fileId))
|
||
|
||
// 打印上传结果
|
||
if self.isAllUploaded {
|
||
self.printUploadResults()
|
||
}
|
||
|
||
case .failure(let error):
|
||
self.logger.error("❌ 上传失败 (\(media.id)): \(error.localizedDescription)")
|
||
self.updateStatus(for: media.id, status: .failed(error))
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
@MainActor
|
||
private func updateStatus(for mediaId: String, status: MediaUploadStatus) {
|
||
uploadStatus[mediaId] = status
|
||
}
|
||
|
||
// MARK: - Upload Results
|
||
|
||
/// 打印上传结果
|
||
private func printUploadResults() {
|
||
let results = self.selectedMedia.compactMap { media -> [String: String]? in
|
||
guard let result = self.uploadResults[media.id] else { return nil }
|
||
return [
|
||
"file_id": result.fileId,
|
||
"preview_file_id": result.thumbnailId ?? result.fileId
|
||
]
|
||
}
|
||
|
||
do {
|
||
let jsonData = try JSONSerialization.data(withJSONObject: results, options: [.prettyPrinted, .sortedKeys])
|
||
if let jsonString = String(data: jsonData, encoding: .utf8) {
|
||
print("📦 上传完成,文件ID列表:")
|
||
print(jsonString)
|
||
}
|
||
} catch {
|
||
print("❌ 无法序列化上传结果: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Preview Helper
|
||
|
||
/// 示例视图,展示如何使用 MediaUploadManager
|
||
struct MediaUploadExample: View {
|
||
@StateObject private var uploadManager = MediaUploadManager()
|
||
@State private var showMediaPicker = false
|
||
|
||
let imageSelectionLimit: Int
|
||
let videoSelectionLimit: Int
|
||
|
||
init(imageSelectionLimit: Int = 10, videoSelectionLimit: Int = 10) {
|
||
self.imageSelectionLimit = imageSelectionLimit
|
||
self.videoSelectionLimit = videoSelectionLimit
|
||
}
|
||
|
||
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)
|
||
|
||
// 显示选择限制信息
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("选择限制:")
|
||
.font(.subheadline)
|
||
.foregroundColor(.secondary)
|
||
Text("• 最多选择 \(imageSelectionLimit) 张图片")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
Text("• 最多选择 \(videoSelectionLimit) 个视频")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(.horizontal)
|
||
|
||
// 显示已选媒体
|
||
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) {
|
||
MediaPicker(
|
||
selectedMedia: Binding(
|
||
get: { self.uploadManager.selectedMedia },
|
||
set: { newMedia in
|
||
Task { @MainActor in
|
||
self.uploadManager.clearAllMedia()
|
||
self.uploadManager.addMedia(newMedia)
|
||
}
|
||
}
|
||
),
|
||
imageSelectionLimit: imageSelectionLimit,
|
||
videoSelectionLimit: videoSelectionLimit,
|
||
onDismiss: { showMediaPicker = false }
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 媒体选择视图组件
|
||
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(uploadManager.selectedMedia, id: \.id) { media in
|
||
let status = uploadManager.uploadStatus[media.id] ?? .pending
|
||
|
||
HStack {
|
||
// 缩略图
|
||
MediaThumbnailView(media: media, onDelete: {
|
||
uploadManager.removeMedia(id: media.id)
|
||
})
|
||
.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)
|
||
}
|
||
}
|
||
|
||
private func statusColor(_ status: MediaUploadStatus) -> 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: 60, height: 60)
|
||
.cornerRadius(8)
|
||
.clipped()
|
||
|
||
if media.isVideo {
|
||
Image(systemName: "video.fill")
|
||
.foregroundColor(.white)
|
||
.font(.system(size: 12))
|
||
.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(.white)
|
||
.font(.system(size: 16))
|
||
.background(Color.black.opacity(0.8))
|
||
.clipShape(Circle())
|
||
}
|
||
.padding(4)
|
||
}
|
||
} else {
|
||
Color.gray.opacity(0.3)
|
||
.frame(width: 60, height: 60)
|
||
.cornerRadius(8)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
// 在预览中显示自定义限制
|
||
MediaUploadExample(
|
||
imageSelectionLimit: 5,
|
||
videoSelectionLimit: 2
|
||
)
|
||
.environmentObject(AuthState.shared)
|
||
} |