// // QuestionViewController.swift // RoderickRalph // // Created by Neoa on 2025/8/19. // import Foundation import UIKit import AnyThinkBanner import AnyThinkSDK import AnyThinkInterstitial import AnyThinkNative import AnyThinkRewardedVideo class QuizStageController: UIViewController, ATAdLoadingDelegate, ATBannerDelegate, ATInterstitialDelegate, ATNativeADDelegate, ATRewardedVideoDelegate, EnergyPanelAgent, OverlayPanelAgent{ // MARK: - UI Components // 实际使用的广告placement ID(从接口获取或使用默认值) private var bannerPlacementID: String = "b68d88cc2ab33d" private var interstitialPlacementID: String = "b68d88cc36bca8" private var nativeRenderPlacementID: String = "b68db6c2fdfcac" private var rewardPlacementID: String = "b68d88cc20bb14" private var nativeAdView: ATNativeADView? private var selfRenderView: RenderUnitView? private var nativeAdOffer: ATNativeAdOffer? private var nativeAdContainer: UIView = { let view = UIView() view.translatesAutoresizingMaskIntoConstraints = false view.backgroundColor = .clear view.isUserInteractionEnabled = true return view }() private var bannerView: ATBannerView? private var bannerContainer: UIView = { let container = UIView() container.translatesAutoresizingMaskIntoConstraints = false container.backgroundColor = .clear container.isUserInteractionEnabled = true container.clipsToBounds = false // 确保子视图不会被裁剪 return container }() private var bannerHeightConstraint: NSLayoutConstraint? private var bannerSize: CGSize { let screenWidth = UIScreen.main.bounds.width let aspectRatio: CGFloat = 320 / 50 let bannerWidth = screenWidth let bannerHeight = bannerWidth / aspectRatio return CGSize(width: bannerWidth, height: bannerHeight) } // MARK: - Channel API (moved to LoginQuestionViewController) private let getDefaultConfig = "/wx/getByDitchId" private let getQuestionListPath = "/question/list" private let addPowerPath = "/wx/addPower" private let answerQuestionPath = "/question/answerQuestion" private let adSaveRecordPath = "/ad/saveRecord" // MARK: - Get Power Countdown private var powerCountdownOverlay: UIView? private var powerCountdownLabel: UILabel? private var powerCountdownTimer: Timer? private var powerRemainingSeconds: Int = 60 private var didStartPowerCountdown = false // Remote-configured values with sensible defaults private var staminaWaitSec: Int = 60 private var interIntervalSec: Int = 30 private var interstitialTimer: Timer? private var nativeIntervalSec: Int = 30 private var nativeTimer: Timer? private var isAllowNativeAutoRefresh: Bool = true // 1:不可叠加体力;0:可叠加体力 private var canStackPower: Int = 0 private var accountInfo: LoginUserInfo? private var selectedChannelRef: Channel? // Reward flow gating for cooldown private var rewardPowerOK: Bool = false private var rewardVideoClosed: Bool = false private var pendingToastText: String? // MARK: - Question Data State private struct QAItem { let id: String; let content: String } private struct QAQuestion { let id: String; let content: String; let correctItemId: String; let items: [QAItem] } private var questions: [QAQuestion] = [] private var currentQuestionIndex: Int = 0 private var option1ItemId: String? private var option2ItemId: String? private var questionStartAt: Date? private var lastQuestionIdFromLogin: String? private var isAnswering: Bool = false // 防止重复答题标志 private let userInfoBGView: UIView = { let view = UIImageView() view.contentMode = .scaleToFill view.image = UIImage(named: "res_ogoxcw01") view.translatesAutoresizingMaskIntoConstraints = false return view }() private let iconImageView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "catlogos") iv.contentMode = .scaleAspectFit iv.layer.cornerRadius = 29 iv.clipsToBounds = true iv.translatesAutoresizingMaskIntoConstraints = false return iv }() private let userInfoLabel: UILabel = { let label = UILabel() label.text = "" label.font = UIFont.boldSystemFont(ofSize: 10) label.textColor = .white // 添加橘色的文字描边 let strokeTextAttributes: [NSAttributedString.Key: Any] = [ .strokeColor: UIColor.orange, // 设置描边颜色 .strokeWidth: -2.0, // 设置描边宽度,负值为描边,正值为文字内部留白 .foregroundColor: UIColor.white // 设置文字颜色 ] let attributedString = NSAttributedString(string: label.text ?? "", attributes: strokeTextAttributes) label.attributedText = attributedString label.translatesAutoresizingMaskIntoConstraints = false return label }() private let musiceView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "res_fb2rjej5") iv.contentMode = .scaleToFill // iv.layer.cornerRadius = 29 // iv.clipsToBounds = true iv.translatesAutoresizingMaskIntoConstraints = false return iv }() private let gainPowerBtn: UIButton = { let button = UIButton(type: .system) button.setBackgroundImage(UIImage(named: "res_4sw46mis"), for: .normal) button.setTitle("获取体力", for: .normal) button.setTitleColor(.white, for: .normal) button.contentHorizontalAlignment = .left button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 15, bottom: 0, right: 0) // Adjust left padding button.translatesAutoresizingMaskIntoConstraints = false return button }() private let todayCountLabel: UILabel = { let label = UILabel() label.text = "今日答题: 0题" label.font = UIFont.systemFont(ofSize: 13) label.textColor = .white label.translatesAutoresizingMaskIntoConstraints = false return label }() private let historyCountLabel: UILabel = { let label = UILabel() label.text = "历史答题: 0题" label.font = UIFont.systemFont(ofSize: 13) label.textColor = .white label.translatesAutoresizingMaskIntoConstraints = false return label }() private let verdictBGView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "res_27q0oxly") iv.contentMode = .scaleToFill iv.translatesAutoresizingMaskIntoConstraints = false return iv }() private let verdictLabel: UILabel = { let label = UILabel() label.text = "请选择正确答案" label.font = UIFont.systemFont(ofSize: 24, weight: .bold) label.textAlignment = .center label.textColor = .white label.translatesAutoresizingMaskIntoConstraints = false return label }() private let boardBGView: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "res_b0fsh9ql") iv.contentMode = .scaleToFill iv.translatesAutoresizingMaskIntoConstraints = false return iv }() private let promptLabel: UILabel = { let label = UILabel() label.text = "《二泉映月》是首用什么乐器独奏的曲子" label.font = UIFont.systemFont(ofSize: 18, weight: .bold) label.textColor = .black label.textAlignment = .center label.numberOfLines = 0 label.translatesAutoresizingMaskIntoConstraints = false return label }() private let levelBadge: UIButton = { let button = UIButton(type: .system) button.setBackgroundImage(UIImage(named: "res_fm0lbz7w"), for: .normal) button.setTitle("关卡:1", for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = UIFont.systemFont(ofSize: 20) button.contentHorizontalAlignment = .center button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) // Adjust left padding button.isUserInteractionEnabled = false button.translatesAutoresizingMaskIntoConstraints = false return button }() private let staminaBadge: UIButton = { let button = UIButton(type: .system) button.setBackgroundImage(UIImage(named: "res_fm0lbz7w"), for: .normal) button.setTitle("体力:0", for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = UIFont.systemFont(ofSize: 20) button.contentHorizontalAlignment = .center button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) button.isUserInteractionEnabled = false button.translatesAutoresizingMaskIntoConstraints = false return button }() private let choiceBtnA: UIButton = { let button = UIButton(type: .system) button.setBackgroundImage(UIImage(named: "res_o67j6d3t"), for: .normal) button.setTitle("二胡", for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 24) button.translatesAutoresizingMaskIntoConstraints = false button.addTarget(self, action: #selector(optionButtonTapped(_:)), for: .touchUpInside) return button }() private let choiceBtnB: UIButton = { let button = UIButton(type: .system) button.setBackgroundImage(UIImage(named: "res_o67j6d3t"), for: .normal) button.setTitle("钢琴", for: .normal) button.setTitleColor(.white, for: .normal) button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 24) button.translatesAutoresizingMaskIntoConstraints = false button.addTarget(self, action: #selector(optionButtonTapped(_:)), for: .touchUpInside) return button }() private let markA: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "res_cwqxdp40") iv.contentMode = .scaleAspectFill iv.isHidden = true iv.translatesAutoresizingMaskIntoConstraints = false return iv }() private let markB: UIImageView = { let iv = UIImageView() iv.image = UIImage(named: "res_4h4cvu8h") iv.contentMode = .scaleToFill iv.isHidden = true iv.translatesAutoresizingMaskIntoConstraints = false return iv }() // MARK: - Public Methods for Data Transfer from LoginQuestionViewController func setLoginUserInfo(_ userInfo: LoginUserInfo) { self.accountInfo = userInfo // 如果界面已经加载,立即更新UI if isViewLoaded { updateUIWithUserInfo(userInfo) } } func setSelectedChannel(_ channel: Channel) { print("SSSS\(channel.id)") self.selectedChannelRef = channel } private func updateUIWithUserInfo(_ userInfo: LoginUserInfo) { // 更新右侧统计 todayCountLabel.text = "今日答题: \(userInfo.todayAnswerCount)题" historyCountLabel.text = "历史答题: \(userInfo.historyAnswerCount)题" userInfoLabel.text = userInfo.userId setIconImage(with: userInfo.headImgURL) // 更新体力显示 let savedPower = UserDefaults.standard.object(forKey: "power") as? Int ?? 0 staminaBadge.setTitle("体力:\(savedPower)", for: .normal) applyPowerToOptionButtons(savedPower) } // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() // navigationController?.setNavigationBarHidden(true, animated: false) // 先不加载广告,等接口返回配置后再加载 // 这样可以确保使用正确的placement ID // Set up gestures for both the icon and the label let iconTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleIconTap)) iconImageView.isUserInteractionEnabled = true iconImageView.addGestureRecognizer(iconTapGesture) let labelTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleLabelTap)) userInfoLabel.isUserInteractionEnabled = true userInfoLabel.addGestureRecognizer(labelTapGesture) gainPowerBtn.addTarget(self, action: #selector(handleGetPowerButtonTap), for: .touchUpInside) setupUI() setupConstraints() // Prefill icon with saved avatar (headImgURL) or placeholder if let userInfo = accountInfo { // 使用传入的用户信息更新UI updateUIWithUserInfo(userInfo) } else { // 使用本地保存的数据 let savedHead = UserDefaults.standard.string(forKey: "headImgURL") setIconImage(with: savedHead) // Prefill power and gray out options if needed let savedPower = UserDefaults.standard.object(forKey: "power") as? Int ?? 0 staminaBadge.setTitle("体力:\(savedPower)", for: .normal) applyPowerToOptionButtons(savedPower) } // Channel selection is now handled in LoginQuestionViewController // Fetch questions directly since login is complete fetchQuestionList() fetchDefaultConfig(channel: self.selectedChannelRef!) //刷新一次banner DispatchQueue.main.asyncAfter(deadline: .now() + 7) { [weak self] in guard let self = self else { return } self.removeBanner() self.loadBannerAd() } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) // 监听应用进入前台的通知 NotificationCenter.default.addObserver( self, selector: #selector(appDidEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil ) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // 移除通知监听 NotificationCenter.default.removeObserver( self, name: UIApplication.willEnterForegroundNotification, object: nil ) } private func loadNativeAd() { let extra: [AnyHashable: Any] = [ kATAdLoadingExtraMediaExtraKey: "QuestionVC", kATExtraInfoNativeAdSizeKey: NSValue(cgSize: CGSize(width: UIScreen.main.bounds.width - 24, height: 170)) ] // Load the native ad with placement ID ATAdManager.shared().loadAD(withPlacementID: nativeRenderPlacementID, extra: extra, delegate: self) } private func showNativeAdIfReady() { // 检查广告是否准备好 guard ATAdManager.shared().nativeAdReady(forPlacementID: nativeRenderPlacementID) else { print("Native ad not ready") return } // 获取广告offer guard let offer = ATAdManager.shared().getNativeAdOffer(withPlacementID: nativeRenderPlacementID) else { print("Failed to get native ad offer") return } self.nativeAdOffer = offer // 创建自渲染视图 let selfRenderView = RenderUnitView(offer: offer) self.selfRenderView = selfRenderView // 初始化配置 let config = ATNativeADConfiguration() config.adFrame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 170) config.mediaViewFrame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 150) config.delegate = self config.rootViewController = self config.sizeToFit = true config.videoPlayType = .alwaysAutoPlayType // 设置logo位置 config.logoViewFrame = CGRect(x: UIScreen.main.bounds.width - 48, y: 140, width: 36, height: 20) // 创建原生广告视图 let nativeAdView = ATNativeADView(configuration: config, currentOffer: offer, placementID: nativeRenderPlacementID) self.nativeAdView = nativeAdView // 获取mediaView if let mediaView = nativeAdView.getMediaView() { selfRenderView.installMediaView(mediaView) } // 创建可点击组件数组 var clickableViewArray: [UIView] = [] clickableViewArray.append(contentsOf: [ // selfRenderView.iconImageView, selfRenderView.headlineLabel, selfRenderView.bodyLabel, // selfRenderView.ctaLabel, selfRenderView.heroImageView ]) // 注册点击事件 nativeAdView.registerClickableViewArray(clickableViewArray) // 绑定组件 let info = ATNativePrepareInfo.load { prepareInfo in prepareInfo.textLabel = selfRenderView.bodyLabel // prepareInfo.advertiserLabel = selfRenderView.advertiserLabel prepareInfo.titleLabel = selfRenderView.headlineLabel // prepareInfo.ratingLabel = selfRenderView.ratingLabel // prepareInfo.iconImageView = selfRenderView.iconImageView prepareInfo.mainImageView = selfRenderView.heroImageView // prepareInfo.logoImageView = selfRenderView.logoImageView // prepareInfo.ctaLabel = selfRenderView.ctaLabel prepareInfo.dislikeButton = selfRenderView.closeControl if let mv = selfRenderView.mediaContainer { prepareInfo.mediaView = mv } } nativeAdView.prepare(with: info) // 渲染广告 offer.renderer(with: config, selfRenderView: selfRenderView, nativeADView: nativeAdView) // 添加到容器(必须把 nativeAdView 放到视图层级中,点击与视频追踪才会生效) nativeAdContainer.subviews.forEach { $0.removeFromSuperview() } nativeAdContainer.addSubview(nativeAdView) nativeAdView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ nativeAdView.leadingAnchor.constraint(equalTo: nativeAdContainer.leadingAnchor), nativeAdView.trailingAnchor.constraint(equalTo: nativeAdContainer.trailingAnchor), nativeAdView.topAnchor.constraint(equalTo: nativeAdContainer.topAnchor), nativeAdView.bottomAnchor.constraint(equalTo: nativeAdContainer.bottomAnchor) ]) // 自渲染视图放入 nativeAdView 内 nativeAdView.addSubview(selfRenderView) selfRenderView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ selfRenderView.leadingAnchor.constraint(equalTo: nativeAdView.leadingAnchor), selfRenderView.trailingAnchor.constraint(equalTo: nativeAdView.trailingAnchor), selfRenderView.topAnchor.constraint(equalTo: nativeAdView.topAnchor), selfRenderView.bottomAnchor.constraint(equalTo: nativeAdView.bottomAnchor) ]) print("Native self-render ad displayed successfully") } @objc func loadRewardAd() { var loadConfig: [String: Any] = [:] // 服务端激励验证透传参数(可选) loadConfig[kATAdLoadingExtraMediaExtraKey] = "media_val_RewardedVC" loadConfig[kATAdLoadingExtraUserIDKey] = "rv_test_user_id" loadConfig[kATAdLoadingExtraRewardNameKey] = "reward_Name" loadConfig[kATAdLoadingExtraRewardAmountKey] = 1 ATAdManager.shared().loadAD(withPlacementID: rewardPlacementID, extra: loadConfig, delegate: self) } // MARK: - Show Ad 展示广告 @objc func showRewardAd() { // 就绪检查 guard ATAdManager.shared().rewardedVideoReady(forPlacementID: rewardPlacementID) else { loadRewardAd() return } let config = ATShowConfig(scene: "", showCustomExt: "testShowCustomExt") // Reset reward flow state before showing a new video rewardPowerOK = false rewardVideoClosed = false // 注意:不清除pendingToastMessage,因为可能还有待显示的消息 // 展示 ATAdManager.shared().showRewardedVideo(withPlacementID: rewardPlacementID, config: config, in: self, delegate: self) } private func loadBannerAd() { var extra: [AnyHashable: Any] = [ kATAdLoadingExtraMediaExtraKey: "QuestionVC" ] extra[kATAdLoadingExtraBannerAdSizeKey] = NSValue(cgSize: bannerSize) ATAdManager.shared().loadAD(withPlacementID: bannerPlacementID, extra: extra, delegate: self) } private func showBannerIfReady() { let config = ATShowConfig(scene: nil, showCustomExt: nil) guard let banner = ATAdManager.shared().retrieveBannerView(forPlacementID: bannerPlacementID, config: config) else { return } banner.delegate = self // 确保设置正确的 presentingViewController banner.presentingViewController = self banner.translatesAutoresizingMaskIntoConstraints = false bannerContainer.subviews.forEach { $0.removeFromSuperview() } bannerContainer.addSubview(banner) // Add constraints to the banner view NSLayoutConstraint.activate([ banner.centerXAnchor.constraint(equalTo: bannerContainer.centerXAnchor), banner.topAnchor.constraint(equalTo: bannerContainer.topAnchor), banner.widthAnchor.constraint(equalToConstant: bannerSize.width), banner.heightAnchor.constraint(equalToConstant: bannerSize.height) ]) // Store the banner view bannerView = banner bannerView?.isUserInteractionEnabled = true // Update the height constraint for the container bannerHeightConstraint?.constant = bannerSize.height UIView.animate(withDuration: 0.25) { self.view.layoutIfNeeded() } // 打印调试信息 print("Banner frame: \(banner.frame)") print("Banner container frame: \(self.bannerContainer.frame)") } private func removeBanner() { bannerView?.destroyBanner() bannerView?.removeFromSuperview() bannerView = nil // Reset the height constraint to 0 bannerHeightConstraint?.constant = 0 UIView.animate(withDuration: 0.25) { self.view.layoutIfNeeded() } } // MARK: - ATAdLoadingDelegate func didFinishLoadingAD(withPlacementID placementID: String) { if placementID == bannerPlacementID { // 横幅广告加载完成,检查横幅广告是否准备好 if ATAdManager.shared().bannerAdReady(forPlacementID: placementID) { showBannerIfReady() // 展示横幅广告 } print("Banner Ad Loaded: \(placementID)") } else if placementID == interstitialPlacementID { // 插屏广告加载完成,检查插屏广告是否准备好 if ATAdManager.shared().interstitialReady(forPlacementID: placementID) { showInterstitialAd() // 展示插屏广告 } print("Interstitial Ad Loaded: \(placementID)") } else if placementID == nativeRenderPlacementID { if ATAdManager.shared().nativeAdReady(forPlacementID: placementID) { showNativeAdIfReady() } print("native Ad Loaded: \(placementID)") } } // MARK: - ATRewardedVideoDelegate(激励回调) // 激励成功(注意:原 ObjC 选择子为 PlacemenID,保持一致以兼容 SDK) func rewardedVideoDidRewardSuccess(forPlacemenID placementID: String, extra: [AnyHashable: Any]) { print("rewardedVideoDidRewardSuccessForPlacemenID:\(placementID) extra:\(extra)") // 激励成功,向服务器申请体力 addPower() } // 视频开始播放 func rewardedVideoDidStartPlaying(forPlacementID placementID: String, extra: [AnyHashable: Any]) { print("rewardedVideoDidStartPlayingForPlacementID:\(placementID) extra:\(extra)") adStartTimes[placementID] = nowMillis() incrementTodayAdCount() } // 视频播放结束 func rewardedVideoDidEndPlaying(forPlacementID placementID: String, extra: [AnyHashable: Any]) { print("rewardedVideoDidEndPlayingForPlacementID:\(placementID) extra:\(extra)") } // 视频播放失败(失败后预加载) func rewardedVideoDidFailToPlay(forPlacementID placementID: String, error: Error, extra: [AnyHashable: Any]) { let code = (error as NSError).code print("rewardedVideoDidFailToPlayForPlacementID:\(placementID) error:\(error) extra:\(extra)") // 预加载 loadRewardAd() } // 广告关闭(关闭后预加载) @objc(rewardedVideoDidCloseForPlacementID:rewarded:extra:) func rewardedVideoDidClose(forPlacementID placementID: String, rewarded: Bool, extra: [AnyHashable: Any]) { print("rewardedVideoDidCloseForPlacementID:\(placementID), rewarded:\(rewarded) extra:\(extra)") // 预加载 loadRewardAd() // 标记关闭并尝试开启倒计时 rewardVideoClosed = true print("[Toast] Video closed, pending message: \(pendingToastText ?? "nil")") startCooldownIfRewardFlowDone() // 显示待显示的Toast消息 showPendingToast() } // 广告点击 @objc(rewardedVideoDidClickForPlacementID:extra:) func rewardedVideoDidClick(forPlacementID placementID: String, extra: [AnyHashable: Any]) { print("rewardedVideoDidClickForPlacementID:\(placementID) extra:\(extra)") } // Deeplink/跳转 @objc(rewardedVideoDidDeepLinkOrJumpForPlacementID:extra:result:) func rewardedVideoDidDeepLinkOrJump(forPlacementID placementID: String, extra: [AnyHashable: Any], result success: Bool) { print("rewardedVideoDidDeepLinkOrJumpForPlacementID:\(placementID) extra:\(extra) success:\(success)") } // MARK: - "再看一个"能力(Again)回调 func rewardedVideoAgainDidRewardSuccess(forPlacemenID placementID: String, extra: [AnyHashable: Any]) { print("rewardedVideoAgainDidRewardSuccessForPlacemenID:\(placementID) extra:\(extra)") } func rewardedVideoAgainDidStartPlaying(forPlacementID placementID: String, extra: [AnyHashable: Any]) { print("rewardedVideoAgainDidStartPlayingForPlacementID:\(placementID) extra:\(extra)") } func rewardedVideoAgainDidEndPlaying(forPlacementID placementID: String, extra: [AnyHashable: Any]) { print("rewardedVideoAgainDidEndPlayingForPlacementID:\(placementID) extra:\(extra)") } func rewardedVideoAgainDidFailToPlay(forPlacementID placementID: String, error: Error, extra: [AnyHashable: Any]) { let code = (error as NSError).code print("rewardedVideoAgainDidFailToPlayForPlacementID:\(placementID) error:\(error) extra:\(extra)") } func rewardedVideoAgainDidClick(forPlacementID placementID: String, extra: [AnyHashable: Any]) { print("rewardedVideoAgainDidClickForPlacementID:\(placementID) extra:\(extra)") } // MARK: - ATNativeAdDelegate func didShowNativeAd(in adView: ATNativeADView, placementID: String, extra: [AnyHashable: Any]?) { print("Native ad shown for \(placementID)") adStartTimes[placementID] = nowMillis() incrementTodayAdCount() // 根据配置决定是否开始原生广告自动刷新计时 if isAllowNativeAutoRefresh { startNativeAdRefreshTimer() } else { print("[Config] Native ad auto refresh is disabled") } } func didTapCloseButton(in adView: ATNativeADView, placementID: String, extra: [AnyHashable: Any]?) { print("Native ad closed for \(placementID)") removeNativeAd() } func didClickNativeAd(in adView: ATNativeADView, placementID: String, extra: [AnyHashable: Any]?) { print("Native ad clicked for \(placementID)") } func didFailToLoadAD(withPlacementID placementID: String, error: Error) { print("AD load failed(\(placementID)): \(error)") // 失败不影响主流程,保持 banner 容器高度为 0 // banner广告失败,自动重试加载与展示 if placementID == bannerPlacementID { print("[Banner] Load failed for bannerPlacementID, will retry...") // 清理旧视图以避免残留 removeBanner() // 稍作延迟再重试,避免与 SDK 内部节流/并发冲突 DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in guard let self = self else { return } self.loadBannerAd() } } // 原生自渲染广告失败,自动重试加载与展示 if placementID == nativeRenderPlacementID { print("[Native] Load failed for nativeRenderPlacementID, will retry...") // 清理旧视图以避免残留 removeNativeAd() // 稍作延迟再重试,避免与 SDK 内部节流/并发冲突 DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in guard let self = self else { return } self.loadNativeAd() } } } func didRevenue(forPlacementID placementID: String, extra: [AnyHashable : Any]!) { // 可选:上报收益日志到我们服务器 print("Banner revenue: placement=\(placementID), extra=\(extra ?? [:])") uploadAdRevenue(placementID: placementID, extra: extra ?? [:]) } // MARK: - ATBannerDelegate func bannerView(_ bannerView: ATBannerView, didTapCloseButtonWithPlacementID placementID: String, extra: [AnyHashable: Any]!) { removeBanner() } func bannerView(_ bannerView: ATBannerView, didShowAdWithPlacementID placementID: String, extra: [AnyHashable: Any]!) { print("Banner Ad shown for \(placementID)") //展示广告的时间戳 adStartTimes[placementID] = nowMillis() incrementTodayAdCount() // 在广告显示时设置正确的 presentingViewController bannerView.presentingViewController = self } func bannerView(_ bannerView: ATBannerView, didClickWithPlacementID placementID: String, extra: [AnyHashable: Any]!) { print("Banner Ad clicked for \(placementID)") } func bannerView(_ bannerView: ATBannerView, didAutoRefreshWithPlacement placementID: String, extra: [AnyHashable: Any]!) { print("Banner auto-refresh for \(placementID)") } func bannerView(_ bannerView: ATBannerView, failedToAutoRefreshWithPlacementID placementID: String, error: Error) { print("Banner auto-refresh failed for \(placementID): \(error.localizedDescription)") } // 添加 Banner 广告深度链接/跳转回调 func bannerView(_ bannerView: ATBannerView, didDeepLinkOrJumpForPlacementID placementID: String, extra: [AnyHashable: Any]!, result success: Bool) { } private func removeNativeAd() { // 停止原生广告刷新计时器 stopNativeAdRefreshTimer() // 清理自渲染视图 selfRenderView?.teardown() selfRenderView?.removeFromSuperview() selfRenderView = nil // 清理原生广告视图 nativeAdView?.removeFromSuperview() nativeAdView?.destroyNative() nativeAdView = nil // 清理offer nativeAdOffer = nil } // 加载插屏广告 private func loadInterstitialAd() { // 加载插屏广告(支持半屏尺寸:如快手;可能会影响展示效果) let screenWidth = UIScreen.main.bounds.width let size = CGSize(width: screenWidth - 30.0, height: 300.0) let extra: [AnyHashable: Any] = [ kATAdLoadingExtraMediaExtraKey: "ProfileView", // 设置半屏插屏广告大小 kATInterstitialExtraAdSizeKey: NSValue(cgSize: size) ] ATAdManager.shared().loadAD(withPlacementID: interstitialPlacementID, extra: extra, delegate: self) } // 展示插屏广告 private func showInterstitialAd() { // 检查广告是否准备好 guard ATAdManager.shared().interstitialReady(forPlacementID: interstitialPlacementID) else { print("广告尚未加载完成") return } // 展示广告 ATAdManager.shared().showInterstitial(withPlacementID: interstitialPlacementID, in: self, delegate: self) } func interstitialDidShow(forPlacementID placementID: String, extra: [AnyHashable : Any]) { print("插屏广告展示完成:\(placementID) \(extra)") // 展示广告时间戳 adStartTimes[placementID] = nowMillis() incrementTodayAdCount() } func interstitialDidClick(forPlacementID placementID: String, extra: [AnyHashable : Any]) { print("插屏广告点击:\(placementID)") } func interstitialDidClose(forPlacementID placementID: String, extra: [AnyHashable : Any]) { print("插屏广告关闭:\(placementID) \(extra)") } @objc private func handleIconTap() { showSettingsPopup() } @objc private func handleLabelTap() { showSettingsPopup() } private func showSettingsPopup() { let settingsPopup = OverlayPanelView() settingsPopup.translatesAutoresizingMaskIntoConstraints = false settingsPopup.delegate = self // ✅ 设置代理 view.addSubview(settingsPopup) // 添加约束使弹窗覆盖整个屏幕 NSLayoutConstraint.activate([ settingsPopup.topAnchor.constraint(equalTo: view.topAnchor), settingsPopup.bottomAnchor.constraint(equalTo: view.bottomAnchor), settingsPopup.leadingAnchor.constraint(equalTo: view.leadingAnchor), settingsPopup.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) } // MARK: - UI Setup private func setupUI() { // Set Gradient Background let gradientLayer = CAGradientLayer() gradientLayer.frame = view.bounds gradientLayer.colors = [UIColor(hexString: "#FF6324")!.cgColor, UIColor(hexString: "#FF9946")!.cgColor] gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) // top center gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) // bottom center view.layer.insertSublayer(gradientLayer, at: 0) view.addSubview(bannerContainer) view.addSubview(userInfoBGView) view.addSubview(iconImageView) view.addSubview(userInfoLabel) view.addSubview(musiceView) // New UI components for the right side view.addSubview(gainPowerBtn) view.addSubview(todayCountLabel) view.addSubview(historyCountLabel) view.addSubview(boardBGView) view.addSubview(verdictBGView) view.addSubview(verdictLabel) view.addSubview(promptLabel) view.addSubview(levelBadge) view.addSubview(staminaBadge) view.addSubview(choiceBtnA) view.addSubview(choiceBtnB) view.addSubview(markA) view.addSubview(markB) view.addSubview(nativeAdContainer) // Add native ad container } private func setupConstraints() { // Set up constraints for banner container bannerHeightConstraint = bannerContainer.heightAnchor.constraint(equalToConstant: 0) bannerHeightConstraint?.isActive = true NSLayoutConstraint.activate([ // Banner container bannerContainer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor,constant: 0), bannerContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), bannerContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), userInfoBGView.topAnchor.constraint(equalTo: bannerContainer.bottomAnchor,constant: 24), userInfoBGView.leadingAnchor.constraint(equalTo: view.leadingAnchor,constant: 24), userInfoBGView.widthAnchor.constraint(equalToConstant: 100), userInfoBGView.heightAnchor.constraint(equalToConstant: 100), iconImageView.centerXAnchor.constraint(equalTo: userInfoBGView.centerXAnchor), iconImageView.topAnchor.constraint(equalTo: userInfoBGView.topAnchor,constant: 15), iconImageView.widthAnchor.constraint(equalToConstant: 58), iconImageView.heightAnchor.constraint(equalToConstant: 58), // UID Label userInfoLabel.bottomAnchor.constraint(equalTo: userInfoBGView.bottomAnchor, constant: -5), userInfoLabel.centerXAnchor.constraint(equalTo: userInfoBGView.centerXAnchor), musiceView.leadingAnchor.constraint(equalTo: userInfoBGView.trailingAnchor, constant: 14), musiceView.centerYAnchor.constraint(equalTo: userInfoLabel.centerYAnchor), verdictBGView.centerXAnchor.constraint(equalTo: view.centerXAnchor), verdictBGView.topAnchor.constraint(equalTo: musiceView.bottomAnchor, constant: 14), verdictBGView.widthAnchor.constraint(equalToConstant: 290), verdictBGView.heightAnchor.constraint(equalToConstant: 72), verdictLabel.centerXAnchor.constraint(equalTo: verdictBGView.centerXAnchor), verdictLabel.topAnchor.constraint(equalTo: verdictBGView.topAnchor, constant: 11), boardBGView.centerXAnchor.constraint(equalTo: view.centerXAnchor), boardBGView.topAnchor.constraint(equalTo: musiceView.bottomAnchor, constant: 46), boardBGView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), boardBGView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), // Question Label promptLabel.centerYAnchor.constraint(equalTo: boardBGView.centerYAnchor), promptLabel.centerXAnchor.constraint(equalTo: boardBGView.centerXAnchor), promptLabel.leadingAnchor.constraint(equalTo: boardBGView.leadingAnchor,constant: 20), promptLabel.trailingAnchor.constraint(equalTo: boardBGView.trailingAnchor,constant: -20), levelBadge.bottomAnchor.constraint(equalTo: boardBGView.bottomAnchor, constant: 14), levelBadge.centerXAnchor.constraint(equalTo: boardBGView.centerXAnchor, constant: -67.5), levelBadge.heightAnchor.constraint(equalToConstant: 41), levelBadge.widthAnchor.constraint(equalToConstant: 111), staminaBadge.bottomAnchor.constraint(equalTo: boardBGView.bottomAnchor, constant: 14), staminaBadge.centerXAnchor.constraint(equalTo: boardBGView.centerXAnchor, constant: 67.5), staminaBadge.heightAnchor.constraint(equalToConstant: 41), staminaBadge.widthAnchor.constraint(equalToConstant: 111), // Option Button 1 choiceBtnA.topAnchor.constraint(equalTo: boardBGView.bottomAnchor, constant: 45), choiceBtnA.centerXAnchor.constraint(equalTo: boardBGView.centerXAnchor), choiceBtnA.heightAnchor.constraint(equalToConstant: 47), choiceBtnA.widthAnchor.constraint(equalToConstant: 211), // Option Button 2 choiceBtnB.topAnchor.constraint(equalTo: choiceBtnA.bottomAnchor, constant: 20), choiceBtnB.centerXAnchor.constraint(equalTo: boardBGView.centerXAnchor), choiceBtnB.heightAnchor.constraint(equalToConstant: 47), choiceBtnB.widthAnchor.constraint(equalToConstant: 211), markA.leadingAnchor.constraint(equalTo: choiceBtnA.trailingAnchor, constant: 4), markA.centerYAnchor.constraint(equalTo: choiceBtnA.centerYAnchor), markA.heightAnchor.constraint(equalToConstant: 40), markA.widthAnchor.constraint(equalToConstant: 40), markB.leadingAnchor.constraint(equalTo: choiceBtnB.trailingAnchor, constant: 4), markB.centerYAnchor.constraint(equalTo: choiceBtnB.centerYAnchor), markB.heightAnchor.constraint(equalToConstant: 40), markB.widthAnchor.constraint(equalToConstant: 40), nativeAdContainer.topAnchor.constraint(equalTo: choiceBtnB.bottomAnchor, constant: 10), nativeAdContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), nativeAdContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), nativeAdContainer.heightAnchor.constraint(equalToConstant: 170), // New UI components: Right-side Labels and Button gainPowerBtn.topAnchor.constraint(equalTo: userInfoBGView.topAnchor,constant: 10), gainPowerBtn.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), gainPowerBtn.widthAnchor.constraint(equalToConstant: 110), // Adjust width to fit content gainPowerBtn.heightAnchor.constraint(equalToConstant: 37), todayCountLabel.topAnchor.constraint(equalTo: gainPowerBtn.bottomAnchor, constant: 10), todayCountLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), historyCountLabel.topAnchor.constraint(equalTo: todayCountLabel.bottomAnchor, constant: 5), historyCountLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20) ]) } // MARK: - Get Power Button Countdown Logic private func startGetPowerCountdownIfNeeded() { guard !didStartPowerCountdown else { return } didStartPowerCountdown = true startGetPowerCountdown() } private func startCooldownIfRewardFlowDone() { // Start only when server addPower succeeded AND video has been closed guard rewardPowerOK && rewardVideoClosed else { return } startGetPowerCountdownIfNeeded() // reset flags for next cycle rewardPowerOK = false rewardVideoClosed = false // 注意:不在这里清理pendingToastMessage,让Toast先显示 } private func startGetPowerCountdown() { // Disable tapping while counting down gainPowerBtn.isEnabled = false // Build a gray overlay that blocks touches if powerCountdownOverlay == nil { let overlay = UIView() overlay.translatesAutoresizingMaskIntoConstraints = false overlay.backgroundColor = UIColor.black.withAlphaComponent(0.5) overlay.isUserInteractionEnabled = true // intercept touches while disabled overlay.layer.cornerRadius = 18.5 overlay.clipsToBounds = true let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.font = UIFont.boldSystemFont(ofSize: 14) label.textColor = .white label.textAlignment = .center overlay.addSubview(label) NSLayoutConstraint.activate([ label.centerXAnchor.constraint(equalTo: overlay.centerXAnchor), label.centerYAnchor.constraint(equalTo: overlay.centerYAnchor) ]) gainPowerBtn.addSubview(overlay) NSLayoutConstraint.activate([ overlay.leadingAnchor.constraint(equalTo: gainPowerBtn.leadingAnchor), overlay.trailingAnchor.constraint(equalTo: gainPowerBtn.trailingAnchor), overlay.topAnchor.constraint(equalTo: gainPowerBtn.topAnchor), overlay.bottomAnchor.constraint(equalTo: gainPowerBtn.bottomAnchor) ]) powerCountdownOverlay = overlay powerCountdownLabel = label } powerRemainingSeconds = staminaWaitSec powerCountdownLabel?.text = "\(powerRemainingSeconds)s" powerCountdownTimer?.invalidate() powerCountdownTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(handlePowerCountdownTick), userInfo: nil, repeats: true) if let timer = powerCountdownTimer { RunLoop.main.add(timer, forMode: .common) } } @objc private func handlePowerCountdownTick() { powerRemainingSeconds -= 1 if powerRemainingSeconds > 0 { powerCountdownLabel?.text = "\(powerRemainingSeconds)s" } else { powerCountdownTimer?.invalidate() powerCountdownTimer = nil // Re-enable the button and remove overlay gainPowerBtn.isEnabled = true powerCountdownOverlay?.removeFromSuperview() powerCountdownOverlay = nil powerCountdownLabel = nil } } deinit { interstitialTimer?.invalidate() interstitialTimer = nil nativeTimer?.invalidate() nativeTimer = nil powerCountdownTimer?.invalidate() } // Keep a reference to the active Power popup (weak to avoid retain cycle) private weak var activePowerPopup: EnergyPanelView? // System loading overlay private var loadingOverlay: UIView? private var loadingSpinner: UIActivityIndicatorView? // MARK: - Button Actions @objc private func optionButtonTapped(_ sender: UIButton) { if sender == choiceBtnA || sender == choiceBtnB { handleAnswerSelection(selectedButton: sender) } } @objc private func handleGetPowerButtonTap() { //若已有体力,则提示并返回 let currentPower = UserDefaults.standard.object(forKey: "power") as? Int ?? 0 if canStackPower == 1 && currentPower > 0 { showToast(message: "已有体力请先答题") return } let powerPopup = EnergyPanelView() powerPopup.translatesAutoresizingMaskIntoConstraints = false powerPopup.delegate = self // 用最新登录信息配置;若没有,则从 UserDefaults 兜底 if let info = accountInfo { powerPopup.configure(with: info) self.activePowerPopup = powerPopup } else { let nick = UserDefaults.standard.string(forKey: "nickname") ?? "XXXX" let role = UserDefaults.standard.string(forKey: "roleID") ?? "XXXX" let reg = UserDefaults.standard.string(forKey: "registryTimeStr") ?? "--" let today = UserDefaults.standard.object(forKey: "todayAnswerCount") as? Int ?? 0 let history = UserDefaults.standard.object(forKey: "historyAnswerCount") as? Int ?? 0 let head = UserDefaults.standard.string(forKey: "headImgURL") let lastLogin = UserDefaults.standard.string(forKey: "lastLoginTimeStr") ?? "--" let info = LoginUserInfo( nickName: nick, userId: role, registryTimeStr: reg, todayAnswerCount: today, historyAnswerCount: history, headImgURL: head, lastLoginTimeStr: lastLogin, answerLogs: UserDefaults.standard.stringArray(forKey: "answerLogs") ?? [] ) powerPopup.configure(with: info) self.activePowerPopup = powerPopup } view.addSubview(powerPopup) NSLayoutConstraint.activate([ powerPopup.topAnchor.constraint(equalTo: view.topAnchor), powerPopup.bottomAnchor.constraint(equalTo: view.bottomAnchor), powerPopup.leadingAnchor.constraint(equalTo: view.leadingAnchor), powerPopup.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) } func energyPanelDidTapAcquire(_ panel: EnergyPanelView) { // 先把弹窗移除(可按需要保留) panel.removeFromSuperview() self.activePowerPopup = nil // 展示激励广告 showRewardAd() } // MARK: - PowerPopupViewDelegate (Close) func energyPanelDidDismiss(_ panel: EnergyPanelView) { // 关闭体力弹窗后刷新广告 removeBanner() loadBannerAd() removeNativeAd() loadNativeAd() self.activePowerPopup = nil } private func showRealNamePopup() { let realNamePopup = IdentityGateView() realNamePopup.translatesAutoresizingMaskIntoConstraints = false view.addSubview(realNamePopup) NSLayoutConstraint.activate([ realNamePopup.topAnchor.constraint(equalTo: view.topAnchor), realNamePopup.bottomAnchor.constraint(equalTo: view.bottomAnchor), realNamePopup.leadingAnchor.constraint(equalTo: view.leadingAnchor), realNamePopup.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) } // MARK: - System Loading Helpers private func showSystemLoading() { guard loadingOverlay == nil else { return } let overlay = UIView() overlay.translatesAutoresizingMaskIntoConstraints = false overlay.backgroundColor = UIColor.clear overlay.isUserInteractionEnabled = true // block touches let spinner = UIActivityIndicatorView(style: .large) spinner.translatesAutoresizingMaskIntoConstraints = false spinner.startAnimating() spinner.hidesWhenStopped = true overlay.addSubview(spinner) view.addSubview(overlay) NSLayoutConstraint.activate([ overlay.topAnchor.constraint(equalTo: view.topAnchor), overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor), overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor), overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor), spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor), spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor) ]) loadingOverlay = overlay loadingSpinner = spinner } private func hideSystemLoading() { loadingSpinner?.stopAnimating() loadingOverlay?.removeFromSuperview() loadingSpinner = nil loadingOverlay = nil } // MARK: - Default Config private func fetchDefaultConfig(channel:Channel) { guard let url = URL(string: apiBaseURL + getDefaultConfig) else { print("[Config] Invalid getDefaultConfig URL") return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body: [String: Any] = ["ditchId": channel.id] request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: []) if let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) { print("[Config] Request URL: \(url)") print("[Config] Request Headers: \(request.allHTTPHeaderFields ?? [:])") print("[Config] Request Body: \(bodyString)") } URLSession.shared.dataTask(with: request) { [weak self] data, response, error in if let http = response as? HTTPURLResponse { print("[Config] Response Code: \(http.statusCode)") print("[Config] Response Headers: \(http.allHeaderFields)") } if let data = data, let s = String(data: data, encoding: .utf8) { print("[Config] Response Body: \(s)") } if let error = error { print("[Config] fetch error: \(error)") // 接口失败时,使用默认配置加载广告 DispatchQueue.main.async { [weak self] in self?.reloadAllAdsWithNewConfig() self?.startInterstitialScheduleIfNeeded() } return } guard let data = data else { DispatchQueue.main.async { [weak self] in self?.reloadAllAdsWithNewConfig() self?.startInterstitialScheduleIfNeeded() } return } // Parse JSON: { code, message, data: { powerWaitTime, interstitialIntervalTime, takuBannerPid, takuInterstitialPid, takuNativePid, takuRewardPid, isAllowNativeAutoRefresh } } var newPowerWait: Int? var newInterstitial: Int? var newNativeInterval: Int? var newCanAccumulation: Int? var newBannerPid: String? var newInterstitialPid: String? var newNativePid: String? var newRewardPid: String? var newIsAllowNativeAutoRefresh: Bool? if let obj = try? JSONSerialization.jsonObject(with: data, options: []), let dict = obj as? [String: Any], let dataDict = dict["data"] as? [String: Any] { if let p = dataDict["powerWaitTime"] as? Int { newPowerWait = p } if let pStr = dataDict["powerWaitTime"] as? String, let p = Int(pStr) { newPowerWait = p } if let i = dataDict["interstitialIntervalTime"] as? Int { newInterstitial = i } if let iStr = dataDict["interstitialIntervalTime"] as? String, let i = Int(iStr) { newInterstitial = i } if let n = dataDict["flowIntervalTime"] as? Int { newNativeInterval = n } if let nStr = dataDict["flowIntervalTime"] as? String, let n = Int(nStr) { newNativeInterval = n } if let c = dataDict["canAccumulation"] as? Int { newCanAccumulation = c } if let cStr = dataDict["canAccumulation"] as? String, let c = Int(cStr) { newCanAccumulation = c } if let cBool = dataDict["canAccumulation"] as? Bool { newCanAccumulation = cBool ? 1 : 0 } // 获取广告placement ID if let bannerPid = dataDict["takuBannerPid"] as? String, !bannerPid.isEmpty { newBannerPid = bannerPid } if let interstitialPid = dataDict["takuInterstitialPid"] as? String, !interstitialPid.isEmpty { newInterstitialPid = interstitialPid } if let nativePid = dataDict["takuNativePid"] as? String, !nativePid.isEmpty { newNativePid = nativePid } if let rewardPid = dataDict["takuRewardPid"] as? String, !rewardPid.isEmpty { newRewardPid = rewardPid } // 获取原生广告自动刷新开关 if let allowRefresh = dataDict["canAllowAutoRefresh"] as? Bool { newIsAllowNativeAutoRefresh = allowRefresh } if let allowRefreshStr = dataDict["canAllowAutoRefresh"] as? String { if allowRefreshStr.lowercased() == "true" { newIsAllowNativeAutoRefresh = true } else if allowRefreshStr.lowercased() == "false" { newIsAllowNativeAutoRefresh = false } } if let allowRefreshInt = dataDict["canAllowAutoRefresh"] as? Int { newIsAllowNativeAutoRefresh = allowRefreshInt != 0 } } DispatchQueue.main.async { [weak self] in if let p = newPowerWait, p > 0 { self?.staminaWaitSec = p print("[Config] Applied powerWaitTime = \(p)s") } self?.startGetPowerCountdownIfNeeded() if let i = newInterstitial, i > 0 { self?.interIntervalSec = i print("[Config] Applied interstitialIntervalTime = \(i)s") } self?.startInterstitialScheduleIfNeeded() if let n = newNativeInterval, n > 0 { self?.nativeIntervalSec = n print("[Config] Applied flowIntervalTime = \(n)s") } if let c = newCanAccumulation { self?.canStackPower = c print("[Config] Applied canAccumulation = \(c)") } // 更新广告placement ID if let bannerPid = newBannerPid { self?.bannerPlacementID = bannerPid print("[Config] Applied takuBannerPid = \(bannerPid)") } if let interstitialPid = newInterstitialPid { self?.interstitialPlacementID = interstitialPid print("[Config] Applied takuInterstitialPid = \(interstitialPid)") } if let nativePid = newNativePid { self?.nativeRenderPlacementID = nativePid print("[Config] Applied takuNativePid = \(nativePid)") } if let rewardPid = newRewardPid { self?.rewardPlacementID = rewardPid print("[Config] Applied takuRewardPid = \(rewardPid)") } // 更新原生广告自动刷新开关 if let allowRefresh = newIsAllowNativeAutoRefresh { self?.isAllowNativeAutoRefresh = allowRefresh print("[Config] Applied canAllowAutoRefresh = \(allowRefresh)") } // 配置更新后,重新加载所有广告 self?.reloadAllAdsWithNewConfig() } }.resume() } private func startInterstitialScheduleIfNeeded() { guard interstitialTimer == nil else { return } let interval = max(5, TimeInterval(interIntervalSec)) // safety floor interstitialTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(handleInterstitialTick), userInfo: nil, repeats: true) if let t = interstitialTimer { RunLoop.main.add(t, forMode: .common) } print("[Config] Interstitial timer started with interval = \(interval)s") } @objc private func handleInterstitialTick() { // Attempt to show an interstitial; if not ready, trigger a load if ATAdManager.shared().interstitialReady(forPlacementID: interstitialPlacementID) { showInterstitialAd() } else { loadInterstitialAd() } } // MARK: - Native Ad Auto Refresh private func startNativeAdRefreshTimer() { guard nativeTimer == nil else { return } let interval = max(5, TimeInterval(nativeIntervalSec)) // safety floor nativeTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(handleNativeAdTick), userInfo: nil, repeats: true) if let t = nativeTimer { RunLoop.main.add(t, forMode: .common) } print("[Config] Native ad timer started with interval = \(interval)s") } private func stopNativeAdRefreshTimer() { nativeTimer?.invalidate() nativeTimer = nil print("[Config] Native ad timer stopped") } @objc private func handleNativeAdTick() { // 刷新原生广告 removeNativeAd() loadNativeAd() print("[Config] Native ad refreshed") } // MARK: - Ad Reload with New Config private func reloadAllAdsWithNewConfig() { print("[Config] Reloading all ads with new placement IDs") // 重新加载所有广告 loadBannerAd() loadInterstitialAd() loadNativeAd() loadRewardAd() } // MARK: - Application Lifecycle @objc private func appDidEnterForeground() { print("[App] App did enter foreground, refreshing native ad") // 应用从后台进入前台时刷新原生广告 refreshNativeAdOnAppBecomeActive() } private func refreshNativeAdOnAppBecomeActive() { // 移除当前原生广告 removeNativeAd() // 重新加载原生广告 loadNativeAd() print("[App] Native ad refreshed on app become active") } // MARK: - Toast Helper /// 延迟显示Toast消息,等待激励视频关闭后显示 private func showToastAfterVideoClosed(message: String) { print("[Toast] Setting pending message: \(message), videoClosed: \(rewardVideoClosed)") pendingToastText = message // 如果视频已经关闭,立即显示 if rewardVideoClosed { print("[Toast] Video already closed, showing immediately") showPendingToast() } else { print("[Toast] Video not closed yet, waiting for close event") } } /// 显示待显示的Toast消息 private func showPendingToast() { guard let message = pendingToastText else { print("[Toast] No pending message to show") return } print("[Toast] Showing pending message: \(message)") pendingToastText = nil showToast(message: message) } private func showToast(message: String) { // Avoid stacking multiple toasts let tag = 9527 if let existing = view.viewWithTag(tag) { existing.removeFromSuperview() } let toastLabel = UILabel() toastLabel.text = message toastLabel.font = UIFont.systemFont(ofSize: 15, weight: .medium) toastLabel.textColor = .white toastLabel.backgroundColor = UIColor.black.withAlphaComponent(0.8) toastLabel.textAlignment = .center toastLabel.alpha = 0.0 toastLabel.layer.cornerRadius = 16 toastLabel.clipsToBounds = true toastLabel.numberOfLines = 0 toastLabel.tag = tag toastLabel.translatesAutoresizingMaskIntoConstraints = false view.addSubview(toastLabel) // Padding let horizontalPadding: CGFloat = 24 let verticalPadding: CGFloat = 12 let maxWidth = view.frame.width - 40 let size = toastLabel.sizeThatFits(CGSize(width: maxWidth - horizontalPadding * 2, height: CGFloat.greatestFiniteMagnitude)) let width = min(size.width + horizontalPadding * 2, maxWidth) let height = size.height + verticalPadding * 2 // Center horizontally, bottom at ~20% above bottom NSLayoutConstraint.activate([ toastLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), toastLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), toastLabel.widthAnchor.constraint(equalToConstant: width), toastLabel.heightAnchor.constraint(equalToConstant: height) ]) UIView.animate(withDuration: 0.25, animations: { toastLabel.alpha = 1.0 }) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { UIView.animate(withDuration: 0.25, animations: { toastLabel.alpha = 0.0 }) { _ in toastLabel.removeFromSuperview() } } } } // MARK: - Image Loading (Icon) private static let imageCache = NSCache() private func setIconImage(with urlString: String?) { guard let s = urlString, !s.isEmpty, let url = URL(string: s) else { self.iconImageView.image = UIImage(named: "catlogos") return } if let cached = QuizStageController.imageCache.object(forKey: url as NSURL) { self.iconImageView.image = cached return } URLSession.shared.dataTask(with: url) { data, _, _ in guard let data = data, let img = UIImage(data: data) else { return } QuizStageController.imageCache.setObject(img, forKey: url as NSURL) DispatchQueue.main.async { [weak self] in self?.iconImageView.image = img } }.resume() } // MARK: - Question List private func fetchQuestionList() { guard var components = URLComponents(string: apiBaseURL + getQuestionListPath) else { print("[Question] Invalid question list URL") return } // If backend needs appId, pass it; harmless if ignored components.queryItems = [ URLQueryItem(name: "appId", value: kTakuAppID) ] guard let url = components.url else { return } var request = URLRequest(url: url) request.httpMethod = "GET" print("[Question] Request URL: \(url)") URLSession.shared.dataTask(with: request) { [weak self] data, response, error in if let http = response as? HTTPURLResponse { print("[Question] Response Code: \(http.statusCode)") print("[Question] Response Headers: \(http.allHeaderFields)") } if let data = data, let s = String(data: data, encoding: .utf8) { print("[Question] Response Body: \(s)") } if let error = error { print("[Question] fetch error: \(error)") return } guard let data = data else { return } let parsed = self?.parseQuestionList(from: data) ?? [] DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.questions = parsed // 根据登录返回的 lastQuestionId(或本地缓存)定位:从其"下一关"开始 let lastQid = self.lastQuestionIdFromLogin ?? UserDefaults.standard.string(forKey: "lastQuestionId") if let lq = lastQid, !lq.isEmpty, let idx = self.questions.firstIndex(where: { $0.id == lq }) { let nextIndex = idx + 1 self.currentQuestionIndex = (nextIndex < self.questions.count) ? nextIndex : 0 } else { // 为空或没匹配上:从第一关开始 self.currentQuestionIndex = 0 } self.renderCurrentQuestion() } }.resume() } private func parseQuestionList(from data: Data) -> [QAQuestion] { var result: [QAQuestion] = [] guard let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], let arr = obj["data"] as? [[String: Any]] else { return result } for q in arr { let qid = (q["questionId"] as? String) ?? "" let content = (q["questionContent"] as? String) ?? "" let correct = (q["correctItem"] as? String) ?? "" var items: [QAItem] = [] if let itemList = q["itemList"] as? [[String: Any]] { for it in itemList { let iid = (it["itemId"] as? String) ?? "" let ic = (it["itemContent"] as? String) ?? "" items.append(QAItem(id: iid, content: ic)) } } if !qid.isEmpty && !content.isEmpty && !items.isEmpty { result.append(QAQuestion(id: qid, content: content, correctItemId: correct, items: items)) } } return result } private func renderCurrentQuestion() { guard currentQuestionIndex >= 0, currentQuestionIndex < questions.count else { return } let q = questions[currentQuestionIndex] promptLabel.text = q.content // Reset indicators markA.isHidden = true markB.isHidden = true verdictLabel.text = "请选择正确答案" // Fill two options (use前两个) if q.items.indices.contains(0) { choiceBtnA.setTitle(q.items[0].content, for: .normal) option1ItemId = q.items[0].id } else { choiceBtnA.setTitle("选项A", for: .normal) option1ItemId = nil } if q.items.indices.contains(1) { choiceBtnB.setTitle(q.items[1].content, for: .normal) option2ItemId = q.items[1].id } else { choiceBtnB.setTitle("选项B", for: .normal) option2ItemId = nil } // Reset option button backgrounds to default choiceBtnA.setBackgroundImage(UIImage(named: "res_o67j6d3t"), for: .normal) choiceBtnB.setBackgroundImage(UIImage(named: "res_o67j6d3t"), for: .normal) // Ensure disabled/gray state persists across questions let currentPower = UserDefaults.standard.object(forKey: "power") as? Int ?? 0 applyPowerToOptionButtons(currentPower) // 重置答题状态,允许答题 isAnswering = false // 更新关卡显示 levelBadge.setTitle("关卡:\(currentQuestionIndex + 1)", for: .normal) // 题目开始计时点 questionStartAt = Date() } private func handleAnswerSelection(selectedButton: UIButton) { // 防止重复答题 guard !isAnswering else { print("[答题] 正在处理中,忽略重复点击") return } guard currentQuestionIndex < questions.count else { return } let q = questions[currentQuestionIndex] // 设置答题状态,禁用按钮 isAnswering = true choiceBtnA.isEnabled = false choiceBtnB.isEnabled = false let selectedId: String? let isFirst = (selectedButton == choiceBtnA) if isFirst { selectedId = option1ItemId } else { selectedId = option2ItemId } guard let sid = selectedId else { print("[答题] 选项ID为空") resetAnswerState() return } // 显示加载状态 showSystemLoading() // verdictLabel.text = "提交中..." // 计算答题耗时 let durationSec: Int = { if let start = questionStartAt { return max(0, Int(Date().timeIntervalSince(start))) } return 0 }() // 先请求API,成功后再显示结果 answerQuestion(questionId: q.id, itemId: sid, duration: durationSec, selectedButton: selectedButton) { [weak self] success in guard let self = self else { return } DispatchQueue.main.async { self.hideSystemLoading() if success { // API成功,显示答题结果 self.showAnswerResult(question: q, selectedId: sid, selectedButton: selectedButton, isFirst: isFirst) // 1秒后进入下一题 DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in self?.goToNextQuestion() } } else { // API失败,重置状态允许重新答题 self.resetAnswerState() self.showToast(message: "提交失败,请重试") } } } } // 显示答题结果(正确/错误标记) private func showAnswerResult(question: QAQuestion, selectedId: String, selectedButton: UIButton, isFirst: Bool) { let correctImage = UIImage(named: "res_cwqxdp40") let wrongImage = UIImage(named: "res_4h4cvu8h") // 先隐藏 markA.isHidden = true markB.isHidden = true // 正确项标绿勾 if option1ItemId == question.correctItemId { markA.image = correctImage; markA.isHidden = false } if option2ItemId == question.correctItemId { markB.image = correctImage; markB.isHidden = false } // 如果选错,则在所选按钮旁显示红叉 let isCorrect = (selectedId == question.correctItemId) if !isCorrect { if isFirst { markA.image = wrongImage; markA.isHidden = false } else { markB.image = wrongImage; markB.isHidden = false } verdictLabel.text = "回答错误!" } else { verdictLabel.text = "回答正确!" } // Update selected button background based on correctness if isCorrect { selectedButton.setBackgroundImage(UIImage(named: "res_rqqxjzk3"), for: .normal) } else { selectedButton.setBackgroundImage(UIImage(named: "res_zup626a7"), for: .normal) } } // 重置答题状态(用于失败时恢复) private func resetAnswerState() { isAnswering = false // 恢复按钮状态(根据体力判断) let currentPower = UserDefaults.standard.object(forKey: "power") as? Int ?? 0 let enabled = currentPower > 0 choiceBtnA.isEnabled = enabled choiceBtnB.isEnabled = enabled // 恢复按钮背景 choiceBtnA.setBackgroundImage(UIImage(named: "res_o67j6d3t"), for: .normal) choiceBtnB.setBackgroundImage(UIImage(named: "res_o67j6d3t"), for: .normal) // 隐藏标记 markA.isHidden = true markB.isHidden = true // 恢复提示文字 verdictLabel.text = "请选择正确答案" } // MARK: - Answer Question API private func answerQuestion(questionId: String, itemId: String, duration: Int, selectedButton: UIButton, completion: @escaping (Bool) -> Void) { guard let url = URL(string: apiBaseURL + answerQuestionPath) else { print("[Answer] Invalid URL") completion(false) return } let userId = accountInfo?.userId ?? (UserDefaults.standard.string(forKey: "roleID") ?? "") guard !userId.isEmpty else { print("[Answer] Missing userId") completion(false) return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 15 // 设置15秒超时 let body: [String: Any] = [ "duration": duration, "itemId": itemId, "questionId": questionId, "userId": userId ] request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: []) // Logs if let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) { print("[Answer] Request URL: \(url)") print("[Answer] Request Headers: \(request.allHTTPHeaderFields ?? [:])") print("[Answer] Request Body: \(bodyString)") } URLSession.shared.dataTask(with: request) { [weak self] data, response, error in if let http = response as? HTTPURLResponse { print("[Answer] Response Code: \(http.statusCode)") print("[Answer] Response Headers: \(http.allHeaderFields)") } if let data = data, let s = String(data: data, encoding: .utf8) { print("[Answer] Response Body: \(s)") } if let error = error { print("[Answer] error: \(error)") completion(false) return } guard let data = data, let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { print("[Answer] Invalid response data") completion(false) return } let code = obj["code"] as? Int ?? 0 if code != 200 { let msg = (obj["message"] as? String) ?? (obj["msg"] as? String) ?? "作答上报失败" print("[Answer] Response code not 200: \(msg)") completion(false) return } // 请求成功后扣除体力 + 同步今日/历史答题与作答时间记录 var returnedToday: Int? var returnedHistory: Int? var returnedLogs: [String]? if let dataDict = obj["data"] as? [String: Any] { if let v = dataDict["todayAnswerCount"] as? Int { returnedToday = v } else if let s = dataDict["todayAnswerCount"] as? String, let i = Int(s) { returnedToday = i } if let v = dataDict["historyAnswerCount"] as? Int { returnedHistory = v } else if let s = dataDict["historyAnswerCount"] as? String, let i = Int(s) { returnedHistory = i } if let arr = dataDict["answerRecordTimeList"] as? [String] { returnedLogs = arr } else if let anyArr = dataDict["answerRecordTimeList"] as? [Any] { returnedLogs = anyArr.compactMap { String(describing: $0) } } else if let arr = dataDict["answerRecordList"] as? [String] { returnedLogs = arr } } DispatchQueue.main.async { [weak self] in guard let self = self else { completion(false) return } // 1) 扣体力 let currentPower = UserDefaults.standard.object(forKey: "power") as? Int ?? 0 let newPower = max(0, currentPower - 1) self.staminaBadge.setTitle("体力:\(newPower)", for: .normal) self.applyPowerToOptionButtons(newPower) UserDefaults.standard.set(newPower, forKey: "power") if newPower == 0 { self.showToast(message: "体力不足,请先领取") } // 2) 更新右侧统计(若后端返回) if let t = returnedToday { self.todayCountLabel.text = "今日答题: \(t)题" UserDefaults.standard.set(t, forKey: "todayAnswerCount") } if let h = returnedHistory { self.historyCountLabel.text = "历史答题: \(h)题" UserDefaults.standard.set(h, forKey: "historyAnswerCount") } if let logs = returnedLogs { UserDefaults.standard.set(logs, forKey: "answerLogs") } // 3) 更新内存里的 loginUserInfo,用于后续弹窗展示 if let old = self.accountInfo { let newInfo = LoginUserInfo( nickName: old.nickName, userId: old.userId, registryTimeStr: old.registryTimeStr, todayAnswerCount: returnedToday ?? old.todayAnswerCount, historyAnswerCount: returnedHistory ?? old.historyAnswerCount, headImgURL: old.headImgURL, lastLoginTimeStr: old.lastLoginTimeStr, answerLogs: returnedLogs ?? old.answerLogs ) self.accountInfo = newInfo // 若弹窗正在显示,实时刷新 self.activePowerPopup?.configure(with: newInfo) } else if (returnedToday != nil) || (returnedHistory != nil) || (returnedLogs != nil) { // 构造一个兜底的 LoginUserInfo let nick = UserDefaults.standard.string(forKey: "nickname") ?? "XXXX" let role = UserDefaults.standard.string(forKey: "roleID") ?? "" let reg = UserDefaults.standard.string(forKey: "registryTimeStr") ?? "--" let head = UserDefaults.standard.string(forKey: "headImgURL") let last = UserDefaults.standard.string(forKey: "lastLoginTimeStr") ?? "--" let t = returnedToday ?? (UserDefaults.standard.object(forKey: "todayAnswerCount") as? Int ?? 0) let h = returnedHistory ?? (UserDefaults.standard.object(forKey: "historyAnswerCount") as? Int ?? 0) let logs = returnedLogs ?? (UserDefaults.standard.stringArray(forKey: "answerLogs") ?? []) let info = LoginUserInfo(nickName: nick, userId: role, registryTimeStr: reg, todayAnswerCount: t, historyAnswerCount: h, headImgURL: head, lastLoginTimeStr: last, answerLogs: logs) self.accountInfo = info self.activePowerPopup?.configure(with: info) } // API请求成功,调用完成回调 completion(true) } }.resume() } private func goToNextQuestion() { currentQuestionIndex += 1 if currentQuestionIndex < questions.count { renderCurrentQuestion() } else { // 题目答完:简单重置或提示 verdictLabel.text = "已答完全部题目" // 也可以重置到第一题: // currentQuestionIndex = 0; renderCurrentQuestion() } // 重置答题状态,允许下一题答题 isAnswering = false } // MARK: - Power → Option Buttons private func applyPowerToOptionButtons(_ power: Int) { let enabled = power > 0 && !isAnswering // 体力大于0且不在答题中 DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in guard let self = self else { return } self.choiceBtnA.isEnabled = enabled self.choiceBtnB.isEnabled = enabled // 置灰/恢复 self.choiceBtnA.alpha = enabled ? 1.0 : 0.5 self.choiceBtnB.alpha = enabled ? 1.0 : 0.5 } } // MARK: - Add Power after Reward private func addPower() { guard var comps = URLComponents(string: apiBaseURL + addPowerPath) else { print("[Power] Invalid addPower URL") return } // 优先使用登录后返回的 userId(roleId),否则从持久化读取 let roleId = accountInfo?.userId ?? (UserDefaults.standard.string(forKey: "roleID") ?? "") guard !roleId.isEmpty else { print("[Power] Missing userId for addPower") DispatchQueue.main.async { [weak self] in self?.showToastAfterVideoClosed(message: "缺少用户ID,无法增加体力") } return } // GET /wx/addPower?userId=xxx comps.queryItems = [ URLQueryItem(name: "userId", value: roleId) ] guard let url = comps.url else { print("[Power] Failed to build addPower URL with query") return } var request = URLRequest(url: url) request.httpMethod = "GET" // Request logs (GET,无请求体) print("[Power] addPower Request URL: \(url)") print("[Power] addPower Request Headers: \(request.allHTTPHeaderFields ?? [:])") URLSession.shared.dataTask(with: request) { [weak self] data, response, error in // Response logs if let http = response as? HTTPURLResponse { print("[Power] addPower Response Code: \(http.statusCode)") print("[Power] addPower Response Headers: \(http.allHeaderFields)") } if let data = data, let s = String(data: data, encoding: .utf8) { print("[Power] addPower Response Body: \(s)") } if let error = error { print("[Power] addPower error: \(error)") DispatchQueue.main.async { [weak self] in self?.showToastAfterVideoClosed(message: "网络异常,体力更新失败") } return } guard let data = data, let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return } let code = obj["code"] as? Int ?? 0 if code != 200 { let msg = (obj["message"] as? String) ?? (obj["msg"] as? String) ?? "体力更新失败" DispatchQueue.main.async { [weak self] in self?.showToastAfterVideoClosed(message: msg) } return } var newPower = 0 if let dataDict = obj["data"] as? [String: Any] { if let v = dataDict["power"] as? Int { newPower = v } if let s = dataDict["power"] as? String, let i = Int(s) { newPower = i } } DispatchQueue.main.async { [weak self] in guard let self = self else { return } self.staminaBadge.setTitle("体力:\(newPower)", for: .normal) self.applyPowerToOptionButtons(newPower) UserDefaults.standard.set(newPower, forKey: "power") // 激励成功 + 服务端加体力成功且激励视频关闭后,才启动获取体力按钮的倒计时 self.rewardPowerOK = true self.didStartPowerCountdown = false self.startCooldownIfRewardFlowDone() // 延迟显示成功消息,等待激励视频关闭 self.showToastAfterVideoClosed(message: "体力增加成功!") } }.resume() } // 广告展示开始时间(毫秒),key 为 placementID private var adStartTimes: [String: Int64] = [:] // 当前时间戳(毫秒) private func nowMillis() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000) } // 将毫秒时间戳格式化为 "yyyy-MM-dd HH:mm:ss" private func formatMillisToString(_ ms: Int64) -> String { let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000.0) let df = DateFormatter() df.locale = Locale(identifier: "en_US_POSIX") df.timeZone = TimeZone.current // 如果后端要 UTC,就改成 TimeZone(secondsFromGMT: 0) df.dateFormat = "yyyy-MM-dd HH:mm:ss" return df.string(from: date) } // ===== 当天广告观看次数(持久化到 UserDefaults)===== private func adCountKeyForToday() -> String { let df = DateFormatter() df.locale = Locale(identifier: "en_US_POSIX") df.dateFormat = "yyyy-MM-dd" // 本地自然日 return "adCount_" + df.string(from: Date()) } private func incrementTodayAdCount() { let key = adCountKeyForToday() let current = UserDefaults.standard.integer(forKey: key) UserDefaults.standard.set(current + 1, forKey: key) } private func todayAdCount() -> Int { let key = adCountKeyForToday() return UserDefaults.standard.integer(forKey: key) } // MARK: - 数据模型 // 将 TopOn/AnyThink 收益回传转换为服务端接口字段并上报 private func uploadAdRevenue(placementID: String, extra: [AnyHashable: Any]) { // 读取鉴权 token // guard let userInfo = UserDefaults.standard.dictionary(forKey: "userInfo"), // let token = userInfo["token"] as? String else { // print("[AD-Upload] 无 token,跳过上报") // return // } // 计算 begin / finish(毫秒)并格式化为 "yyyy-MM-dd HH:mm:ss" let beginMs = adStartTimes[placementID] ?? nowMillis() let finishMs = nowMillis() print("testaa \(beginMs) \(String(describing: adStartTimes[placementID])) \(finishMs) \(placementID)") adStartTimes.removeValue(forKey: placementID) // 避免复用旧开始时间 let beginTime = formatMillisToString(beginMs) let finishTime = formatMillisToString(finishMs) // 将 AnyHashable-key 的字典转为 String-key,且递归转为 JSON 可序列化类型 func jsonSafe(_ value: Any) -> Any { if value is String || value is NSNumber || value is NSNull { return value } if let d = value as? [String: Any] { return d.mapValues { jsonSafe($0) } } if let d = value as? [AnyHashable: Any] { var nd: [String: Any] = [:] d.forEach { nd[String(describing: $0.key)] = jsonSafe($0.value) } return nd } if let arr = value as? [Any] { return arr.map { jsonSafe($0) } } if let v = value as? NSValue { // 处理常见 CoreGraphics 结构体;其余转描述串 let type = String(cString: v.objCType) #if canImport(CoreGraphics) if type == "{CGSize=dd}" { let s = v.cgSizeValue return ["width": s.width, "height": s.height] } else if type == "{CGPoint=dd}" { let p = v.cgPointValue return ["x": p.x, "y": p.y] } else if type == "{CGRect={CGPoint=dd}{CGSize=dd}}" { let r = v.cgRectValue return ["x": r.origin.x, "y": r.origin.y, "width": r.size.width, "height": r.size.height] } #endif return v.description } if let date = value as? Date { return ISO8601DateFormatter().string(from: date) } if let url = value as? URL { return url.absoluteString } return String(describing: value) } // 注意:这里用 jsonSafe 处理每个值 var extraDict: [String: Any] = [:] extra.forEach { (k, v) in extraDict[String(describing: k)] = jsonSafe(v) } // 便捷取值工具 func str(_ key: String) -> String { if let v = extraDict[key] { return String(describing: v) } return "" } func intVal(_ key: String) -> Int { if let v = extraDict[key] as? Int { return v } if let s = extraDict[key] as? String, let i = Int(s) { return i } if let d = extraDict[key] as? Double { return Int(d) } return 0 } func dbl(_ key: String) -> Double { if let v = extraDict[key] as? Double { return v } if let s = extraDict[key] as? String, let d = Double(s) { return d } if let i = extraDict[key] as? Int { return Double(i) } return 0 } // —— 字段映射(对齐安卓) —— // adSourceId let adSourceId = intVal("adsource_id") // networkFormId / networkName / networkPlacementId let networkFormId = intVal("network_firm_id") let networkName = str("network_name") let networkPlacementId = str("network_placement_id") // placementId(iOS 回传里一般是 adunit_id;若无,用回调给的 placementID) let placementIdValue = str("adunit_id").isEmpty ? placementID : str("adunit_id") // recordId(有的 key 叫 requestId,也可能是 req_id,兜底) let recordId = str("requestId").isEmpty ? str("req_id") : str("requestId") // revenue(publisher_revenue) let revenue = dbl("publisher_revenue") // userId / nickName let userId = UserDefaults.standard.string(forKey: "roleID") ?? "" let iosId = UIDevice.current.identifierForVendor?.uuidString ?? "" let nickName = UserDefaults.standard.string(forKey: "memberName") ?? "" // ecpm:优先取 adsource_price;若没有,用 revenue*1000 估算一个(单位同 revenue) var ecpmStr = str("adsource_price") if ecpmStr.isEmpty { let ecpm = revenue * 1000.0 ecpmStr = String(format: "%.6f", ecpm) } // adSourceIndex / adSourceType(优先 adsource_bid_type: 0=非竞价, 1=竞价) let adSourceIndex = intVal("adsource_index") // adSourceType 依据 adunit_format 映射:Native->0, RewardedVideo->1, Banner->2, Interstitial->3 let adUnitFormatRaw = str("adunit_format") let adUnitFormat = adUnitFormatRaw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let adSourceType: Int switch adUnitFormat { case "native": adSourceType = 0 case "rewardedvideo", "rewarded_video", "rewarded": adSourceType = 1 case "banner": adSourceType = 2 case "interstitial": adSourceType = 3 default: // 兜底逻辑:保持原有取值(若 SDK 提供 adsource_bid_type 则沿用) adSourceType = intVal("adsource_bid_type") } // resultJson:把 SDK 回传完整塞进去,便于排查 let resultJsonData = try? JSONSerialization.data(withJSONObject: extraDict, options: []) let resultJson = resultJsonData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}" // appId:iOS 若无可用 App ID,可用 Bundle Identifier 兜底 let appId = kTakuAppID let adCount = todayAdCount() // 组装请求体(与安卓 map 字段完全一致) let body: [String: Any] = [ "adSourceId": adSourceId, "beginTime": beginTime, "finishTime": finishTime, "networkFormId": networkFormId, "networkName": networkName, "networkPlacementId": networkPlacementId, "nickName": nickName, "placementId": placementIdValue, "recordId": recordId, "revenue": revenue, "userId": userId, "ecpm": ecpmStr, "adSourceIndex": adSourceIndex, "adSourceType": adSourceType, "resultJson": resultJson, "appId": appId, "adCount": adCount, "iosId": iosId, "begintimestamp": beginMs, "finishtimestamp": finishMs ] // print("testggg \(body)") // 发起上报 let urlString = apiBaseURL + adSaveRecordPath // print("[AD-Upload] URL \(urlString)") guard let url = URL(string: urlString) else { print("[AD-Upload] URL 无效: \(urlString)") return } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") // request.setValue(token, forHTTPHeaderField: "Authorization") request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: []) if let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) { print("[AD-Upload] Request URL: \(url)") print("[AD-Upload] Request Headers: \(request.allHTTPHeaderFields ?? [:])") print("[AD-Upload] Request Body: \(bodyString)") } URLSession.shared.dataTask(with: request) { [weak self] data, response, error in if let error = error { print("[AD-Upload] 网络错误: \(error.localizedDescription)") return } if let http = response as? HTTPURLResponse { print("[AD-Upload] 响应状态码: \(http.statusCode)") } if let data = data, let s = String(data: data, encoding: .utf8) { print("[AD-Upload] 响应: \(s)") // 解析 JSON,并在 code == 301 时弹 toast 并延迟跳转登录 if let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { let codeVal: Int = { if let i = obj["code"] as? Int { return i } if let str = obj["code"] as? String, let i = Int(str) { return i } return 0 }() if codeVal == 301 { let message = (obj["message"] as? String) ?? (obj["msg"] as? String) ?? "软件被手机系统限制!请稍后再试!" DispatchQueue.main.async { [weak self] in self?.showToast(message: message) DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in self?.switchToLoginScreen() } } } } } }.resume() } private func switchToLoginScreen() { // // 清除用户登录状态 // UserDefaults.standard.set(false, forKey: "isLoggedIn") // UserDefaults.standard.removeObject(forKey: "userInfo") // 创建登录界面 let loginVC = EntryGateController() // let navController = UINavigationController(rootViewController: loginVC) // 获取当前窗口 guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return } // 设置根视图控制器为登录界面 window.rootViewController = loginVC // 添加切换动画 UIView.transition(with: window, duration: 0.5, options: .transitionCrossDissolve, animations: nil, completion: nil) } // MARK: - OverlayPanelAgent func overlayPanelDidDismiss(_ panel: OverlayPanelView) { // 刷新横幅广告 removeBanner() loadBannerAd() // 刷新信息流(原生)广告 removeNativeAd() loadNativeAd() } } // MARK: - UIColor Extension extension UIColor { convenience init?(hexString: String) { var hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() if hex.hasPrefix("#") { hex.remove(at: hex.startIndex) } var rgbValue: UInt64 = 0 Scanner(string: hex).scanHexInt64(&rgbValue) let r = CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0 let g = CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0 let b = CGFloat(rgbValue & 0x0000FF) / 255.0 self.init(red: r, green: g, blue: b, alpha: 1.0) } }