feat: 第二个盲盒流程
This commit is contained in:
parent
3dc301d6c7
commit
9c9404785f
@ -59,6 +59,28 @@ class BlindBoxApi {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 使用 async/await 生成盲盒
|
||||||
|
/// - Parameters:
|
||||||
|
/// - boxType: 盲盒类型 (如 "First")
|
||||||
|
/// - materialIds: 素材ID数组
|
||||||
|
/// - Returns: 盲盒数据
|
||||||
|
@available(iOS 13.0, *)
|
||||||
|
func generateBlindBox(boxType: String, materialIds: [String]) async throws -> BlindBoxData? {
|
||||||
|
let parameters: [String: Any] = [
|
||||||
|
"box_type": boxType,
|
||||||
|
"material_ids": materialIds
|
||||||
|
]
|
||||||
|
let response: GenerateBlindBoxResponse = try await NetworkService.shared.postWithToken(
|
||||||
|
path: "/blind_box/generate",
|
||||||
|
parameters: parameters
|
||||||
|
)
|
||||||
|
if response.code == 0 {
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw NetworkError.serverError("服务器返回错误码: \(response.code)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取盲盒信息
|
/// 获取盲盒信息
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - boxId: 盲盒ID
|
/// - boxId: 盲盒ID
|
||||||
|
|||||||
@ -63,4 +63,50 @@ class MaterialUpload {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 使用 async/await 方式添加素材到服务器
|
||||||
|
/// - Parameters:
|
||||||
|
/// - fileId: 文件ID
|
||||||
|
/// - previewFileId: 预览文件ID
|
||||||
|
/// - Returns: 结果ID数组(可为空)
|
||||||
|
/// - Throws: NetworkError 或其他错误
|
||||||
|
func addMaterial(
|
||||||
|
fileId: String,
|
||||||
|
previewFileId: String
|
||||||
|
) async throws -> [String]? {
|
||||||
|
// 创建请求数据(数组结构,与现有接口保持一致)
|
||||||
|
let materials: [[String: String]] = [[
|
||||||
|
"file_id": fileId,
|
||||||
|
"preview_file_id": previewFileId
|
||||||
|
]]
|
||||||
|
|
||||||
|
// 调试信息
|
||||||
|
print("🔍(async) 准备发送的参数: \(materials)")
|
||||||
|
|
||||||
|
// 直接使用 async/await 版本的 post
|
||||||
|
let response: AddMaterialResponse = try await NetworkService.shared.post(
|
||||||
|
path: "/material",
|
||||||
|
parameters: materials
|
||||||
|
)
|
||||||
|
|
||||||
|
// 按业务约定检查 code
|
||||||
|
if response.code == 0 {
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw NetworkError.serverError("服务器返回错误码: \(response.code)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addMaterials(files: [[String: String]]) async throws -> [String]? {
|
||||||
|
let response: AddMaterialResponse = try await NetworkService.shared.post(
|
||||||
|
path: "/material",
|
||||||
|
parameters: files
|
||||||
|
)
|
||||||
|
if response.code == 0 {
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw NetworkError.serverError("服务器返回错误码: \(response.code)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,6 +143,42 @@ extension NetworkService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 使用 async/await 的 POST 请求(支持数组或字典参数)
|
||||||
|
public func post<T: Decodable>(
|
||||||
|
path: String,
|
||||||
|
parameters: Any? = nil,
|
||||||
|
headers: [String: String]? = nil
|
||||||
|
) async throws -> T {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
post(path: path, parameters: parameters, headers: headers) { (result: Result<T, NetworkError>) in
|
||||||
|
switch result {
|
||||||
|
case .success(let value):
|
||||||
|
continuation.resume(returning: value)
|
||||||
|
case .failure(let error):
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 使用 async/await 的 POST 请求(带Token,支持数组或字典参数)
|
||||||
|
public func postWithToken<T: Decodable>(
|
||||||
|
path: String,
|
||||||
|
parameters: Any? = nil,
|
||||||
|
headers: [String: String]? = nil
|
||||||
|
) async throws -> T {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
postWithToken(path: path, parameters: parameters, headers: headers) { (result: Result<T, NetworkError>) in
|
||||||
|
switch result {
|
||||||
|
case .success(let value):
|
||||||
|
continuation.resume(returning: value)
|
||||||
|
case .failure(let error):
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum NetworkError: Error {
|
public enum NetworkError: Error {
|
||||||
|
|||||||
722
wake/View/OnBoarding/MediaUploadView.swift
Normal file
722
wake/View/OnBoarding/MediaUploadView.swift
Normal file
@ -0,0 +1,722 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
import AVKit
|
||||||
|
import CoreTransferable
|
||||||
|
import CoreImage.CIFilterBuiltins
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let didAddFirstMedia = Notification.Name("didAddFirstMedia")
|
||||||
|
}
|
||||||
|
/// 主上传视图
|
||||||
|
/// 提供媒体选择、预览和上传功能
|
||||||
|
@MainActor
|
||||||
|
struct MediaUploadView: View {
|
||||||
|
// MARK: - 属性
|
||||||
|
|
||||||
|
/// 上传管理器,负责处理上传逻辑
|
||||||
|
@StateObject private var uploadManager = MediaUploadManager()
|
||||||
|
/// 控制媒体选择器的显示/隐藏
|
||||||
|
@State private var showMediaPicker = false
|
||||||
|
/// 当前选中的媒体项
|
||||||
|
@State private var selectedMedia: MediaType? = nil
|
||||||
|
/// 当前选中的媒体索引集合
|
||||||
|
@State private var selectedIndices: Set<Int> = []
|
||||||
|
@State private var mediaPickerSelection: [MediaType] = [] // 添加这个状态变量
|
||||||
|
/// 上传完成状态
|
||||||
|
@State private var uploadComplete = false
|
||||||
|
/// 上传完成的文件ID列表
|
||||||
|
@State private var uploadedFileIds: [[String: String]] = []
|
||||||
|
|
||||||
|
// MARK: - 视图主体
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// 顶部导航栏
|
||||||
|
topNavigationBar
|
||||||
|
|
||||||
|
// 上传提示信息
|
||||||
|
uploadHintView
|
||||||
|
Spacer()
|
||||||
|
.frame(height: uploadManager.selectedMedia.isEmpty ? 80 : 40)
|
||||||
|
// 主上传区域
|
||||||
|
MainUploadArea(
|
||||||
|
uploadManager: uploadManager,
|
||||||
|
showMediaPicker: $showMediaPicker,
|
||||||
|
selectedMedia: $selectedMedia
|
||||||
|
)
|
||||||
|
.id("mainUploadArea\(uploadManager.selectedMedia.count)")
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// // 上传结果展示
|
||||||
|
// if uploadComplete && !uploadedFileIds.isEmpty {
|
||||||
|
// VStack(alignment: .leading) {
|
||||||
|
// Text("上传完成!")
|
||||||
|
// .font(.headline)
|
||||||
|
|
||||||
|
// ScrollView {
|
||||||
|
// ForEach(Array(uploadedFileIds.enumerated()), id: \.offset) { index, fileInfo in
|
||||||
|
// VStack(alignment: .leading) {
|
||||||
|
// Text("文件 \(index + 1):")
|
||||||
|
// .font(.subheadline)
|
||||||
|
// Text("ID: \(fileInfo["file_id"] ?? "")")
|
||||||
|
// .font(.caption)
|
||||||
|
// .foregroundColor(.gray)
|
||||||
|
// }
|
||||||
|
// .padding()
|
||||||
|
// .frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
// .background(Color.gray.opacity(0.1))
|
||||||
|
// .cornerRadius(8)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// .frame(height: 200)
|
||||||
|
// }
|
||||||
|
// .padding()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 继续按钮
|
||||||
|
continueButton
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
.background(Color.themeTextWhiteSecondary)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.sheet(isPresented: $showMediaPicker) {
|
||||||
|
// 媒体选择器
|
||||||
|
mediaPickerView
|
||||||
|
}
|
||||||
|
.onChange(of: uploadManager.uploadResults) { newResults in
|
||||||
|
handleUploadCompletion(results: newResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 子视图
|
||||||
|
|
||||||
|
/// 顶部导航栏
|
||||||
|
private var topNavigationBar: some View {
|
||||||
|
HStack {
|
||||||
|
// 返回按钮
|
||||||
|
Button(action: { Router.shared.pop() }) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundColor(.themeTextMessageMain)
|
||||||
|
}
|
||||||
|
.padding(.leading, 16)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
Text("Complete Your Profile")
|
||||||
|
.font(Typography.font(for: .title2, family: .quicksandBold))
|
||||||
|
.foregroundColor(.themeTextMessageMain)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// 右侧占位视图(保持布局平衡)
|
||||||
|
Color.clear
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.padding(.trailing, 16)
|
||||||
|
}
|
||||||
|
.background(Color.themeTextWhiteSecondary)
|
||||||
|
// .padding(.horizontal)
|
||||||
|
.zIndex(1) // 确保导航栏显示在最上层
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传提示视图
|
||||||
|
private var uploadHintView: some View {
|
||||||
|
HStack (spacing: 6) {
|
||||||
|
SVGImage(svgName: "Tips")
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.padding(.leading,6)
|
||||||
|
Text("The upload process will take approximately 2 minutes. Thank you for your patience.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(3)
|
||||||
|
}
|
||||||
|
.background(
|
||||||
|
Color.themeTextWhite
|
||||||
|
.cornerRadius(6)
|
||||||
|
)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 继续按钮
|
||||||
|
private var continueButton: some View {
|
||||||
|
Button(action: handleContinue) {
|
||||||
|
Text("Continue")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(uploadManager.selectedMedia.isEmpty ? Color.themeTextMessage : Color.themeTextMessageMain)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 56)
|
||||||
|
.background(uploadManager.selectedMedia.isEmpty ? Color.white : Color.themePrimary)
|
||||||
|
.cornerRadius(28)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.disabled(uploadManager.selectedMedia.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 媒体选择器视图
|
||||||
|
private var mediaPickerView: some View {
|
||||||
|
MediaPicker(
|
||||||
|
selectedMedia: Binding(
|
||||||
|
get: { mediaPickerSelection },
|
||||||
|
set: { newSelections in
|
||||||
|
print("🔄 开始处理用户选择的媒体文件")
|
||||||
|
print("📌 新选择的媒体数量: \(newSelections.count)")
|
||||||
|
|
||||||
|
// 1. 去重处理:过滤掉已经存在的媒体项
|
||||||
|
var uniqueNewMedia: [MediaType] = []
|
||||||
|
|
||||||
|
for newItem in newSelections {
|
||||||
|
let isDuplicate = uploadManager.selectedMedia.contains { existingItem in
|
||||||
|
switch (existingItem, newItem) {
|
||||||
|
case (.image(let existingImage), .image(let newImage)):
|
||||||
|
return existingImage.pngData() == newImage.pngData()
|
||||||
|
case (.video(let existingURL, _), .video(let newURL, _)):
|
||||||
|
return existingURL == newURL
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isDuplicate {
|
||||||
|
uniqueNewMedia.append(newItem)
|
||||||
|
} else {
|
||||||
|
print("⚠️ 检测到重复文件,已跳过: \(newItem)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 添加新文件
|
||||||
|
if !uniqueNewMedia.isEmpty {
|
||||||
|
print("✅ 添加 \(uniqueNewMedia.count) 个新文件")
|
||||||
|
uploadManager.addMedia(uniqueNewMedia)
|
||||||
|
|
||||||
|
// 如果没有当前选中的媒体,则选择第一个新添加的
|
||||||
|
if selectedMedia == nil, let firstNewItem = uniqueNewMedia.first {
|
||||||
|
selectedMedia = firstNewItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始上传
|
||||||
|
uploadManager.startUpload()
|
||||||
|
} else {
|
||||||
|
print("ℹ️ 没有新文件需要添加,所有选择的文件都已存在")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
imageSelectionLimit: max(0, 20 - uploadManager.selectedMedia.filter {
|
||||||
|
if case .image = $0 { return true }
|
||||||
|
return false
|
||||||
|
}.count),
|
||||||
|
videoSelectionLimit: max(0, 5 - uploadManager.selectedMedia.filter {
|
||||||
|
if case .video = $0 { return true }
|
||||||
|
return false
|
||||||
|
}.count),
|
||||||
|
selectionMode: .multiple,
|
||||||
|
onDismiss: handleMediaPickerDismiss,
|
||||||
|
onUploadProgress: { index, progress in
|
||||||
|
print("文件 \(index) 上传进度: \(progress * 100)%")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onAppear {
|
||||||
|
// 重置选择状态当选择器出现时
|
||||||
|
mediaPickerSelection = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 私有方法
|
||||||
|
|
||||||
|
/// 处理媒体选择器关闭事件
|
||||||
|
private func handleMediaPickerDismiss() {
|
||||||
|
showMediaPicker = false
|
||||||
|
print("媒体选择器关闭 - 开始处理")
|
||||||
|
|
||||||
|
// 如果有选中的媒体,开始上传
|
||||||
|
if !uploadManager.selectedMedia.isEmpty {
|
||||||
|
// 不需要在这里开始上传,因为handleMediaChange会处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理媒体变化
|
||||||
|
/// - Parameters:
|
||||||
|
/// - newMedia: 新的媒体数组
|
||||||
|
/// - oldMedia: 旧的媒体数组
|
||||||
|
private func handleMediaChange(_ newMedia: [MediaType], oldMedia: [MediaType]) {
|
||||||
|
print("开始处理媒体变化,新数量: \(newMedia.count), 原数量: \(oldMedia.count)")
|
||||||
|
|
||||||
|
// 如果没有变化,直接返回
|
||||||
|
guard newMedia != oldMedia else {
|
||||||
|
print("媒体未发生变化,跳过处理")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在后台线程处理媒体变化
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||||
|
// 找出新增的媒体(在newMedia中但不在oldMedia中的项)
|
||||||
|
let newItems = newMedia.filter { newItem in
|
||||||
|
!oldMedia.contains { $0.id == newItem.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
print("检测到\(newItems.count)个新增媒体项")
|
||||||
|
|
||||||
|
// 如果有新增媒体
|
||||||
|
if !newItems.isEmpty {
|
||||||
|
print("准备添加\(newItems.count)个新项...")
|
||||||
|
|
||||||
|
// 在主线程更新UI
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
// 创建新的数组,包含原有媒体和新媒体
|
||||||
|
var updatedMedia = uploadManager.selectedMedia
|
||||||
|
updatedMedia.append(contentsOf: newItems)
|
||||||
|
|
||||||
|
// 更新选中的媒体
|
||||||
|
uploadManager.clearAllMedia()
|
||||||
|
uploadManager.addMedia(updatedMedia)
|
||||||
|
|
||||||
|
// 如果当前没有选中的媒体,则选中第一个新增的媒体
|
||||||
|
if selectedIndices.isEmpty && !newItems.isEmpty {
|
||||||
|
selectedIndices = [oldMedia.count] // 选择第一个新增项的索引
|
||||||
|
selectedMedia = newItems.first
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始上传新添加的媒体
|
||||||
|
uploadManager.startUpload()
|
||||||
|
print("媒体添加完成,总数量: \(uploadManager.selectedMedia.count)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否有正在上传的文件
|
||||||
|
/// - Returns: 是否正在上传
|
||||||
|
private func isUploading() -> Bool {
|
||||||
|
return uploadManager.uploadStatus.values.contains { status in
|
||||||
|
if case .uploading = status { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理上传完成
|
||||||
|
private func handleUploadCompletion(results: [String: MediaUploadManager.UploadResult]) {
|
||||||
|
// 转换为需要的格式
|
||||||
|
let formattedResults = results.map { (_, result) -> [String: String] in
|
||||||
|
return [
|
||||||
|
"file_id": result.fileId,
|
||||||
|
"preview_file_id": result.thumbnailId ?? result.fileId
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedFileIds = formattedResults
|
||||||
|
uploadComplete = !uploadedFileIds.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理继续按钮点击
|
||||||
|
private func handleContinue() {
|
||||||
|
// 获取所有已上传文件的结果
|
||||||
|
let uploadResults = uploadManager.uploadResults
|
||||||
|
guard !uploadResults.isEmpty else {
|
||||||
|
print("⚠️ 没有可用的文件ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备请求参数
|
||||||
|
let files = uploadResults.map { (_, result) -> [String: String] in
|
||||||
|
return [
|
||||||
|
"file_id": result.fileId,
|
||||||
|
"preview_file_id": result.thumbnailId ?? result.fileId
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交素材,并利用返回的素材id数组,创建第二个盲盒
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let materialIds = try await MaterialUpload.shared.addMaterials(files: files)
|
||||||
|
print("🚀 素材ID: \(materialIds ?? [])")
|
||||||
|
// 创建盲盒
|
||||||
|
if let materialIds = materialIds {
|
||||||
|
let result = try await BlindBoxApi.shared.generateBlindBox(boxType: "Second", materialIds: materialIds)
|
||||||
|
print("🎉 盲盒结果: \(result ?? nil)")
|
||||||
|
if let result = result {
|
||||||
|
let blindBoxId = result.id ?? ""
|
||||||
|
print("🎉 盲盒ID: \(blindBoxId)")
|
||||||
|
// 导航到盲盒首页等待盲盒开启
|
||||||
|
Router.shared.navigate(to: .blindBox(mediaType: .video, blindBoxId: blindBoxId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("❌ 添加素材失败: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 主上传区域
|
||||||
|
|
||||||
|
/// 主上传区域视图
|
||||||
|
/// 显示上传提示、媒体预览和添加更多按钮
|
||||||
|
struct MainUploadArea: View {
|
||||||
|
// MARK: - 属性
|
||||||
|
|
||||||
|
/// 上传管理器
|
||||||
|
@ObservedObject var uploadManager: MediaUploadManager
|
||||||
|
/// 控制媒体选择器的显示/隐藏
|
||||||
|
@Binding var showMediaPicker: Bool
|
||||||
|
/// 当前选中的媒体
|
||||||
|
@Binding var selectedMedia: MediaType?
|
||||||
|
|
||||||
|
// MARK: - 视图主体
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack() {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 30)
|
||||||
|
// 标题
|
||||||
|
Text("Click to upload 5+ videos to generate your next blind box.")
|
||||||
|
.font(Typography.font(for: .title2, family: .quicksandBold))
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 50)
|
||||||
|
// 主显示区域
|
||||||
|
if let mediaToDisplay = selectedMedia ?? uploadManager.selectedMedia.first {
|
||||||
|
Button(action: { showMediaPicker = true }) {
|
||||||
|
MediaPreview(media: mediaToDisplay)
|
||||||
|
.id(mediaToDisplay.id)
|
||||||
|
.frame(width: 225, height: 225)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.stroke(Color.themePrimary, lineWidth: 5)
|
||||||
|
)
|
||||||
|
.cornerRadius(16)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
UploadPromptView(showMediaPicker: $showMediaPicker)
|
||||||
|
}
|
||||||
|
// 媒体预览区域
|
||||||
|
mediaPreviewSection
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 10)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
print("MainUploadArea appeared")
|
||||||
|
print("Selected media count: \(uploadManager.selectedMedia.count)")
|
||||||
|
|
||||||
|
if selectedMedia == nil, let firstMedia = uploadManager.selectedMedia.first {
|
||||||
|
print("Selecting first media: \(firstMedia.id)")
|
||||||
|
selectedMedia = firstMedia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .didAddFirstMedia)) { notification in
|
||||||
|
if let media = notification.userInfo?["media"] as? MediaType, selectedMedia == nil {
|
||||||
|
selectedMedia = media
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.white)
|
||||||
|
.cornerRadius(18)
|
||||||
|
.animation(.default, value: selectedMedia?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 子视图
|
||||||
|
|
||||||
|
/// 媒体预览区域
|
||||||
|
private var mediaPreviewSection: some View {
|
||||||
|
Group {
|
||||||
|
if !uploadManager.selectedMedia.isEmpty {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
// 横向滚动的缩略图列表
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(spacing: 10) {
|
||||||
|
ForEach(Array(uploadManager.selectedMedia.enumerated()), id: \.element.id) { index, media in
|
||||||
|
mediaItemView(for: media, at: index)
|
||||||
|
}
|
||||||
|
// 当没有选择媒体时显示添加更多按钮
|
||||||
|
if !uploadManager.selectedMedia.isEmpty {
|
||||||
|
addMoreButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.frame(height: 70)
|
||||||
|
}
|
||||||
|
.padding(.top, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个媒体项视图
|
||||||
|
/// - Parameters:
|
||||||
|
/// - media: 媒体项
|
||||||
|
/// - index: 索引
|
||||||
|
/// - Returns: 媒体项视图
|
||||||
|
private func mediaItemView(for media: MediaType, at index: Int) -> some View {
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
// 媒体预览 - 始终使用本地资源
|
||||||
|
MediaPreview(media: media)
|
||||||
|
.frame(width: 58, height: 58)
|
||||||
|
.cornerRadius(8)
|
||||||
|
.shadow(radius: 1)
|
||||||
|
.overlay(
|
||||||
|
// 左上角序号
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
Path { path in
|
||||||
|
let radius: CGFloat = 4
|
||||||
|
let width: CGFloat = 14
|
||||||
|
let height: CGFloat = 10
|
||||||
|
|
||||||
|
// 从左上角开始(带圆角)
|
||||||
|
path.move(to: CGPoint(x: 0, y: radius))
|
||||||
|
path.addQuadCurve(to: CGPoint(x: radius, y: 0),
|
||||||
|
control: CGPoint(x: 0, y: 0))
|
||||||
|
|
||||||
|
// 上边缘(右上角保持直角)
|
||||||
|
path.addLine(to: CGPoint(x: width, y: 0))
|
||||||
|
|
||||||
|
// 右边缘(右下角保持直角)
|
||||||
|
path.addLine(to: CGPoint(x: width, y: height - radius))
|
||||||
|
|
||||||
|
// 右下角圆角
|
||||||
|
path.addQuadCurve(to: CGPoint(x: width - radius, y: height),
|
||||||
|
control: CGPoint(x: width, y: height))
|
||||||
|
|
||||||
|
// 下边缘(左下角保持直角)
|
||||||
|
path.addLine(to: CGPoint(x: 0, y: height))
|
||||||
|
|
||||||
|
// 闭合路径
|
||||||
|
path.closeSubpath()
|
||||||
|
}
|
||||||
|
.fill(Color(hex: "BEBEBE").opacity(0.6))
|
||||||
|
.frame(width: 14, height: 10)
|
||||||
|
.overlay(
|
||||||
|
Text("\(index + 1)")
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.frame(width: 14, height: 10)
|
||||||
|
.offset(y: -1),
|
||||||
|
alignment: .topLeading
|
||||||
|
)
|
||||||
|
.padding([.top, .leading], 2)
|
||||||
|
|
||||||
|
// 右下角视频时长
|
||||||
|
if case .video(let url, _) = media, let videoURL = url as? URL {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text(getVideoDuration(url: videoURL))
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.frame(height: 10)
|
||||||
|
.background(Color(hex: "BEBEBE").opacity(0.6))
|
||||||
|
.cornerRadius(2)
|
||||||
|
}
|
||||||
|
.padding([.trailing, .bottom], 0)
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
// 占位
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text("占位")
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.frame(height: 10)
|
||||||
|
.background(Color(hex: "BEBEBE").opacity(0.6))
|
||||||
|
.cornerRadius(2)
|
||||||
|
}
|
||||||
|
.padding([.trailing, .bottom], 0)
|
||||||
|
}
|
||||||
|
.opacity(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
alignment: .topLeading
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
print("点击了媒体项,索引: \(index)")
|
||||||
|
withAnimation {
|
||||||
|
selectedMedia = media
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
|
||||||
|
// 右上角关闭按钮
|
||||||
|
Button(action: {
|
||||||
|
uploadManager.removeMedia(id: media.id)
|
||||||
|
if selectedMedia == media {
|
||||||
|
selectedMedia = nil
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: "BEBEBE").opacity(0.6))
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.offset(x: 6, y: -6)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加更多按钮
|
||||||
|
private var addMoreButton: some View {
|
||||||
|
Button(action: { showMediaPicker = true }) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
.frame(width: 58, height: 58)
|
||||||
|
.background(Color.white)
|
||||||
|
.cornerRadius(8)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.strokeBorder(style: StrokeStyle(
|
||||||
|
lineWidth: 2,
|
||||||
|
dash: [4, 4]
|
||||||
|
))
|
||||||
|
.foregroundColor(Color.themePrimary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 上传提示视图
|
||||||
|
|
||||||
|
/// 上传提示视图
|
||||||
|
/// 显示上传区域的占位图和提示
|
||||||
|
struct UploadPromptView: View {
|
||||||
|
/// 控制媒体选择器的显示/隐藏
|
||||||
|
@Binding var showMediaPicker: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: { showMediaPicker = true }) {
|
||||||
|
// 上传图标
|
||||||
|
SVGImageHtml(svgName: "IP")
|
||||||
|
.frame(width: 225, height: 225)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.overlay(
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.stroke(style: StrokeStyle(
|
||||||
|
lineWidth: 5,
|
||||||
|
lineCap: .round,
|
||||||
|
dash: [12, 8]
|
||||||
|
))
|
||||||
|
.foregroundColor(Color.themePrimary)
|
||||||
|
|
||||||
|
// Add plus icon in the center
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 32, weight: .bold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 媒体预览视图
|
||||||
|
|
||||||
|
/// 媒体预览视图
|
||||||
|
/// 显示图片或视频的预览图,始终使用本地资源
|
||||||
|
struct MediaPreview: View {
|
||||||
|
// MARK: - 属性
|
||||||
|
|
||||||
|
/// 媒体类型
|
||||||
|
let media: MediaType
|
||||||
|
|
||||||
|
// MARK: - 计算属性
|
||||||
|
|
||||||
|
/// 获取要显示的图片
|
||||||
|
private var displayImage: UIImage? {
|
||||||
|
switch media {
|
||||||
|
case .image(let uiImage):
|
||||||
|
return uiImage
|
||||||
|
case .video(_, let thumbnail):
|
||||||
|
return thumbnail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 视图主体
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// 1. 显示图片或视频缩略图
|
||||||
|
if let image = displayImage {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.transition(.opacity.animation(.easeInOut(duration: 0.2)))
|
||||||
|
} else {
|
||||||
|
// 2. 加载中的占位图
|
||||||
|
Color.gray.opacity(0.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aspectRatio(1, contentMode: .fill)
|
||||||
|
.clipped()
|
||||||
|
.cornerRadius(8)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(Color.themePrimary.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getVideoDuration(url: URL) -> String {
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let durationInSeconds = CMTimeGetSeconds(asset.duration)
|
||||||
|
guard durationInSeconds.isFinite else { return "0:00" }
|
||||||
|
|
||||||
|
let minutes = Int(durationInSeconds) / 60
|
||||||
|
let seconds = Int(durationInSeconds) % 60
|
||||||
|
return String(format: "%d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Response Types
|
||||||
|
|
||||||
|
private struct EmptyResponse: Decodable {
|
||||||
|
// Empty response type for endpoints that don't return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 扩展
|
||||||
|
|
||||||
|
/// 扩展 MediaType 以支持 Identifiable 协议
|
||||||
|
extension MediaType: Identifiable {
|
||||||
|
/// 唯一标识符
|
||||||
|
public var id: String {
|
||||||
|
switch self {
|
||||||
|
case .image(let uiImage):
|
||||||
|
return "image_\(uiImage.hashValue)"
|
||||||
|
case .video(let url, _):
|
||||||
|
return "video_\(url.absoluteString.hashValue)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimeInterval {
|
||||||
|
var formattedDuration: String {
|
||||||
|
let minutes = Int(self) / 60
|
||||||
|
let seconds = Int(self) % 60
|
||||||
|
return String(format: "%d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 预览
|
||||||
|
|
||||||
|
struct MediaUploadView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
NavigationView {
|
||||||
|
MediaUploadView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user