Compare commits

..

No commits in common. "3dc301d6c77edc1b40e9fd9b4230e6e6ff5dcc92" and "e9cdb82b70114624e3b11dca7394b5dca5abe49c" have entirely different histories.

10 changed files with 957 additions and 396 deletions

View File

@ -1,5 +1,4 @@
{ {
"lldb.library": "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/LLDB.framework/Versions/A/LLDB", "lldb.library": "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/LLDB.framework/Versions/A/LLDB",
"lldb.launch.expressions": "native", "lldb.launch.expressions": "native"
"sweetpad.build.xcodeWorkspacePath": "wake.xcodeproj/project.xcworkspace"
} }

View File

@ -68,37 +68,19 @@ struct BlindCount: Codable {
// MARK: - Blind Box Data // MARK: - Blind Box Data
struct BlindBoxData: Codable { struct BlindBoxData: Codable {
let id: String let id: Int64
let boxCode: String let boxCode: String
let userId: String let userId: Int64
let name: String let name: String
let boxType: String let boxType: String
let features: String? let features: String?
let resultFile: FileInfo? let url: String?
let status: String let status: String
let workflowInstanceId: String? let workflowInstanceId: String?
//
let videoGenerateTime: String? let videoGenerateTime: String?
let createTime: String let createTime: String
let coverFile: FileInfo? let description: String?
let description: String
// Int64
var idValue: Int64 { Int64(id) ?? 0 }
var userIdValue: Int64 { Int64(userId) ?? 0 }
struct FileInfo: Codable {
let id: String
let fileName: String?
let url: String?
let metadata: [String: String]?
enum CodingKeys: String, CodingKey {
case id
case fileName = "file_name"
case url
case metadata
}
}
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
@ -107,68 +89,43 @@ struct BlindBoxData: Codable {
case name case name
case boxType = "box_type" case boxType = "box_type"
case features case features
case resultFile = "result_file" case url
case status case status
case workflowInstanceId = "workflow_instance_id" case workflowInstanceId = "workflow_instance_id"
case videoGenerateTime = "video_generate_time" case videoGenerateTime = "video_generate_time"
case createTime = "create_time" case createTime = "create_time"
case coverFile = "cover_file"
case description case description
} }
init(id: String, boxCode: String, userId: String, name: String, boxType: String, features: String?, resultFile: FileInfo?, status: String, workflowInstanceId: String?, videoGenerateTime: String?, createTime: String, coverFile: FileInfo?, description: String) { init(id: Int64, boxCode: String, userId: Int64, name: String, boxType: String, features: String?, url: String?, status: String, workflowInstanceId: String?, videoGenerateTime: String?, createTime: String, description: String?) {
self.id = id self.id = id
self.boxCode = boxCode self.boxCode = boxCode
self.userId = userId self.userId = userId
self.name = name self.name = name
self.boxType = boxType self.boxType = boxType
self.features = features self.features = features
self.resultFile = resultFile self.url = url
self.status = status self.status = status
self.workflowInstanceId = workflowInstanceId self.workflowInstanceId = workflowInstanceId
self.videoGenerateTime = videoGenerateTime self.videoGenerateTime = videoGenerateTime
self.createTime = createTime self.createTime = createTime
self.coverFile = coverFile
self.description = description self.description = description
} }
init(from listItem: BlindList) { init(from listItem: BlindList) {
self.id = listItem.id self.init(
self.boxCode = listItem.boxCode id: Int64(listItem.id) ?? 0,
self.userId = listItem.userId boxCode: listItem.boxCode,
self.name = listItem.name userId: Int64(listItem.userId) ?? 0,
self.boxType = listItem.boxType name: listItem.name,
self.features = listItem.features boxType: listItem.boxType,
features: listItem.features,
// FileInfo url: listItem.resultFile?.url,
if let resultFileInfo = listItem.resultFile { status: listItem.status,
self.resultFile = FileInfo( workflowInstanceId: listItem.workflowInstanceId,
id: resultFileInfo.id, videoGenerateTime: listItem.videoGenerateTime,
fileName: resultFileInfo.fileName, createTime: listItem.createTime,
url: resultFileInfo.url, description: listItem.description
metadata: resultFileInfo.metadata )
)
} else {
self.resultFile = nil
}
self.status = listItem.status
self.workflowInstanceId = listItem.workflowInstanceId
self.videoGenerateTime = listItem.videoGenerateTime
self.createTime = listItem.createTime
// coverFileFileInfo
if let coverFileInfo = listItem.coverFile {
self.coverFile = FileInfo(
id: coverFileInfo.id,
fileName: coverFileInfo.fileName,
url: coverFileInfo.url,
metadata: coverFileInfo.metadata
)
} else {
self.coverFile = nil
}
self.description = listItem.description ?? ""
} }
} }

