import SwiftUI import SwiftData import AVKit import Foundation // 添加通知名称 extension Notification.Name { static let blindBoxStatusChanged = Notification.Name("blindBoxStatusChanged") } extension Notification.Name { static let navigateToMediaViewer = Notification.Name("navigateToMediaViewer") } // MARK: - 主视图 struct BlindBoxView: View { let mediaType: BlindBoxMediaType let currentBoxId: String? @StateObject private var viewModel: BlindBoxViewModel @State private var showModal = false // 控制用户资料弹窗显示 @State private var showSettings = false // 控制设置页面显示 @State private var showLogin = false // 倒计时由 ViewModel 管理(countdownText) @State private var animationPhase: BlindBoxAnimationPhase = .none // 查询数据 - 简单查询 @Query private var login: [Login] init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) { self.mediaType = mediaType self.currentBoxId = blindBoxId _viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId)) } // 计算尺寸逻辑已迁移至 BlindBoxMediaOverlay 组件(已不再使用) var body: some View { ZStack { Color.themeTextWhiteSecondary.ignoresSafeArea() .onAppear { Perf.event("BlindBox_Appear") print("🎯 BlindBoxView appeared with mediaType: \(mediaType)") print("🎯 Current thread: \(Thread.current)") // 调用接口 Task { await viewModel.load() } } .onDisappear { viewModel.stopPolling() viewModel.stopCountdown() // Clean up video player viewModel.player?.pause() viewModel.player?.replaceCurrentItem(with: nil) viewModel.player = nil NotificationCenter.default.removeObserver( self, name: .blindBoxStatusChanged, object: nil ) } .onChange(of: viewModel.blindGenerate?.status) { _, status in guard let status = status?.lowercased() else { return } if status == "unopened" { Perf.event("BlindBox_Status_Unopened") withAnimation { self.animationPhase = .ready } } else if status == "preparing" { Perf.event("BlindBox_Status_Preparing") withAnimation { self.animationPhase = .loading } } } .onChange(of: animationPhase) { _, phase in if phase != .loading { // 仅用于迁移前清理;现倒计时在 VM 中管理 } } .onChange(of: viewModel.videoURL) { _, url in if !url.isEmpty && self.animationPhase != .opening { withAnimation { self.animationPhase = .ready } } } .onChange(of: viewModel.imageURL) { _, url in if !url.isEmpty && self.animationPhase != .opening { withAnimation { self.animationPhase = .ready } } } .onChange(of: viewModel.didBootstrap) { _, done in guard done else { return } // 根据首帧状态决定初始动画态,避免先显示 loading 再跳到 ready 的割裂感 let initialStatus = viewModel.blindGenerate?.status.lowercased() ?? "" if initialStatus == "unopened" { withAnimation { self.animationPhase = .ready } } else if initialStatus == "preparing" { withAnimation { self.animationPhase = .loading } } else { // 若未知状态,保持 none;后续 onChange 会驱动到正确态 self.animationPhase = .none } } // 原 overlay 分支已移除,直接展示内容 // Original content VStack { VStack(spacing: 20) { if mediaType == .all { BlindBoxHeaderBar( onMenuTap: showUserProfile, remainPoints: viewModel.memberProfile?.remainPoints ?? 0, showLogin: $showLogin ) } // 标题 BlindBoxTitleView() .opacity(animationPhase == .opening ? 0 : 1) // 盲盒 ZStack { // 1. 背景Card CardBlindBackground() if mediaType == .all { BlindCountBadge(text: "\(viewModel.blindCount?.availableQuantity ?? 0) Boxes") .position(x: UIScreen.main.bounds.width * 0.7, y: UIScreen.main.bounds.height * 0.1) } VStack(spacing: 20) { BlindBoxAnimationView( phase: $animationPhase, onTapReady: { Perf.event("BlindBox_Open_Tapped") print("点击了盲盒") let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id if let boxId = boxIdToOpen { Task { do { try await viewModel.openBlindBox(for: boxId) print("✅ 盲盒开启成功") await viewModel.startPolling() withAnimation { animationPhase = .opening } } catch { print("❌ 开启盲盒失败: \(error)") } } } }, onOpeningCompleted: { navigateToOutcome() } ) } .compositingGroup() .padding() // 非 opening 阶段显示文字 if animationPhase == .ready { BlindBoxDescriptionView( name: viewModel.blindGenerate?.name ?? "Some box", description: viewModel.blindGenerate?.description ?? "" ) .offset(x: 0, y: UIScreen.main.bounds.height * 0.2) } } .padding() .frame( maxWidth: .infinity, maxHeight: UIScreen.main.bounds.height * 0.65 ) // 打开 TODO 引导时,也要有按钮 if mediaType == .all, viewModel.didBootstrap { BlindBoxActionButton( phase: animationPhase, countdownText: viewModel.countdownText, onOpen: { let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id if let boxId = boxIdToOpen { Task { do { try await viewModel.openBlindBox(for: boxId) print("✅ 盲盒开启成功") await viewModel.startPolling() withAnimation { animationPhase = .opening } } catch { print("❌ 开启盲盒失败: \(error)") } } } }, onGoToBuy: { Router.shared.navigate(to: .mediaUpload) } ) .padding(.horizontal) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.themeTextWhiteSecondary) .offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0) .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal) .edgesIgnoringSafeArea(.all) } // 用户资料弹窗 SlideInModal( isPresented: $showModal, onDismiss: hideUserProfile ) { UserProfileModal( showModal: $showModal, showSettings: $showSettings, isMember: .init(get: { viewModel.isMember }, set: { viewModel.isMember = $0 }), memberDate: .init(get: { viewModel.memberDate }, set: { viewModel.memberDate = $0 }) ) } .offset(x: showSettings ? UIScreen.main.bounds.width : 0) .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings) // 设置页面遮罩层 ZStack { if showSettings { Color.black.opacity(0.3) .edgesIgnoringSafeArea(.all) .onTapGesture(perform: hideSettings) .transition(.opacity) } if showSettings { SettingsView(isPresented: $showSettings) .transition(.move(edge: .leading)) .zIndex(1) } } .animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings) } .navigationBarBackButtonHidden(true) } /// 显示用户资料弹窗 private func showUserProfile() { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { // print("登录记录数量: \(login.count)") // for (index, item) in login.enumerated() { // print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)") // } print("当前登录记录:") for (index, item) in login.enumerated() { print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)") } showModal.toggle() } } /// 隐藏用户资料弹窗 private func hideUserProfile() { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { showModal = false } } /// 隐藏设置页面 private func hideSettings() { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { showSettings = false } } /// 开启动画播放完成后,准备媒体并跳转到结果页 private func navigateToOutcome() { Perf.event("BlindBox_Opening_Completed") Task { @MainActor in let interval: UInt64 = 300_000_000 // 300ms let timeout: UInt64 = 6_000_000_000 // 6s var waited: UInt64 = 0 if mediaType == .all { // 等待视频 URL 就绪 while viewModel.videoURL.isEmpty && waited < timeout { try? await Task.sleep(nanoseconds: interval) waited += interval } // 拿到 URL 即可跳转;不强依赖 player 准备 if !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) { Router.shared.navigate( to: .blindOutcome( media: .video(url, nil), time: viewModel.blindGenerate?.name ?? "Your box", description: viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember ) ) return } } else if mediaType == .image { // 等到有 imageURL 后再加载 UIImage while viewModel.imageURL.isEmpty && waited < timeout { try? await Task.sleep(nanoseconds: interval) waited += interval } if viewModel.displayImage == nil && !viewModel.imageURL.isEmpty { await viewModel.prepareMedia() } if let image = viewModel.displayImage { Router.shared.navigate( to: .blindOutcome( media: .image(image), time: viewModel.blindGenerate?.name ?? "Your box", description: viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember ) ) return } } // 若仍未获取到媒体,记录日志以便排查 print("⚠️ navigateToOutcome: 媒体尚未准备好,videoURL=\(viewModel.videoURL), image=\(String(describing: viewModel.displayImage))") } } } // MARK: - 预览 #Preview { BlindBoxView(mediaType: .all) .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 } } // 预览第一个盲盒 #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 } }