feat: 盲盒数据类型打通

This commit is contained in:
Junhui Chen 2025-09-07 13:55:31 +08:00
parent e9cdb82b70
commit 8f369867b2
6 changed files with 385 additions and 235 deletions

View File

@ -68,19 +68,37 @@ struct BlindCount: Codable {
// MARK: - Blind Box Data // MARK: - Blind Box Data
struct BlindBoxData: Codable { struct BlindBoxData: Codable {
let id: Int64 let id: String
let boxCode: String let boxCode: String
let userId: Int64 let userId: String
let name: String let name: String
let boxType: String let boxType: String
let features: String? let features: String?
let url: String? let resultFile: FileInfo?
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 description: 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 { enum CodingKeys: String, CodingKey {
case id case id
@ -89,43 +107,68 @@ struct BlindBoxData: Codable {
case name case name
case boxType = "box_type" case boxType = "box_type"
case features case features
case url case resultFile = "result_file"
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: Int64, boxCode: String, userId: Int64, name: String, boxType: String, features: String?, url: String?, status: String, workflowInstanceId: String?, videoGenerateTime: String?, createTime: String, description: String?) { 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) {
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.url = url self.resultFile = resultFile
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.init( self.id = listItem.id
id: Int64(listItem.id) ?? 0, self.boxCode = listItem.boxCode
boxCode: listItem.boxCode, self.userId = listItem.userId
userId: Int64(listItem.userId) ?? 0, self.name = listItem.name
name: listItem.name, self.boxType = listItem.boxType
boxType: listItem.boxType, self.features = listItem.features
features: listItem.features,
url: listItem.resultFile?.url, // FileInfo
status: listItem.status, if let resultFileInfo = listItem.resultFile {
workflowInstanceId: listItem.workflowInstanceId, self.resultFile = FileInfo(
videoGenerateTime: listItem.videoGenerateTime, id: resultFileInfo.id,
createTime: listItem.createTime, fileName: resultFileInfo.fileName,
description: listItem.description url: resultFileInfo.url,
) 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,57 +14,7 @@ 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: BlindBoxDataWrapper? let data: BlindBoxData?
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
@ -81,7 +31,7 @@ class BlindBoxApi {
func generateBlindBox( func generateBlindBox(
boxType: String, boxType: String,
materialIds: [String], materialIds: [String],
completion: @escaping (Result<GenerateBlindBoxResponse.BlindBoxDataWrapper?, Error>) -> Void completion: @escaping (Result<BlindBoxData?, Error>) -> Void
) { ) {
// Codable // Codable
let parameters: [String: Any] = [ let parameters: [String: Any] = [
@ -108,4 +58,48 @@ 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,6 +108,43 @@ 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) case blindBox(mediaType: BlindBoxMediaType, blindBoxId: String? = nil)
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): case .blindBox(let mediaType, let blindBoxId):
BlindBoxView(mediaType: mediaType) BlindBoxView(mediaType: mediaType, blindBoxId: blindBoxId)
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,6 +1,7 @@
import SwiftUI import SwiftUI
import SwiftData import SwiftData
import AVKit import AVKit
import Foundation
// //
extension Notification.Name { extension Notification.Name {
@ -69,6 +70,8 @@ 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 //
@ -78,7 +81,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
@ -106,8 +109,9 @@ struct BlindBoxView: View {
// - // -
@Query private var login: [Login] @Query private var login: [Login]
init(mediaType: BlindBoxMediaType) { init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) {
self.mediaType = mediaType self.mediaType = mediaType
self.currentBoxId = blindBoxId
} }
// //
@ -143,88 +147,138 @@ struct BlindBoxView: View {
} }
} }
private func loadMedia() { private func loadBlindBox() async {
print("loadMedia called with mediaType: \(mediaType)") print("loadMedia called with mediaType: \(mediaType)")
switch mediaType { if self.currentBoxId != nil {
case .video: print("指定监听某盲盒结果: ", self.currentBoxId! as Any)
loadVideo() //
currentBoxType = "Video" await pollingToQuerySingleBox()
startPolling() }
case .image:
loadImage() // switch mediaType {
currentBoxType = "Image" // case .video:
startPolling() // loadVideo()
case .all: // currentBoxType = "Video"
print("Loading all content...") // startPolling()
// First/Second // case .image:
NetworkService.shared.get( // loadImage()
path: "/blind_boxs/query", // currentBoxType = "Image"
parameters: nil // startPolling()
) { (result: Result<APIResponse<[BlindList]>, NetworkError>) in // case .all:
DispatchQueue.main.async { // print("Loading all content...")
switch result { // // First/Second
case .success(let response): // // 使NetworkService.shared.getasync/await
if response.data.count == 0 { // NetworkService.shared.get(
// -First // path: "/blind_boxs/query",
print("❌ 没有盲盒,跳转到新手引导-First盲盒页面") // parameters: nil
// return // ) { (result: Result<APIResponse<[BlindList]>, NetworkError>) in
} // DispatchQueue.main.async {
if response.data.count == 1 && response.data[0].boxType == "First" { // switch result {
// -Second // case .success(let response):
print("❌ 只有First盲盒跳转到新手引导-Second盲盒页面") // if response.data.count == 0 {
// return // // -First
} // print(" -First")
// // return
// }
// if response.data.count == 1 && response.data[0].boxType == "First" {
// // -Second
// print(" First-Second")
// // return
// }
self.blindList = response.data ?? [] // self.blindList = response.data ?? []
// none // // none
if self.blindList.isEmpty { // if self.blindList.isEmpty {
self.animationPhase = .none // self.animationPhase = .none
} // }
print("✅ 成功获取 \(self.blindList.count) 个盲盒") // print(" \(self.blindList.count) ")
case .failure(let error): // case .failure(let error):
self.blindList = [] // self.blindList = []
self.animationPhase = .none // self.animationPhase = .none
print("❌ 获取盲盒列表失败:", error.localizedDescription) // print(" :", error.localizedDescription)
} // }
} // }
} // }
// // // //
NetworkService.shared.get( // // NetworkService.shared.get(
path: "/membership/personal-center-info", // // path: "/membership/personal-center-info",
parameters: nil // // parameters: nil
) { (result: Result<MemberProfileResponse, NetworkError>) in // // ) { (result: Result<MemberProfileResponse, NetworkError>) in
DispatchQueue.main.async { // // DispatchQueue.main.async {
switch result { // // switch result {
case .success(let response): // // case .success(let response):
self.memberProfile = response.data // // self.memberProfile = response.data
self.isMember = response.data.membershipLevel == "Pioneer" // // self.isMember = response.data.membershipLevel == "Pioneer"
self.memberDate = response.data.membershipEndAt ?? "" // // self.memberDate = response.data.membershipEndAt ?? ""
print("✅ 成功获取会员信息:", response.data) // // print(" :", response.data)
print("✅ 用户ID:", response.data.userInfo.userId) // // print(" ID:", response.data.userInfo.userId)
case .failure(let error): // // case .failure(let error):
print("❌ 获取会员信息失败:", error) // // print(" :", error)
} // // }
} // // }
} // // }
// // // //
NetworkService.shared.get( // // NetworkService.shared.get(
path: "/blind_box/available/quantity", // // path: "/blind_box/available/quantity",
parameters: nil // // parameters: nil
) { (result: Result<APIResponse<BlindCount>, NetworkError>) in // // ) { (result: Result<APIResponse<BlindCount>, NetworkError>) in
DispatchQueue.main.async { // // DispatchQueue.main.async {
switch result { // // switch result {
case .success(let response): // // case .success(let response):
self.blindCount = response.data // // self.blindCount = response.data
print("✅ 成功获取盲盒数量:", response.data) // // print(" :", response.data)
case .failure(let error): // // case .failure(let error):
print("❌ 获取数量失败:", 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("✅ 盲盒已准备就绪,停止轮询")
stopPolling()
break
}
}
// 2
try await Task.sleep(nanoseconds: 2_000_000_000)
} catch {
print("❌ 获取盲盒数据失败: \(error)")
//
self.animationPhase = .none
stopPolling()
break
}
}
}
// //
private func startPolling() { private func startPolling() {
stopPolling() stopPolling()
@ -244,52 +298,54 @@ 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<APIResponse<BlindBoxData>, NetworkError>) in // ) { (result: Result<GenerateBlindBoxResponse, 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)") // print(": \(data?.status ?? "Unknown")")
// // //
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
} // }
//
// // //
NotificationCenter.default.post( // if let status = data?.status {
name: .blindBoxStatusChanged, // NotificationCenter.default.post(
object: nil, // name: .blindBoxStatusChanged,
userInfo: ["status": data.status] // object: nil,
) // userInfo: ["status": status]
// )
if data.status != "Preparing" { // }
self.stopPolling() //
print("✅ 盲盒准备就绪,状态: \(data.status)") // if data?.status != "Preparing" {
if self.mediaType == .video { // self.stopPolling()
self.videoURL = data.url ?? "" // print(" : \(data?.status ?? "Unknown")")
} else if self.mediaType == .image { // if self.mediaType == .video {
self.imageURL = data.url ?? "" // self.videoURL = data?.resultFile?.url ?? ""
} // } else if self.mediaType == .image {
} else { // self.imageURL = data?.resultFile?.url ?? ""
self.pollingTimer = Timer.scheduledTimer( // }
withTimeInterval: 2.0, // } else {
repeats: false // self.pollingTimer = Timer.scheduledTimer(
) { _ in // withTimeInterval: 2.0,
self.checkBlindBoxStatus() // repeats: false
} // ) { _ in
} // self.checkBlindBoxStatus()
case .failure(let error): // }
print("❌ 获取盲盒状态失败: \(error.localizedDescription)") // }
self.stopPolling() // case .failure(let error):
} // print(" : \(error.localizedDescription)")
} // self.stopPolling()
} // }
// }
// }
} }
private func loadImage() { private func loadImage() {
@ -409,40 +465,45 @@ 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
} // }
} // }
} // }
// //
loadMedia() Task {
await loadBlindBox()
}
} }
.onDisappear { .onDisappear {
stopPolling() stopPolling()
@ -722,10 +783,10 @@ struct BlindBoxView: View {
if !showScalingOverlay && !showMedia { if !showScalingOverlay && !showMedia {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
// blindGeneratedescription // blindGeneratedescription
Text(blindGenerate?.videoGenerateTime ?? "hhsdshjsjdhn") Text(blindGenerate?.name ?? "Some box")
.font(Typography.font(for: .body, family: .quicksandBold)) .font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(Color.themeTextMessageMain) .foregroundColor(Color.themeTextMessageMain)
Text(blindGenerate?.description ?? "informationinformationinformationinformationinformationinformation") Text(blindGenerate?.description ?? "")
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(Color.themeTextMessageMain) .foregroundColor(Color.themeTextMessageMain)
} }
@ -876,6 +937,21 @@ 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)) Router.shared.navigate(to: .blindBox(mediaType: .image, blindBoxId: blindBoxData?.id ?? "0"))
case .failure(let error): case .failure(let error):
print("❌ 盲盒生成失败: \(error.localizedDescription)") print("❌ 盲盒生成失败: \(error.localizedDescription)")
} }