View File

@ -14,7 +14,57 @@ import Foundation
// MARK: - Generate Blind Box Response Model // MARK: - Generate Blind Box Response Model
struct GenerateBlindBoxResponse: Codable { struct GenerateBlindBoxResponse: Codable {
let code: Int let code: Int
let data: BlindBoxData? let data: BlindBoxDataWrapper?
struct BlindBoxDataWrapper: Codable {
let id: String
let boxCode: String
let userId: String
let name: String
let boxType: String
let features: String?
let resultFile: FileInfo?
let status: String
let workflowInstanceId: String?
let videoGenerateTime: String?
let createTime: String
let coverFile: FileInfo?
let description: String
// Int64
var idValue: Int64 { Int64(id) ?? 0 }
var userIdValue: Int64 { Int64(userId) ?? 0 }
struct FileInfo: Codable {
let id: String
let fileName: String?
let url: String?
let metadata: [String: String]?
enum CodingKeys: String, CodingKey {
case id
case fileName = "file_name"
case url
case metadata
}
}
enum CodingKeys: String, CodingKey {
case id
case boxCode = "box_code"
case userId = "user_id"
case name
case boxType = "box_type"
case features
case resultFile = "result_file"
case status
case workflowInstanceId = "workflow_instance_id"
case videoGenerateTime = "video_generate_time"
case createTime = "create_time"
case coverFile = "cover_file"
case description
}
}
} }
// MARK: - Blind Box API Client // MARK: - Blind Box API Client
@ -31,7 +81,7 @@ class BlindBoxApi {
func generateBlindBox( func generateBlindBox(
boxType: String, boxType: String,
materialIds: [String], materialIds: [String],
completion: @escaping (Result<BlindBoxData?, Error>) -> Void completion: @escaping (Result<GenerateBlindBoxResponse.BlindBoxDataWrapper?, Error>) -> Void
) { ) {
// Codable // Codable
let parameters: [String: Any] = [ let parameters: [String: Any] = [
@ -58,48 +108,4 @@ class BlindBoxApi {
} }
) )
} }
///
/// - Parameters:
/// - boxId: ID
/// - completion:
func getBlindBox(
boxId: String,
completion: @escaping (Result<BlindBoxData?, Error>) -> Void
) {
let path = "/blind_box/query/\(boxId)"
NetworkService.shared.getWithToken(
path: path,
completion: { (result: Result<GenerateBlindBoxResponse, NetworkError>) in
DispatchQueue.main.async {
switch result {
case .success(let response):
if response.code == 0 {
completion(.success(response.data))
} else {
completion(.failure(NetworkError.serverError("服务器返回错误码: \(response.code)")))
}
case .failure(let error):
completion(.failure(error))
}
}
}
)
}
/// 使 async/await
/// - Parameter boxId: ID
/// - Returns:
@available(iOS 13.0, *)
func getBlindBox(boxId: String) async throws -> BlindBoxData? {
let path = "/blind_box/query/\(boxId)"
let response: GenerateBlindBoxResponse = try await NetworkService.shared.getWithToken(path: path)
if response.code == 0 {
return response.data
} else {
throw NetworkError.serverError("服务器返回错误码: \(response.code)")
}
}
} }

View File

@ -108,43 +108,6 @@ extension NetworkService: NetworkServiceProtocol {
} }
} }
// MARK: - Async/Await Extensions
extension NetworkService {
/// 使 async/await GET Token
public func getWithToken<T: Decodable>(
path: String,
parameters: [String: Any]? = nil
) async throws -> T {
return try await withCheckedThrowingContinuation { continuation in
getWithToken(path: path, parameters: parameters) { (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: [String: Any]
) async throws -> T {
return try await withCheckedThrowingContinuation { continuation in
postWithToken(path: path, parameters: parameters) { (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 {
case invalidURL case invalidURL
case noData case noData

View File

@ -7,7 +7,7 @@ enum AppRoute: Hashable {
case feedbackView case feedbackView
case feedbackDetail(type: FeedbackView.FeedbackType) case feedbackDetail(type: FeedbackView.FeedbackType)
case mediaUpload case mediaUpload
case blindBox(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) case blindBox(mediaType: BlindBoxMediaType)
case blindOutcome(media: MediaType, time: String? = nil, description: String? = nil) case blindOutcome(media: MediaType, time: String? = nil, description: String? = nil)
case memories case memories
case subscribe case subscribe
@ -31,8 +31,8 @@ enum AppRoute: Hashable {
FeedbackDetailView(feedbackType: type) FeedbackDetailView(feedbackType: type)
case .mediaUpload: case .mediaUpload:
MediaUploadView() MediaUploadView()
case .blindBox(let mediaType, let blindBoxId): case .blindBox(let mediaType):
BlindBoxView(mediaType: mediaType, blindBoxId: blindBoxId) BlindBoxView(mediaType: mediaType)
case .blindOutcome(let media, let time, let description): case .blindOutcome(let media, let time, let description):
BlindOutcomeView(media: media, time: time, description: description) BlindOutcomeView(media: media, time: time, description: description)
case .memories: case .memories:

View File

@ -1,7 +1,6 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import AVKit import AVKit
import Foundation
// //
extension Notification.Name { extension Notification.Name {
@ -70,8 +69,6 @@ struct AVPlayerController: UIViewControllerRepresentable {
struct BlindBoxView: View { struct BlindBoxView: View {
let mediaType: BlindBoxMediaType let mediaType: BlindBoxMediaType
let currentBoxId: String?
@State private var showModal = false // @State private var showModal = false //
@State private var showSettings = false // @State private var showSettings = false //
@State private var isMember = false // @State private var isMember = false //
@ -81,7 +78,7 @@ struct BlindBoxView: View {
@State private var blindCount: BlindCount? = nil @State private var blindCount: BlindCount? = nil
@State private var blindList: [BlindList] = [] // Changed to array @State private var blindList: [BlindList] = [] // Changed to array
// //
@State private var blindGenerate: BlindBoxData? @State private var blindGenerate : BlindBoxData?
@State private var showLottieAnimation = true @State private var showLottieAnimation = true
// //
@State private var isPolling = false @State private var isPolling = false
@ -109,9 +106,8 @@ struct BlindBoxView: View {
// - // -
@Query private var login: [Login] @Query private var login: [Login]
init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) { init(mediaType: BlindBoxMediaType) {
self.mediaType = mediaType self.mediaType = mediaType
self.currentBoxId = blindBoxId
} }
// //
@ -147,139 +143,88 @@ struct BlindBoxView: View {
} }
} }
private func loadBlindBox() async { private func loadMedia() {
print("loadMedia called with mediaType: \(mediaType)") print("loadMedia called with mediaType: \(mediaType)")
if self.currentBoxId != nil { switch mediaType {
print("指定监听某盲盒结果: ", self.currentBoxId! as Any) case .video:
// loadVideo()
await pollingToQuerySingleBox() currentBoxType = "Video"
} startPolling()
case .image:
loadImage()
currentBoxType = "Image"
startPolling()
case .all:
print("Loading all content...")
// First/Second
NetworkService.shared.get(
path: "/blind_boxs/query",
parameters: nil
) { (result: Result<APIResponse<[BlindList]>, NetworkError>) in
DispatchQueue.main.async {
switch result {
case .success(let response):
if response.data.count == 0 {
// -First
print("❌ 没有盲盒,跳转到新手引导-First盲盒页面")
// return
}
if response.data.count == 1 && response.data[0].boxType == "First" {
// -Second
print("❌ 只有First盲盒跳转到新手引导-Second盲盒页面")
// return
}
// switch mediaType { self.blindList = response.data ?? []
// case .video: // none
// loadVideo() if self.blindList.isEmpty {
// currentBoxType = "Video" self.animationPhase = .none
// startPolling() }
// case .image: print("✅ 成功获取 \(self.blindList.count) 个盲盒")
// loadImage() case .failure(let error):
// currentBoxType = "Image" self.blindList = []
// startPolling() self.animationPhase = .none
// case .all: print("❌ 获取盲盒列表失败:", error.localizedDescription)
// print("Loading all content...") }
// // First/Second
// // 使NetworkService.shared.getasync/await
// NetworkService.shared.get(
// path: "/blind_boxs/query",
// parameters: nil
// ) { (result: Result<APIResponse<[BlindList]>, NetworkError>) in
// DispatchQueue.main.async {
// switch result {
// case .success(let response):
// if response.data.count == 0 {
// // -First
// print(" -First")
// // return
// }
// if response.data.count == 1 && response.data[0].boxType == "First" {
// // -Second
// print(" First-Second")
// // return
// }
// self.blindList = response.data ?? []
// // none
// if self.blindList.isEmpty {
// self.animationPhase = .none
// }
// print(" \(self.blindList.count) ")
// case .failure(let error):
// self.blindList = []
// self.animationPhase = .none
// print(" :", error.localizedDescription)
// }
// }
// }
// // //
// // NetworkService.shared.get(
// // path: "/membership/personal-center-info",
// // parameters: nil
// // ) { (result: Result<MemberProfileResponse, NetworkError>) in
// // DispatchQueue.main.async {
// // switch result {
// // case .success(let response):
// // self.memberProfile = response.data
// // self.isMember = response.data.membershipLevel == "Pioneer"
// // self.memberDate = response.data.membershipEndAt ?? ""
// // print(" :", response.data)
// // print(" ID:", response.data.userInfo.userId)
// // case .failure(let error):
// // print(" :", error)
// // }
// // }
// // }
// // //
// // NetworkService.shared.get(
// // path: "/blind_box/available/quantity",
// // parameters: nil
// // ) { (result: Result<APIResponse<BlindCount>, NetworkError>) in
// // DispatchQueue.main.async {
// // switch result {
// // case .success(let response):
// // self.blindCount = response.data
// // print(" :", response.data)
// // case .failure(let error):
// // print(" :", error)
// // }
// // }
// // }
// }
}
private func pollingToQuerySingleBox() async {
stopPolling()
isPolling = true
// Unopened
while isPolling {
do {
let blindBoxData = try await BlindBoxApi.shared.getBlindBox(boxId: self.currentBoxId!)
// UI
if let data = blindBoxData {
self.blindGenerate = data
// URL
if mediaType == .video {
self.videoURL = data.resultFile?.url ?? ""
} else if mediaType == .image {
self.imageURL = data.resultFile?.url ?? ""
}
print("✅ 成功获取盲盒数据: \(data.name), 状态: \(data.status)")
// Unopened
if data.status == "Unopened" {
print("✅ 盲盒已准备就绪,停止轮询")
self.animationPhase = .ready
stopPolling()
break
} }
} }
// 2 //
try await Task.sleep(nanoseconds: 2_000_000_000) NetworkService.shared.get(
} catch { path: "/membership/personal-center-info",
print("❌ 获取盲盒数据失败: \(error)") parameters: nil
// ) { (result: Result<MemberProfileResponse, NetworkError>) in
self.animationPhase = .none DispatchQueue.main.async {
stopPolling() switch result {
break case .success(let response):
self.memberProfile = response.data
self.isMember = response.data.membershipLevel == "Pioneer"
self.memberDate = response.data.membershipEndAt ?? ""
print("✅ 成功获取会员信息:", response.data)
print("✅ 用户ID:", response.data.userInfo.userId)
case .failure(let error):
print("❌ 获取会员信息失败:", error)
}
}
}
//
NetworkService.shared.get(
path: "/blind_box/available/quantity",
parameters: nil
) { (result: Result<APIResponse<BlindCount>, NetworkError>) in
DispatchQueue.main.async {
switch result {
case .success(let response):
self.blindCount = response.data
print("✅ 成功获取盲盒数量:", response.data)
case .failure(let error):
print("❌ 获取数量失败:", error)
}
}
}
} }
}
} }
// //
private func startPolling() { private func startPolling() {
stopPolling() stopPolling()
@ -299,54 +244,52 @@ struct BlindBoxView: View {
return return
} }
// NetworkService.shared.postWithToken( NetworkService.shared.postWithToken(
// path: "/blind_box/generate/mock", path: "/blind_box/generate/mock",
// parameters: ["box_type": currentBoxType] parameters: ["box_type": currentBoxType]
// ) { (result: Result<GenerateBlindBoxResponse, NetworkError>) in ) { (result: Result<APIResponse<BlindBoxData>, NetworkError>) in
// DispatchQueue.main.async { DispatchQueue.main.async {
// switch result { switch result {
// case .success(let response): case .success(let response):
// let data = response.data let data = response.data
// self.blindGenerate = data self.blindGenerate = data
// print(": \(data?.status ?? "Unknown")") print("当前盲盒状态: \(data.status)")
// // //
// if self.mediaType == .all, let firstItem = self.blindList.first { if self.mediaType == .all, let firstItem = self.blindList.first {
// self.displayData = BlindBoxData(from: firstItem) self.displayData = BlindBoxData(from: firstItem)
// } else { } else {
// self.displayData = data self.displayData = data
// } }
//
// // //
// if let status = data?.status { NotificationCenter.default.post(
// NotificationCenter.default.post( name: .blindBoxStatusChanged,
// name: .blindBoxStatusChanged, object: nil,
// object: nil, userInfo: ["status": data.status]
// userInfo: ["status": status] )
// )
// } if data.status != "Preparing" {
// self.stopPolling()
// if data?.status != "Preparing" { print("✅ 盲盒准备就绪,状态: \(data.status)")
// self.stopPolling() if self.mediaType == .video {
// print(" : \(data?.status ?? "Unknown")") self.videoURL = data.url ?? ""
// if self.mediaType == .video { } else if self.mediaType == .image {
// self.videoURL = data?.resultFile?.url ?? "" self.imageURL = data.url ?? ""
// } else if self.mediaType == .image { }
// self.imageURL = data?.resultFile?.url ?? "" } else {
// } self.pollingTimer = Timer.scheduledTimer(
// } else { withTimeInterval: 2.0,
// self.pollingTimer = Timer.scheduledTimer( repeats: false
// withTimeInterval: 2.0, ) { _ in
// repeats: false self.checkBlindBoxStatus()
// ) { _ in }
// self.checkBlindBoxStatus() }
// } case .failure(let error):
// } print("❌ 获取盲盒状态失败: \(error.localizedDescription)")
// case .failure(let error): self.stopPolling()
// print(" : \(error.localizedDescription)") }
// self.stopPolling() }
// } }
// }
// }
} }
private func loadImage() { private func loadImage() {
@ -466,45 +409,40 @@ struct BlindBoxView: View {
.onAppear { .onAppear {
print("🎯 BlindBoxView appeared with mediaType: \(mediaType)") print("🎯 BlindBoxView appeared with mediaType: \(mediaType)")
print("🎯 Current thread: \(Thread.current)") print("🎯 Current thread: \(Thread.current)")
// //
// if mediaType == .all, let firstItem = blindList.first { if mediaType == .all, let firstItem = blindList.first {
// displayData = BlindBoxData(from: firstItem) displayData = BlindBoxData(from: firstItem)
// } else { } else {
// displayData = blindGenerate displayData = blindGenerate
// } }
// //
// NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
// forName: .blindBoxStatusChanged, forName: .blindBoxStatusChanged,
// object: nil, object: nil,
// queue: .main queue: .main
// ) { notification in ) { notification in
// if let status = notification.userInfo?["status"] as? String { if let status = notification.userInfo?["status"] as? String {
// switch status { switch status {
// case "Preparing": case "Preparing":
// withAnimation { withAnimation {
// self.animationPhase = .loading self.animationPhase = .loading
// } }
// case "Unopened": case "Unopened":
// withAnimation { withAnimation {
// self.animationPhase = .ready self.animationPhase = .ready
// } }
// default: default:
// // //
// withAnimation { withAnimation {
// self.animationPhase = .ready self.animationPhase = .ready
// } }
// break break
// } }
// } }
// }
//
Task {
await loadBlindBox()
} }
//
loadMedia()
} }
.onDisappear { .onDisappear {
stopPolling() stopPolling()
@ -559,9 +497,9 @@ struct BlindBoxView: View {
Button(action: { Button(action: {
// BlindOutcomeView // BlindOutcomeView
if mediaType == .video, !videoURL.isEmpty, let url = URL(string: videoURL) { if mediaType == .video, !videoURL.isEmpty, let url = URL(string: videoURL) {
Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: blindGenerate?.name ?? "Your box", description:blindGenerate?.description ?? "")) Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: blindGenerate?.videoGenerateTime ?? "hhsdshjsjdhn", description:blindGenerate?.description ?? "informationinformationinformationinformationinformationinformation"))
} else if mediaType == .image, let image = displayImage { } else if mediaType == .image, let image = displayImage {
Router.shared.navigate(to: .blindOutcome(media: .image(image), time: blindGenerate?.name ?? "Your box", description:blindGenerate?.description ?? "")) Router.shared.navigate(to: .blindOutcome(media: .image(image), time: blindGenerate?.videoGenerateTime ?? "hhsdshjsjdhn", description:blindGenerate?.description ?? "informationinformationinformationinformationinformationinformation"))
} }
}) { }) {
Image(systemName: "chevron.left") Image(systemName: "chevron.left")
@ -657,11 +595,10 @@ struct BlindBoxView: View {
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 20) .padding(.top, 20)
} }
// //
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Hi! Click And") Text("Hi! Click And")
Text("Open Your Box~") Text("Open Your First Box~")
} }
.font(Typography.font(for: .smallLargeTitle)) .font(Typography.font(for: .smallLargeTitle))
.fontWeight(.bold) .fontWeight(.bold)
@ -785,10 +722,10 @@ struct BlindBoxView: View {
if !showScalingOverlay && !showMedia { if !showScalingOverlay && !showMedia {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
// blindGeneratedescription // blindGeneratedescription
Text(blindGenerate?.name ?? "Some box") Text(blindGenerate?.videoGenerateTime ?? "hhsdshjsjdhn")
.font(Typography.font(for: .body, family: .quicksandBold)) .font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(Color.themeTextMessageMain) .foregroundColor(Color.themeTextMessageMain)
Text(blindGenerate?.description ?? "") Text(blindGenerate?.description ?? "informationinformationinformationinformationinformationinformation")
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(Color.themeTextMessageMain) .foregroundColor(Color.themeTextMessageMain)
} }
@ -806,8 +743,7 @@ struct BlindBoxView: View {
.animation(.easeOut(duration: 1.5), value: showScalingOverlay) .animation(.easeOut(duration: 1.5), value: showScalingOverlay)
.offset(y: showScalingOverlay ? -100 : 0) .offset(y: showScalingOverlay ? -100 : 0)
.animation(.easeInOut(duration: 1.5), value: showScalingOverlay) .animation(.easeInOut(duration: 1.5), value: showScalingOverlay)
//
// TODO
if mediaType == .all { if mediaType == .all {
Button(action: { Button(action: {
if animationPhase == .ready { if animationPhase == .ready {
@ -859,7 +795,6 @@ struct BlindBoxView: View {
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal) .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal)
.edgesIgnoringSafeArea(.all) .edgesIgnoringSafeArea(.all)
} }
// //
SlideInModal( SlideInModal(
isPresented: $showModal, isPresented: $showModal,
@ -941,21 +876,6 @@ struct BlindBoxView: View {
} }
} }
//
#Preview("First Blind Box") {
BlindBoxView(mediaType: .image, blindBoxId: "7370140297747107840")
.onAppear {
// Preview使
#if DEBUG
if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
// Preview
let previewToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJqdGkiOjczNzAwMTY5NzMzODE1NzA1NjAsImlkZW50aXR5IjoiNzM1MDQzOTY2MzExNjYxOTc3NyIsImV4cCI6MTc1Nzc1Mzc3NH0.tZ8p5sW4KX6HFoJpJN0e4VmJOAGhTrYD2yTwQwilKpufzqOAfXX4vpGYBurgBIcHj2KmXKX2PQMOeeAtvAypDA"
let _ = KeychainHelper.saveAccessToken(previewToken)
print("🔑 Preview token set for testing")
}
#endif
}
}
// struct TransparentVideoPlayer: UIViewRepresentable { // struct TransparentVideoPlayer: UIViewRepresentable {
// func makeUIView(context: Context) -> UIView { // func makeUIView(context: Context) -> UIView {
// let view = UIView() // let view = UIView()

View File

@ -198,7 +198,7 @@ struct UserInfo: View {
case .success(let blindBoxData): case .success(let blindBoxData):
print("✅ 盲盒生成成功: \(blindBoxData?.id ?? "0")") print("✅ 盲盒生成成功: \(blindBoxData?.id ?? "0")")
// //
Router.shared.navigate(to: .blindBox(mediaType: .image, blindBoxId: blindBoxData?.id ?? "0")) Router.shared.navigate(to: .blindBox(mediaType: .image))
case .failure(let error): case .failure(let error):
print("❌ 盲盒生成失败: \(error.localizedDescription)") print("❌ 盲盒生成失败: \(error.localizedDescription)")
} }

View File

@ -0,0 +1,719 @@
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
// newMediaoldMedia
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
]
}
// POST/material
NetworkService.shared.postWithToken(
path: "/material",
parameters: files
) { (result: Result<EmptyResponse, NetworkError>) in
switch result {
case .success:
print("✅ 素材提交成功")
//
DispatchQueue.main.async {
Router.shared.navigate(to: .blindBox(mediaType: .video))
}
case .failure(let error):
print("❌ 素材提交失败: \(error.localizedDescription)")
//
}
}
}
}
// 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 20 images and 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()
}
}
}

View File

@ -51,9 +51,6 @@ struct WakeApp: App {
// route.view // route.view
// } // }
UserInfo() UserInfo()
.navigationDestination(for: AppRoute.self) { route in
route.view
}
} }
} else { } else {
// //