QuizStageController.swift 96 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270
  1. //
  2. // QuestionViewController.swift
  3. // RoderickRalph
  4. //
  5. // Created by Neoa on 2025/8/19.
  6. //
  7. import Foundation
  8. import UIKit
  9. import AnyThinkBanner
  10. import AnyThinkSDK
  11. import AnyThinkInterstitial
  12. import AnyThinkNative
  13. import AnyThinkRewardedVideo
  14. class QuizStageController: UIViewController, ATAdLoadingDelegate, ATBannerDelegate, ATInterstitialDelegate, ATNativeADDelegate, ATRewardedVideoDelegate, EnergyPanelAgent, OverlayPanelAgent{
  15. // MARK: - UI Components
  16. // 实际使用的广告placement ID(从接口获取或使用默认值)
  17. private var bannerPlacementID: String = "b68d88cc2ab33d"
  18. private var interstitialPlacementID: String = "b68d88cc36bca8"
  19. private var nativeRenderPlacementID: String = "b68db6c2fdfcac"
  20. private var rewardPlacementID: String = "b68d88cc20bb14"
  21. private var nativeAdView: ATNativeADView?
  22. private var selfRenderView: RenderUnitView?
  23. private var nativeAdOffer: ATNativeAdOffer?
  24. private var nativeAdContainer: UIView = {
  25. let view = UIView()
  26. view.translatesAutoresizingMaskIntoConstraints = false
  27. view.backgroundColor = .clear
  28. view.isUserInteractionEnabled = true
  29. return view
  30. }()
  31. private var bannerView: ATBannerView?
  32. private var bannerContainer: UIView = {
  33. let container = UIView()
  34. container.translatesAutoresizingMaskIntoConstraints = false
  35. container.backgroundColor = .clear
  36. container.isUserInteractionEnabled = true
  37. container.clipsToBounds = false // 确保子视图不会被裁剪
  38. return container
  39. }()
  40. private var bannerHeightConstraint: NSLayoutConstraint?
  41. private var bannerSize: CGSize {
  42. let screenWidth = UIScreen.main.bounds.width
  43. let aspectRatio: CGFloat = 320 / 50
  44. let bannerWidth = screenWidth
  45. let bannerHeight = bannerWidth / aspectRatio
  46. return CGSize(width: bannerWidth, height: bannerHeight)
  47. }
  48. // MARK: - Channel API (moved to LoginQuestionViewController)
  49. private let getDefaultConfig = "/wx/getByDitchId"
  50. private let getQuestionListPath = "/question/list"
  51. private let addPowerPath = "/wx/addPower"
  52. private let answerQuestionPath = "/question/answerQuestion"
  53. private let adSaveRecordPath = "/ad/saveRecord"
  54. // MARK: - Get Power Countdown
  55. private var powerCountdownOverlay: UIView?
  56. private var powerCountdownLabel: UILabel?
  57. private var powerCountdownTimer: Timer?
  58. private var powerRemainingSeconds: Int = 60
  59. private var didStartPowerCountdown = false
  60. // Remote-configured values with sensible defaults
  61. private var staminaWaitSec: Int = 60
  62. private var interIntervalSec: Int = 30
  63. private var interstitialTimer: Timer?
  64. private var nativeIntervalSec: Int = 30
  65. private var nativeTimer: Timer?
  66. private var isAllowNativeAutoRefresh: Bool = true
  67. // 1:不可叠加体力;0:可叠加体力
  68. private var canStackPower: Int = 0
  69. private var accountInfo: LoginUserInfo?
  70. private var selectedChannelRef: Channel?
  71. // Reward flow gating for cooldown
  72. private var rewardPowerOK: Bool = false
  73. private var rewardVideoClosed: Bool = false
  74. private var pendingToastText: String?
  75. // MARK: - Question Data State
  76. private struct QAItem { let id: String; let content: String }
  77. private struct QAQuestion { let id: String; let content: String; let correctItemId: String; let items: [QAItem] }
  78. private var questions: [QAQuestion] = []
  79. private var currentQuestionIndex: Int = 0
  80. private var option1ItemId: String?
  81. private var option2ItemId: String?
  82. private var questionStartAt: Date?
  83. private var lastQuestionIdFromLogin: String?
  84. private var isAnswering: Bool = false // 防止重复答题标志
  85. private let userInfoBGView: UIView = {
  86. let view = UIImageView()
  87. view.contentMode = .scaleToFill
  88. view.image = UIImage(named: "res_ogoxcw01")
  89. view.translatesAutoresizingMaskIntoConstraints = false
  90. return view
  91. }()
  92. private let iconImageView: UIImageView = {
  93. let iv = UIImageView()
  94. iv.image = UIImage(named: "catlogos")
  95. iv.contentMode = .scaleAspectFit
  96. iv.layer.cornerRadius = 29
  97. iv.clipsToBounds = true
  98. iv.translatesAutoresizingMaskIntoConstraints = false
  99. return iv
  100. }()
  101. private let userInfoLabel: UILabel = {
  102. let label = UILabel()
  103. label.text = ""
  104. label.font = UIFont.boldSystemFont(ofSize: 10)
  105. label.textColor = .white
  106. // 添加橘色的文字描边
  107. let strokeTextAttributes: [NSAttributedString.Key: Any] = [
  108. .strokeColor: UIColor.orange, // 设置描边颜色
  109. .strokeWidth: -2.0, // 设置描边宽度,负值为描边,正值为文字内部留白
  110. .foregroundColor: UIColor.white // 设置文字颜色
  111. ]
  112. let attributedString = NSAttributedString(string: label.text ?? "", attributes: strokeTextAttributes)
  113. label.attributedText = attributedString
  114. label.translatesAutoresizingMaskIntoConstraints = false
  115. return label
  116. }()
  117. private let musiceView: UIImageView = {
  118. let iv = UIImageView()
  119. iv.image = UIImage(named: "res_fb2rjej5")
  120. iv.contentMode = .scaleToFill
  121. // iv.layer.cornerRadius = 29
  122. // iv.clipsToBounds = true
  123. iv.translatesAutoresizingMaskIntoConstraints = false
  124. return iv
  125. }()
  126. private let gainPowerBtn: UIButton = {
  127. let button = UIButton(type: .system)
  128. button.setBackgroundImage(UIImage(named: "res_4sw46mis"), for: .normal)
  129. button.setTitle("获取体力", for: .normal)
  130. button.setTitleColor(.white, for: .normal)
  131. button.contentHorizontalAlignment = .left
  132. button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 15, bottom: 0, right: 0) // Adjust left padding
  133. button.translatesAutoresizingMaskIntoConstraints = false
  134. return button
  135. }()
  136. private let todayCountLabel: UILabel = {
  137. let label = UILabel()
  138. label.text = "今日答题: 0题"
  139. label.font = UIFont.systemFont(ofSize: 13)
  140. label.textColor = .white
  141. label.translatesAutoresizingMaskIntoConstraints = false
  142. return label
  143. }()
  144. private let historyCountLabel: UILabel = {
  145. let label = UILabel()
  146. label.text = "历史答题: 0题"
  147. label.font = UIFont.systemFont(ofSize: 13)
  148. label.textColor = .white
  149. label.translatesAutoresizingMaskIntoConstraints = false
  150. return label
  151. }()
  152. private let verdictBGView: UIImageView = {
  153. let iv = UIImageView()
  154. iv.image = UIImage(named: "res_27q0oxly")
  155. iv.contentMode = .scaleToFill
  156. iv.translatesAutoresizingMaskIntoConstraints = false
  157. return iv
  158. }()
  159. private let verdictLabel: UILabel = {
  160. let label = UILabel()
  161. label.text = "请选择正确答案"
  162. label.font = UIFont.systemFont(ofSize: 24, weight: .bold)
  163. label.textAlignment = .center
  164. label.textColor = .white
  165. label.translatesAutoresizingMaskIntoConstraints = false
  166. return label
  167. }()
  168. private let boardBGView: UIImageView = {
  169. let iv = UIImageView()
  170. iv.image = UIImage(named: "res_b0fsh9ql")
  171. iv.contentMode = .scaleToFill
  172. iv.translatesAutoresizingMaskIntoConstraints = false
  173. return iv
  174. }()
  175. private let promptLabel: UILabel = {
  176. let label = UILabel()
  177. label.text = "《二泉映月》是首用什么乐器独奏的曲子"
  178. label.font = UIFont.systemFont(ofSize: 18, weight: .bold)
  179. label.textColor = .black
  180. label.textAlignment = .center
  181. label.numberOfLines = 0
  182. label.translatesAutoresizingMaskIntoConstraints = false
  183. return label
  184. }()
  185. private let levelBadge: UIButton = {
  186. let button = UIButton(type: .system)
  187. button.setBackgroundImage(UIImage(named: "res_fm0lbz7w"), for: .normal)
  188. button.setTitle("关卡:1", for: .normal)
  189. button.setTitleColor(.white, for: .normal)
  190. button.titleLabel?.font = UIFont.systemFont(ofSize: 20)
  191. button.contentHorizontalAlignment = .center
  192. button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) // Adjust left padding
  193. button.isUserInteractionEnabled = false
  194. button.translatesAutoresizingMaskIntoConstraints = false
  195. return button
  196. }()
  197. private let staminaBadge: UIButton = {
  198. let button = UIButton(type: .system)
  199. button.setBackgroundImage(UIImage(named: "res_fm0lbz7w"), for: .normal)
  200. button.setTitle("体力:0", for: .normal)
  201. button.setTitleColor(.white, for: .normal)
  202. button.titleLabel?.font = UIFont.systemFont(ofSize: 20)
  203. button.contentHorizontalAlignment = .center
  204. button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
  205. button.isUserInteractionEnabled = false
  206. button.translatesAutoresizingMaskIntoConstraints = false
  207. return button
  208. }()
  209. private let choiceBtnA: UIButton = {
  210. let button = UIButton(type: .system)
  211. button.setBackgroundImage(UIImage(named: "res_o67j6d3t"), for: .normal)
  212. button.setTitle("二胡", for: .normal)
  213. button.setTitleColor(.white, for: .normal)
  214. button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 24)
  215. button.translatesAutoresizingMaskIntoConstraints = false
  216. button.addTarget(self, action: #selector(optionButtonTapped(_:)), for: .touchUpInside)
  217. return button
  218. }()
  219. private let choiceBtnB: UIButton = {
  220. let button = UIButton(type: .system)
  221. button.setBackgroundImage(UIImage(named: "res_o67j6d3t"), for: .normal)
  222. button.setTitle("钢琴", for: .normal)
  223. button.setTitleColor(.white, for: .normal)
  224. button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 24)
  225. button.translatesAutoresizingMaskIntoConstraints = false
  226. button.addTarget(self, action: #selector(optionButtonTapped(_:)), for: .touchUpInside)
  227. return button
  228. }()
  229. private let markA: UIImageView = {
  230. let iv = UIImageView()
  231. iv.image = UIImage(named: "res_cwqxdp40")
  232. iv.contentMode = .scaleAspectFill
  233. iv.isHidden = true
  234. iv.translatesAutoresizingMaskIntoConstraints = false
  235. return iv
  236. }()
  237. private let markB: UIImageView = {
  238. let iv = UIImageView()
  239. iv.image = UIImage(named: "res_4h4cvu8h")
  240. iv.contentMode = .scaleToFill
  241. iv.isHidden = true
  242. iv.translatesAutoresizingMaskIntoConstraints = false
  243. return iv
  244. }()
  245. // MARK: - Public Methods for Data Transfer from LoginQuestionViewController
  246. func setLoginUserInfo(_ userInfo: LoginUserInfo) {
  247. self.accountInfo = userInfo
  248. // 如果界面已经加载,立即更新UI
  249. if isViewLoaded {
  250. updateUIWithUserInfo(userInfo)
  251. }
  252. }
  253. func setSelectedChannel(_ channel: Channel) {
  254. print("SSSS\(channel.id)")
  255. self.selectedChannelRef = channel
  256. }
  257. private func updateUIWithUserInfo(_ userInfo: LoginUserInfo) {
  258. // 更新右侧统计
  259. todayCountLabel.text = "今日答题: \(userInfo.todayAnswerCount)题"
  260. historyCountLabel.text = "历史答题: \(userInfo.historyAnswerCount)题"
  261. userInfoLabel.text = userInfo.userId
  262. setIconImage(with: userInfo.headImgURL)
  263. // 更新体力显示
  264. let savedPower = UserDefaults.standard.object(forKey: "power") as? Int ?? 0
  265. staminaBadge.setTitle("体力:\(savedPower)", for: .normal)
  266. applyPowerToOptionButtons(savedPower)
  267. }
  268. // MARK: - Lifecycle
  269. override func viewDidLoad() {
  270. super.viewDidLoad()
  271. // navigationController?.setNavigationBarHidden(true, animated: false)
  272. // 先不加载广告,等接口返回配置后再加载
  273. // 这样可以确保使用正确的placement ID
  274. // Set up gestures for both the icon and the label
  275. let iconTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleIconTap))
  276. iconImageView.isUserInteractionEnabled = true
  277. iconImageView.addGestureRecognizer(iconTapGesture)
  278. let labelTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleLabelTap))
  279. userInfoLabel.isUserInteractionEnabled = true
  280. userInfoLabel.addGestureRecognizer(labelTapGesture)
  281. gainPowerBtn.addTarget(self, action: #selector(handleGetPowerButtonTap), for: .touchUpInside)
  282. setupUI()
  283. setupConstraints()
  284. // Prefill icon with saved avatar (headImgURL) or placeholder
  285. if let userInfo = accountInfo {
  286. // 使用传入的用户信息更新UI
  287. updateUIWithUserInfo(userInfo)
  288. } else {
  289. // 使用本地保存的数据
  290. let savedHead = UserDefaults.standard.string(forKey: "headImgURL")
  291. setIconImage(with: savedHead)
  292. // Prefill power and gray out options if needed
  293. let savedPower = UserDefaults.standard.object(forKey: "power") as? Int ?? 0
  294. staminaBadge.setTitle("体力:\(savedPower)", for: .normal)
  295. applyPowerToOptionButtons(savedPower)
  296. }
  297. // Channel selection is now handled in LoginQuestionViewController
  298. // Fetch questions directly since login is complete
  299. fetchQuestionList()
  300. fetchDefaultConfig(channel: self.selectedChannelRef!)
  301. //刷新一次banner
  302. DispatchQueue.main.asyncAfter(deadline: .now() + 7) { [weak self] in
  303. guard let self = self else { return }
  304. self.removeBanner()
  305. self.loadBannerAd()
  306. }
  307. }
  308. override func viewWillAppear(_ animated: Bool) {
  309. super.viewWillAppear(animated)
  310. navigationController?.setNavigationBarHidden(true, animated: animated)
  311. // 监听应用进入前台的通知
  312. NotificationCenter.default.addObserver(
  313. self,
  314. selector: #selector(appDidEnterForeground),
  315. name: UIApplication.willEnterForegroundNotification,
  316. object: nil
  317. )
  318. }
  319. override func viewDidAppear(_ animated: Bool) {
  320. super.viewDidAppear(animated)
  321. }
  322. override func viewWillDisappear(_ animated: Bool) {
  323. super.viewWillDisappear(animated)
  324. // 移除通知监听
  325. NotificationCenter.default.removeObserver(
  326. self,
  327. name: UIApplication.willEnterForegroundNotification,
  328. object: nil
  329. )
  330. }
  331. private func loadNativeAd() {
  332. let extra: [AnyHashable: Any] = [
  333. kATAdLoadingExtraMediaExtraKey: "QuestionVC",
  334. kATExtraInfoNativeAdSizeKey: NSValue(cgSize: CGSize(width: UIScreen.main.bounds.width - 24, height: 170))
  335. ]
  336. // Load the native ad with placement ID
  337. ATAdManager.shared().loadAD(withPlacementID: nativeRenderPlacementID, extra: extra, delegate: self)
  338. }
  339. private func showNativeAdIfReady() {
  340. // 检查广告是否准备好
  341. guard ATAdManager.shared().nativeAdReady(forPlacementID: nativeRenderPlacementID) else {
  342. print("Native ad not ready")
  343. return
  344. }
  345. // 获取广告offer
  346. guard let offer = ATAdManager.shared().getNativeAdOffer(withPlacementID: nativeRenderPlacementID) else {
  347. print("Failed to get native ad offer")
  348. return
  349. }
  350. self.nativeAdOffer = offer
  351. // 创建自渲染视图
  352. let selfRenderView = RenderUnitView(offer: offer)
  353. self.selfRenderView = selfRenderView
  354. // 初始化配置
  355. let config = ATNativeADConfiguration()
  356. config.adFrame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 170)
  357. config.mediaViewFrame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 150)
  358. config.delegate = self
  359. config.rootViewController = self
  360. config.sizeToFit = true
  361. config.videoPlayType = .alwaysAutoPlayType
  362. // 设置logo位置
  363. config.logoViewFrame = CGRect(x: UIScreen.main.bounds.width - 48, y: 140, width: 36, height: 20)
  364. // 创建原生广告视图
  365. let nativeAdView = ATNativeADView(configuration: config, currentOffer: offer, placementID: nativeRenderPlacementID)
  366. self.nativeAdView = nativeAdView
  367. // 获取mediaView
  368. if let mediaView = nativeAdView.getMediaView() {
  369. selfRenderView.installMediaView(mediaView)
  370. }
  371. // 创建可点击组件数组
  372. var clickableViewArray: [UIView] = []
  373. clickableViewArray.append(contentsOf: [
  374. // selfRenderView.iconImageView,
  375. selfRenderView.headlineLabel,
  376. selfRenderView.bodyLabel,
  377. // selfRenderView.ctaLabel,
  378. selfRenderView.heroImageView
  379. ])
  380. // 注册点击事件
  381. nativeAdView.registerClickableViewArray(clickableViewArray)
  382. // 绑定组件
  383. let info = ATNativePrepareInfo.load { prepareInfo in
  384. prepareInfo.textLabel = selfRenderView.bodyLabel
  385. // prepareInfo.advertiserLabel = selfRenderView.advertiserLabel
  386. prepareInfo.titleLabel = selfRenderView.headlineLabel
  387. // prepareInfo.ratingLabel = selfRenderView.ratingLabel
  388. // prepareInfo.iconImageView = selfRenderView.iconImageView
  389. prepareInfo.mainImageView = selfRenderView.heroImageView
  390. // prepareInfo.logoImageView = selfRenderView.logoImageView
  391. // prepareInfo.ctaLabel = selfRenderView.ctaLabel
  392. prepareInfo.dislikeButton = selfRenderView.closeControl
  393. if let mv = selfRenderView.mediaContainer {
  394. prepareInfo.mediaView = mv
  395. }
  396. }
  397. nativeAdView.prepare(with: info)
  398. // 渲染广告
  399. offer.renderer(with: config, selfRenderView: selfRenderView, nativeADView: nativeAdView)
  400. // 添加到容器(必须把 nativeAdView 放到视图层级中,点击与视频追踪才会生效)
  401. nativeAdContainer.subviews.forEach { $0.removeFromSuperview() }
  402. nativeAdContainer.addSubview(nativeAdView)
  403. nativeAdView.translatesAutoresizingMaskIntoConstraints = false
  404. NSLayoutConstraint.activate([
  405. nativeAdView.leadingAnchor.constraint(equalTo: nativeAdContainer.leadingAnchor),
  406. nativeAdView.trailingAnchor.constraint(equalTo: nativeAdContainer.trailingAnchor),
  407. nativeAdView.topAnchor.constraint(equalTo: nativeAdContainer.topAnchor),
  408. nativeAdView.bottomAnchor.constraint(equalTo: nativeAdContainer.bottomAnchor)
  409. ])
  410. // 自渲染视图放入 nativeAdView 内
  411. nativeAdView.addSubview(selfRenderView)
  412. selfRenderView.translatesAutoresizingMaskIntoConstraints = false
  413. NSLayoutConstraint.activate([
  414. selfRenderView.leadingAnchor.constraint(equalTo: nativeAdView.leadingAnchor),
  415. selfRenderView.trailingAnchor.constraint(equalTo: nativeAdView.trailingAnchor),
  416. selfRenderView.topAnchor.constraint(equalTo: nativeAdView.topAnchor),
  417. selfRenderView.bottomAnchor.constraint(equalTo: nativeAdView.bottomAnchor)
  418. ])
  419. print("Native self-render ad displayed successfully")
  420. }
  421. @objc func loadRewardAd() {
  422. var loadConfig: [String: Any] = [:]
  423. // 服务端激励验证透传参数(可选)
  424. loadConfig[kATAdLoadingExtraMediaExtraKey] = "media_val_RewardedVC"
  425. loadConfig[kATAdLoadingExtraUserIDKey] = "rv_test_user_id"
  426. loadConfig[kATAdLoadingExtraRewardNameKey] = "reward_Name"
  427. loadConfig[kATAdLoadingExtraRewardAmountKey] = 1
  428. ATAdManager.shared().loadAD(withPlacementID: rewardPlacementID,
  429. extra: loadConfig,
  430. delegate: self)
  431. }
  432. // MARK: - Show Ad 展示广告
  433. @objc func showRewardAd() {
  434. // 就绪检查
  435. guard ATAdManager.shared().rewardedVideoReady(forPlacementID: rewardPlacementID) else {
  436. loadRewardAd()
  437. return
  438. }
  439. let config = ATShowConfig(scene: "", showCustomExt: "testShowCustomExt")
  440. // Reset reward flow state before showing a new video
  441. rewardPowerOK = false
  442. rewardVideoClosed = false
  443. // 注意:不清除pendingToastMessage,因为可能还有待显示的消息
  444. // 展示
  445. ATAdManager.shared().showRewardedVideo(withPlacementID: rewardPlacementID,
  446. config: config,
  447. in: self,
  448. delegate: self)
  449. }
  450. private func loadBannerAd() {
  451. var extra: [AnyHashable: Any] = [
  452. kATAdLoadingExtraMediaExtraKey: "QuestionVC"
  453. ]
  454. extra[kATAdLoadingExtraBannerAdSizeKey] = NSValue(cgSize: bannerSize)
  455. ATAdManager.shared().loadAD(withPlacementID: bannerPlacementID, extra: extra, delegate: self)
  456. }
  457. private func showBannerIfReady() {
  458. let config = ATShowConfig(scene: nil, showCustomExt: nil)
  459. guard let banner = ATAdManager.shared().retrieveBannerView(forPlacementID: bannerPlacementID, config: config) else { return }
  460. banner.delegate = self
  461. // 确保设置正确的 presentingViewController
  462. banner.presentingViewController = self
  463. banner.translatesAutoresizingMaskIntoConstraints = false
  464. bannerContainer.subviews.forEach { $0.removeFromSuperview() }
  465. bannerContainer.addSubview(banner)
  466. // Add constraints to the banner view
  467. NSLayoutConstraint.activate([
  468. banner.centerXAnchor.constraint(equalTo: bannerContainer.centerXAnchor),
  469. banner.topAnchor.constraint(equalTo: bannerContainer.topAnchor),
  470. banner.widthAnchor.constraint(equalToConstant: bannerSize.width),
  471. banner.heightAnchor.constraint(equalToConstant: bannerSize.height)
  472. ])
  473. // Store the banner view
  474. bannerView = banner
  475. bannerView?.isUserInteractionEnabled = true
  476. // Update the height constraint for the container
  477. bannerHeightConstraint?.constant = bannerSize.height
  478. UIView.animate(withDuration: 0.25) {
  479. self.view.layoutIfNeeded()
  480. }
  481. // 打印调试信息
  482. print("Banner frame: \(banner.frame)")
  483. print("Banner container frame: \(self.bannerContainer.frame)")
  484. }
  485. private func removeBanner() {
  486. bannerView?.destroyBanner()
  487. bannerView?.removeFromSuperview()
  488. bannerView = nil
  489. // Reset the height constraint to 0
  490. bannerHeightConstraint?.constant = 0
  491. UIView.animate(withDuration: 0.25) {
  492. self.view.layoutIfNeeded()
  493. }
  494. }
  495. // MARK: - ATAdLoadingDelegate
  496. func didFinishLoadingAD(withPlacementID placementID: String) {
  497. if placementID == bannerPlacementID {
  498. // 横幅广告加载完成,检查横幅广告是否准备好
  499. if ATAdManager.shared().bannerAdReady(forPlacementID: placementID) {
  500. showBannerIfReady() // 展示横幅广告
  501. }
  502. print("Banner Ad Loaded: \(placementID)")
  503. } else if placementID == interstitialPlacementID {
  504. // 插屏广告加载完成,检查插屏广告是否准备好
  505. if ATAdManager.shared().interstitialReady(forPlacementID: placementID) {
  506. showInterstitialAd() // 展示插屏广告
  507. }
  508. print("Interstitial Ad Loaded: \(placementID)")
  509. } else if placementID == nativeRenderPlacementID {
  510. if ATAdManager.shared().nativeAdReady(forPlacementID: placementID) {
  511. showNativeAdIfReady()
  512. }
  513. print("native Ad Loaded: \(placementID)")
  514. }
  515. }
  516. // MARK: - ATRewardedVideoDelegate(激励回调)
  517. // 激励成功(注意:原 ObjC 选择子为 PlacemenID,保持一致以兼容 SDK)
  518. func rewardedVideoDidRewardSuccess(forPlacemenID placementID: String, extra: [AnyHashable: Any]) {
  519. print("rewardedVideoDidRewardSuccessForPlacemenID:\(placementID) extra:\(extra)")
  520. // 激励成功,向服务器申请体力
  521. addPower()
  522. }
  523. // 视频开始播放
  524. func rewardedVideoDidStartPlaying(forPlacementID placementID: String, extra: [AnyHashable: Any]) {
  525. print("rewardedVideoDidStartPlayingForPlacementID:\(placementID) extra:\(extra)")
  526. adStartTimes[placementID] = nowMillis()
  527. incrementTodayAdCount()
  528. }
  529. // 视频播放结束
  530. func rewardedVideoDidEndPlaying(forPlacementID placementID: String, extra: [AnyHashable: Any]) {
  531. print("rewardedVideoDidEndPlayingForPlacementID:\(placementID) extra:\(extra)")
  532. }
  533. // 视频播放失败(失败后预加载)
  534. func rewardedVideoDidFailToPlay(forPlacementID placementID: String, error: Error, extra: [AnyHashable: Any]) {
  535. let code = (error as NSError).code
  536. print("rewardedVideoDidFailToPlayForPlacementID:\(placementID) error:\(error) extra:\(extra)")
  537. // 预加载
  538. loadRewardAd()
  539. }
  540. // 广告关闭(关闭后预加载)
  541. @objc(rewardedVideoDidCloseForPlacementID:rewarded:extra:)
  542. func rewardedVideoDidClose(forPlacementID placementID: String, rewarded: Bool, extra: [AnyHashable: Any]) {
  543. print("rewardedVideoDidCloseForPlacementID:\(placementID), rewarded:\(rewarded) extra:\(extra)")
  544. // 预加载
  545. loadRewardAd()
  546. // 标记关闭并尝试开启倒计时
  547. rewardVideoClosed = true
  548. print("[Toast] Video closed, pending message: \(pendingToastText ?? "nil")")
  549. startCooldownIfRewardFlowDone()
  550. // 显示待显示的Toast消息
  551. showPendingToast()
  552. }
  553. // 广告点击
  554. @objc(rewardedVideoDidClickForPlacementID:extra:)
  555. func rewardedVideoDidClick(forPlacementID placementID: String, extra: [AnyHashable: Any]) {
  556. print("rewardedVideoDidClickForPlacementID:\(placementID) extra:\(extra)")
  557. }
  558. // Deeplink/跳转
  559. @objc(rewardedVideoDidDeepLinkOrJumpForPlacementID:extra:result:)
  560. func rewardedVideoDidDeepLinkOrJump(forPlacementID placementID: String, extra: [AnyHashable: Any], result success: Bool) {
  561. print("rewardedVideoDidDeepLinkOrJumpForPlacementID:\(placementID) extra:\(extra) success:\(success)")
  562. }
  563. // MARK: - "再看一个"能力(Again)回调
  564. func rewardedVideoAgainDidRewardSuccess(forPlacemenID placementID: String, extra: [AnyHashable: Any]) {
  565. print("rewardedVideoAgainDidRewardSuccessForPlacemenID:\(placementID) extra:\(extra)")
  566. }
  567. func rewardedVideoAgainDidStartPlaying(forPlacementID placementID: String, extra: [AnyHashable: Any]) {
  568. print("rewardedVideoAgainDidStartPlayingForPlacementID:\(placementID) extra:\(extra)")
  569. }
  570. func rewardedVideoAgainDidEndPlaying(forPlacementID placementID: String, extra: [AnyHashable: Any]) {
  571. print("rewardedVideoAgainDidEndPlayingForPlacementID:\(placementID) extra:\(extra)")
  572. }
  573. func rewardedVideoAgainDidFailToPlay(forPlacementID placementID: String, error: Error, extra: [AnyHashable: Any]) {
  574. let code = (error as NSError).code
  575. print("rewardedVideoAgainDidFailToPlayForPlacementID:\(placementID) error:\(error) extra:\(extra)")
  576. }
  577. func rewardedVideoAgainDidClick(forPlacementID placementID: String, extra: [AnyHashable: Any]) {
  578. print("rewardedVideoAgainDidClickForPlacementID:\(placementID) extra:\(extra)")
  579. }
  580. // MARK: - ATNativeAdDelegate
  581. func didShowNativeAd(in adView: ATNativeADView, placementID: String, extra: [AnyHashable: Any]?) {
  582. print("Native ad shown for \(placementID)")
  583. adStartTimes[placementID] = nowMillis()
  584. incrementTodayAdCount()
  585. // 根据配置决定是否开始原生广告自动刷新计时
  586. if isAllowNativeAutoRefresh {
  587. startNativeAdRefreshTimer()
  588. } else {
  589. print("[Config] Native ad auto refresh is disabled")
  590. }
  591. }
  592. func didTapCloseButton(in adView: ATNativeADView, placementID: String, extra: [AnyHashable: Any]?) {
  593. print("Native ad closed for \(placementID)")
  594. removeNativeAd()
  595. }
  596. func didClickNativeAd(in adView: ATNativeADView, placementID: String, extra: [AnyHashable: Any]?) {
  597. print("Native ad clicked for \(placementID)")
  598. }
  599. func didFailToLoadAD(withPlacementID placementID: String, error: Error) {
  600. print("AD load failed(\(placementID)): \(error)")
  601. // 失败不影响主流程,保持 banner 容器高度为 0
  602. // banner广告失败,自动重试加载与展示
  603. if placementID == bannerPlacementID {
  604. print("[Banner] Load failed for bannerPlacementID, will retry...")
  605. // 清理旧视图以避免残留
  606. removeBanner()
  607. // 稍作延迟再重试,避免与 SDK 内部节流/并发冲突
  608. DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
  609. guard let self = self else { return }
  610. self.loadBannerAd()
  611. }
  612. }
  613. // 原生自渲染广告失败,自动重试加载与展示
  614. if placementID == nativeRenderPlacementID {
  615. print("[Native] Load failed for nativeRenderPlacementID, will retry...")
  616. // 清理旧视图以避免残留
  617. removeNativeAd()
  618. // 稍作延迟再重试,避免与 SDK 内部节流/并发冲突
  619. DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
  620. guard let self = self else { return }
  621. self.loadNativeAd()
  622. }
  623. }
  624. }
  625. func didRevenue(forPlacementID placementID: String, extra: [AnyHashable : Any]!) {
  626. // 可选:上报收益日志到我们服务器
  627. print("Banner revenue: placement=\(placementID), extra=\(extra ?? [:])")
  628. uploadAdRevenue(placementID: placementID, extra: extra ?? [:])
  629. }
  630. // MARK: - ATBannerDelegate
  631. func bannerView(_ bannerView: ATBannerView, didTapCloseButtonWithPlacementID placementID: String, extra: [AnyHashable: Any]!) {
  632. removeBanner()
  633. }
  634. func bannerView(_ bannerView: ATBannerView, didShowAdWithPlacementID placementID: String, extra: [AnyHashable: Any]!) {
  635. print("Banner Ad shown for \(placementID)")
  636. //展示广告的时间戳
  637. adStartTimes[placementID] = nowMillis()
  638. incrementTodayAdCount()
  639. // 在广告显示时设置正确的 presentingViewController
  640. bannerView.presentingViewController = self
  641. }
  642. func bannerView(_ bannerView: ATBannerView, didClickWithPlacementID placementID: String, extra: [AnyHashable: Any]!) {
  643. print("Banner Ad clicked for \(placementID)")
  644. }
  645. func bannerView(_ bannerView: ATBannerView, didAutoRefreshWithPlacement placementID: String, extra: [AnyHashable: Any]!) {
  646. print("Banner auto-refresh for \(placementID)")
  647. }
  648. func bannerView(_ bannerView: ATBannerView, failedToAutoRefreshWithPlacementID placementID: String, error: Error) {
  649. print("Banner auto-refresh failed for \(placementID): \(error.localizedDescription)")
  650. }
  651. // 添加 Banner 广告深度链接/跳转回调
  652. func bannerView(_ bannerView: ATBannerView, didDeepLinkOrJumpForPlacementID placementID: String, extra: [AnyHashable: Any]!, result success: Bool) {
  653. }
  654. private func removeNativeAd() {
  655. // 停止原生广告刷新计时器
  656. stopNativeAdRefreshTimer()
  657. // 清理自渲染视图
  658. selfRenderView?.teardown()
  659. selfRenderView?.removeFromSuperview()
  660. selfRenderView = nil
  661. // 清理原生广告视图
  662. nativeAdView?.removeFromSuperview()
  663. nativeAdView?.destroyNative()
  664. nativeAdView = nil
  665. // 清理offer
  666. nativeAdOffer = nil
  667. }
  668. // 加载插屏广告
  669. private func loadInterstitialAd() {
  670. // 加载插屏广告(支持半屏尺寸:如快手;可能会影响展示效果)
  671. let screenWidth = UIScreen.main.bounds.width
  672. let size = CGSize(width: screenWidth - 30.0, height: 300.0)
  673. let extra: [AnyHashable: Any] = [
  674. kATAdLoadingExtraMediaExtraKey: "ProfileView",
  675. // 设置半屏插屏广告大小
  676. kATInterstitialExtraAdSizeKey: NSValue(cgSize: size)
  677. ]
  678. ATAdManager.shared().loadAD(withPlacementID: interstitialPlacementID, extra: extra, delegate: self)
  679. }
  680. // 展示插屏广告
  681. private func showInterstitialAd() {
  682. // 检查广告是否准备好
  683. guard ATAdManager.shared().interstitialReady(forPlacementID: interstitialPlacementID) else {
  684. print("广告尚未加载完成")
  685. return
  686. }
  687. // 展示广告
  688. ATAdManager.shared().showInterstitial(withPlacementID: interstitialPlacementID, in: self, delegate: self)
  689. }
  690. func interstitialDidShow(forPlacementID placementID: String, extra: [AnyHashable : Any]) {
  691. print("插屏广告展示完成:\(placementID) \(extra)")
  692. // 展示广告时间戳
  693. adStartTimes[placementID] = nowMillis()
  694. incrementTodayAdCount()
  695. }
  696. func interstitialDidClick(forPlacementID placementID: String, extra: [AnyHashable : Any]) {
  697. print("插屏广告点击:\(placementID)")
  698. }
  699. func interstitialDidClose(forPlacementID placementID: String, extra: [AnyHashable : Any]) {
  700. print("插屏广告关闭:\(placementID) \(extra)")
  701. }
  702. @objc private func handleIconTap() {
  703. showSettingsPopup()
  704. }
  705. @objc private func handleLabelTap() {
  706. showSettingsPopup()
  707. }
  708. private func showSettingsPopup() {
  709. let settingsPopup = OverlayPanelView()
  710. settingsPopup.translatesAutoresizingMaskIntoConstraints = false
  711. settingsPopup.delegate = self // ✅ 设置代理
  712. view.addSubview(settingsPopup)
  713. // 添加约束使弹窗覆盖整个屏幕
  714. NSLayoutConstraint.activate([
  715. settingsPopup.topAnchor.constraint(equalTo: view.topAnchor),
  716. settingsPopup.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  717. settingsPopup.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  718. settingsPopup.trailingAnchor.constraint(equalTo: view.trailingAnchor)
  719. ])
  720. }
  721. // MARK: - UI Setup
  722. private func setupUI() {
  723. // Set Gradient Background
  724. let gradientLayer = CAGradientLayer()
  725. gradientLayer.frame = view.bounds
  726. gradientLayer.colors = [UIColor(hexString: "#FF6324")!.cgColor, UIColor(hexString: "#FF9946")!.cgColor]
  727. gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) // top center
  728. gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) // bottom center
  729. view.layer.insertSublayer(gradientLayer, at: 0)
  730. view.addSubview(bannerContainer)
  731. view.addSubview(userInfoBGView)
  732. view.addSubview(iconImageView)
  733. view.addSubview(userInfoLabel)
  734. view.addSubview(musiceView)
  735. // New UI components for the right side
  736. view.addSubview(gainPowerBtn)
  737. view.addSubview(todayCountLabel)
  738. view.addSubview(historyCountLabel)
  739. view.addSubview(boardBGView)
  740. view.addSubview(verdictBGView)
  741. view.addSubview(verdictLabel)
  742. view.addSubview(promptLabel)
  743. view.addSubview(levelBadge)
  744. view.addSubview(staminaBadge)
  745. view.addSubview(choiceBtnA)
  746. view.addSubview(choiceBtnB)
  747. view.addSubview(markA)
  748. view.addSubview(markB)
  749. view.addSubview(nativeAdContainer) // Add native ad container
  750. }
  751. private func setupConstraints() {
  752. // Set up constraints for banner container
  753. bannerHeightConstraint = bannerContainer.heightAnchor.constraint(equalToConstant: 0)
  754. bannerHeightConstraint?.isActive = true
  755. NSLayoutConstraint.activate([
  756. // Banner container
  757. bannerContainer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor,constant: 0),
  758. bannerContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  759. bannerContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  760. userInfoBGView.topAnchor.constraint(equalTo: bannerContainer.bottomAnchor,constant: 24),
  761. userInfoBGView.leadingAnchor.constraint(equalTo: view.leadingAnchor,constant: 24),
  762. userInfoBGView.widthAnchor.constraint(equalToConstant: 100),
  763. userInfoBGView.heightAnchor.constraint(equalToConstant: 100),
  764. iconImageView.centerXAnchor.constraint(equalTo: userInfoBGView.centerXAnchor),
  765. iconImageView.topAnchor.constraint(equalTo: userInfoBGView.topAnchor,constant: 15),
  766. iconImageView.widthAnchor.constraint(equalToConstant: 58),
  767. iconImageView.heightAnchor.constraint(equalToConstant: 58),
  768. // UID Label
  769. userInfoLabel.bottomAnchor.constraint(equalTo: userInfoBGView.bottomAnchor, constant: -5),
  770. userInfoLabel.centerXAnchor.constraint(equalTo: userInfoBGView.centerXAnchor),
  771. musiceView.leadingAnchor.constraint(equalTo: userInfoBGView.trailingAnchor, constant: 14),
  772. musiceView.centerYAnchor.constraint(equalTo: userInfoLabel.centerYAnchor),
  773. verdictBGView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  774. verdictBGView.topAnchor.constraint(equalTo: musiceView.bottomAnchor, constant: 14),
  775. verdictBGView.widthAnchor.constraint(equalToConstant: 290),
  776. verdictBGView.heightAnchor.constraint(equalToConstant: 72),
  777. verdictLabel.centerXAnchor.constraint(equalTo: verdictBGView.centerXAnchor),
  778. verdictLabel.topAnchor.constraint(equalTo: verdictBGView.topAnchor, constant: 11),
  779. boardBGView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  780. boardBGView.topAnchor.constraint(equalTo: musiceView.bottomAnchor, constant: 46),
  781. boardBGView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
  782. boardBGView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
  783. // Question Label
  784. promptLabel.centerYAnchor.constraint(equalTo: boardBGView.centerYAnchor),
  785. promptLabel.centerXAnchor.constraint(equalTo: boardBGView.centerXAnchor),
  786. promptLabel.leadingAnchor.constraint(equalTo: boardBGView.leadingAnchor,constant: 20),
  787. promptLabel.trailingAnchor.constraint(equalTo: boardBGView.trailingAnchor,constant: -20),
  788. levelBadge.bottomAnchor.constraint(equalTo: boardBGView.bottomAnchor, constant: 14),
  789. levelBadge.centerXAnchor.constraint(equalTo: boardBGView.centerXAnchor, constant: -67.5),
  790. levelBadge.heightAnchor.constraint(equalToConstant: 41),
  791. levelBadge.widthAnchor.constraint(equalToConstant: 111),
  792. staminaBadge.bottomAnchor.constraint(equalTo: boardBGView.bottomAnchor, constant: 14),
  793. staminaBadge.centerXAnchor.constraint(equalTo: boardBGView.centerXAnchor, constant: 67.5),
  794. staminaBadge.heightAnchor.constraint(equalToConstant: 41),
  795. staminaBadge.widthAnchor.constraint(equalToConstant: 111),
  796. // Option Button 1
  797. choiceBtnA.topAnchor.constraint(equalTo: boardBGView.bottomAnchor, constant: 45),
  798. choiceBtnA.centerXAnchor.constraint(equalTo: boardBGView.centerXAnchor),
  799. choiceBtnA.heightAnchor.constraint(equalToConstant: 47),
  800. choiceBtnA.widthAnchor.constraint(equalToConstant: 211),
  801. // Option Button 2
  802. choiceBtnB.topAnchor.constraint(equalTo: choiceBtnA.bottomAnchor, constant: 20),
  803. choiceBtnB.centerXAnchor.constraint(equalTo: boardBGView.centerXAnchor),
  804. choiceBtnB.heightAnchor.constraint(equalToConstant: 47),
  805. choiceBtnB.widthAnchor.constraint(equalToConstant: 211),
  806. markA.leadingAnchor.constraint(equalTo: choiceBtnA.trailingAnchor, constant: 4),
  807. markA.centerYAnchor.constraint(equalTo: choiceBtnA.centerYAnchor),
  808. markA.heightAnchor.constraint(equalToConstant: 40),
  809. markA.widthAnchor.constraint(equalToConstant: 40),
  810. markB.leadingAnchor.constraint(equalTo: choiceBtnB.trailingAnchor, constant: 4),
  811. markB.centerYAnchor.constraint(equalTo: choiceBtnB.centerYAnchor),
  812. markB.heightAnchor.constraint(equalToConstant: 40),
  813. markB.widthAnchor.constraint(equalToConstant: 40),
  814. nativeAdContainer.topAnchor.constraint(equalTo: choiceBtnB.bottomAnchor, constant: 10),
  815. nativeAdContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  816. nativeAdContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  817. nativeAdContainer.heightAnchor.constraint(equalToConstant: 170),
  818. // New UI components: Right-side Labels and Button
  819. gainPowerBtn.topAnchor.constraint(equalTo: userInfoBGView.topAnchor,constant: 10),
  820. gainPowerBtn.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
  821. gainPowerBtn.widthAnchor.constraint(equalToConstant: 110), // Adjust width to fit content
  822. gainPowerBtn.heightAnchor.constraint(equalToConstant: 37),
  823. todayCountLabel.topAnchor.constraint(equalTo: gainPowerBtn.bottomAnchor, constant: 10),
  824. todayCountLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
  825. historyCountLabel.topAnchor.constraint(equalTo: todayCountLabel.bottomAnchor, constant: 5),
  826. historyCountLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
  827. ])
  828. }
  829. // MARK: - Get Power Button Countdown Logic
  830. private func startGetPowerCountdownIfNeeded() {
  831. guard !didStartPowerCountdown else { return }
  832. didStartPowerCountdown = true
  833. startGetPowerCountdown()
  834. }
  835. private func startCooldownIfRewardFlowDone() {
  836. // Start only when server addPower succeeded AND video has been closed
  837. guard rewardPowerOK && rewardVideoClosed else { return }
  838. startGetPowerCountdownIfNeeded()
  839. // reset flags for next cycle
  840. rewardPowerOK = false
  841. rewardVideoClosed = false
  842. // 注意:不在这里清理pendingToastMessage,让Toast先显示
  843. }
  844. private func startGetPowerCountdown() {
  845. // Disable tapping while counting down
  846. gainPowerBtn.isEnabled = false
  847. // Build a gray overlay that blocks touches
  848. if powerCountdownOverlay == nil {
  849. let overlay = UIView()
  850. overlay.translatesAutoresizingMaskIntoConstraints = false
  851. overlay.backgroundColor = UIColor.black.withAlphaComponent(0.5)
  852. overlay.isUserInteractionEnabled = true // intercept touches while disabled
  853. overlay.layer.cornerRadius = 18.5
  854. overlay.clipsToBounds = true
  855. let label = UILabel()
  856. label.translatesAutoresizingMaskIntoConstraints = false
  857. label.font = UIFont.boldSystemFont(ofSize: 14)
  858. label.textColor = .white
  859. label.textAlignment = .center
  860. overlay.addSubview(label)
  861. NSLayoutConstraint.activate([
  862. label.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
  863. label.centerYAnchor.constraint(equalTo: overlay.centerYAnchor)
  864. ])
  865. gainPowerBtn.addSubview(overlay)
  866. NSLayoutConstraint.activate([
  867. overlay.leadingAnchor.constraint(equalTo: gainPowerBtn.leadingAnchor),
  868. overlay.trailingAnchor.constraint(equalTo: gainPowerBtn.trailingAnchor),
  869. overlay.topAnchor.constraint(equalTo: gainPowerBtn.topAnchor),
  870. overlay.bottomAnchor.constraint(equalTo: gainPowerBtn.bottomAnchor)
  871. ])
  872. powerCountdownOverlay = overlay
  873. powerCountdownLabel = label
  874. }
  875. powerRemainingSeconds = staminaWaitSec
  876. powerCountdownLabel?.text = "\(powerRemainingSeconds)s"
  877. powerCountdownTimer?.invalidate()
  878. powerCountdownTimer = Timer.scheduledTimer(timeInterval: 1.0,
  879. target: self,
  880. selector: #selector(handlePowerCountdownTick),
  881. userInfo: nil,
  882. repeats: true)
  883. if let timer = powerCountdownTimer {
  884. RunLoop.main.add(timer, forMode: .common)
  885. }
  886. }
  887. @objc private func handlePowerCountdownTick() {
  888. powerRemainingSeconds -= 1
  889. if powerRemainingSeconds > 0 {
  890. powerCountdownLabel?.text = "\(powerRemainingSeconds)s"
  891. } else {
  892. powerCountdownTimer?.invalidate()
  893. powerCountdownTimer = nil
  894. // Re-enable the button and remove overlay
  895. gainPowerBtn.isEnabled = true
  896. powerCountdownOverlay?.removeFromSuperview()
  897. powerCountdownOverlay = nil
  898. powerCountdownLabel = nil
  899. }
  900. }
  901. deinit {
  902. interstitialTimer?.invalidate()
  903. interstitialTimer = nil
  904. nativeTimer?.invalidate()
  905. nativeTimer = nil
  906. powerCountdownTimer?.invalidate()
  907. }
  908. // Keep a reference to the active Power popup (weak to avoid retain cycle)
  909. private weak var activePowerPopup: EnergyPanelView?
  910. // System loading overlay
  911. private var loadingOverlay: UIView?
  912. private var loadingSpinner: UIActivityIndicatorView?
  913. // MARK: - Button Actions
  914. @objc private func optionButtonTapped(_ sender: UIButton) {
  915. if sender == choiceBtnA || sender == choiceBtnB {
  916. handleAnswerSelection(selectedButton: sender)
  917. }
  918. }
  919. @objc private func handleGetPowerButtonTap() {
  920. //若已有体力,则提示并返回
  921. let currentPower = UserDefaults.standard.object(forKey: "power") as? Int ?? 0
  922. if canStackPower == 1 && currentPower > 0 {
  923. showToast(message: "已有体力请先答题")
  924. return
  925. }
  926. let powerPopup = EnergyPanelView()
  927. powerPopup.translatesAutoresizingMaskIntoConstraints = false
  928. powerPopup.delegate = self
  929. // 用最新登录信息配置;若没有,则从 UserDefaults 兜底
  930. if let info = accountInfo {
  931. powerPopup.configure(with: info)
  932. self.activePowerPopup = powerPopup
  933. } else {
  934. let nick = UserDefaults.standard.string(forKey: "nickname") ?? "XXXX"
  935. let role = UserDefaults.standard.string(forKey: "roleID") ?? "XXXX"
  936. let reg = UserDefaults.standard.string(forKey: "registryTimeStr") ?? "--"
  937. let today = UserDefaults.standard.object(forKey: "todayAnswerCount") as? Int ?? 0
  938. let history = UserDefaults.standard.object(forKey: "historyAnswerCount") as? Int ?? 0
  939. let head = UserDefaults.standard.string(forKey: "headImgURL")
  940. let lastLogin = UserDefaults.standard.string(forKey: "lastLoginTimeStr") ?? "--"
  941. let info = LoginUserInfo(
  942. nickName: nick,
  943. userId: role,
  944. registryTimeStr: reg,
  945. todayAnswerCount: today,
  946. historyAnswerCount: history,
  947. headImgURL: head,
  948. lastLoginTimeStr: lastLogin,
  949. answerLogs: UserDefaults.standard.stringArray(forKey: "answerLogs") ?? []
  950. )
  951. powerPopup.configure(with: info)
  952. self.activePowerPopup = powerPopup
  953. }
  954. view.addSubview(powerPopup)
  955. NSLayoutConstraint.activate([
  956. powerPopup.topAnchor.constraint(equalTo: view.topAnchor),
  957. powerPopup.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  958. powerPopup.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  959. powerPopup.trailingAnchor.constraint(equalTo: view.trailingAnchor)
  960. ])
  961. }
  962. func energyPanelDidTapAcquire(_ panel: EnergyPanelView) {
  963. // 先把弹窗移除(可按需要保留)
  964. panel.removeFromSuperview()
  965. self.activePowerPopup = nil
  966. // 展示激励广告
  967. showRewardAd()
  968. }
  969. // MARK: - PowerPopupViewDelegate (Close)
  970. func energyPanelDidDismiss(_ panel: EnergyPanelView) {
  971. // 关闭体力弹窗后刷新广告
  972. removeBanner()
  973. loadBannerAd()
  974. removeNativeAd()
  975. loadNativeAd()
  976. self.activePowerPopup = nil
  977. }
  978. private func showRealNamePopup() {
  979. let realNamePopup = IdentityGateView()
  980. realNamePopup.translatesAutoresizingMaskIntoConstraints = false
  981. view.addSubview(realNamePopup)
  982. NSLayoutConstraint.activate([
  983. realNamePopup.topAnchor.constraint(equalTo: view.topAnchor),
  984. realNamePopup.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  985. realNamePopup.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  986. realNamePopup.trailingAnchor.constraint(equalTo: view.trailingAnchor)
  987. ])
  988. }
  989. // MARK: - System Loading Helpers
  990. private func showSystemLoading() {
  991. guard loadingOverlay == nil else { return }
  992. let overlay = UIView()
  993. overlay.translatesAutoresizingMaskIntoConstraints = false
  994. overlay.backgroundColor = UIColor.black.withAlphaComponent(0.35)
  995. overlay.isUserInteractionEnabled = true // block touches
  996. let spinner = UIActivityIndicatorView(style: .large)
  997. spinner.translatesAutoresizingMaskIntoConstraints = false
  998. spinner.startAnimating()
  999. spinner.hidesWhenStopped = true
  1000. overlay.addSubview(spinner)
  1001. view.addSubview(overlay)
  1002. NSLayoutConstraint.activate([
  1003. overlay.topAnchor.constraint(equalTo: view.topAnchor),
  1004. overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  1005. overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  1006. overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  1007. spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
  1008. spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor)
  1009. ])
  1010. loadingOverlay = overlay
  1011. loadingSpinner = spinner
  1012. }
  1013. private func hideSystemLoading() {
  1014. loadingSpinner?.stopAnimating()
  1015. loadingOverlay?.removeFromSuperview()
  1016. loadingSpinner = nil
  1017. loadingOverlay = nil
  1018. }
  1019. // MARK: - Default Config
  1020. private func fetchDefaultConfig(channel:Channel) {
  1021. guard let url = URL(string: apiBaseURL + getDefaultConfig) else {
  1022. print("[Config] Invalid getDefaultConfig URL")
  1023. return
  1024. }
  1025. var request = URLRequest(url: url)
  1026. request.httpMethod = "POST"
  1027. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  1028. let body: [String: Any] = ["ditchId": channel.id]
  1029. request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
  1030. if let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) {
  1031. print("[Config] Request URL: \(url)")
  1032. print("[Config] Request Headers: \(request.allHTTPHeaderFields ?? [:])")
  1033. print("[Config] Request Body: \(bodyString)")
  1034. }
  1035. URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
  1036. if let http = response as? HTTPURLResponse {
  1037. print("[Config] Response Code: \(http.statusCode)")
  1038. print("[Config] Response Headers: \(http.allHeaderFields)")
  1039. }
  1040. if let data = data, let s = String(data: data, encoding: .utf8) {
  1041. print("[Config] Response Body: \(s)")
  1042. }
  1043. if let error = error {
  1044. print("[Config] fetch error: \(error)")
  1045. // 接口失败时,使用默认配置加载广告
  1046. DispatchQueue.main.async { [weak self] in
  1047. self?.reloadAllAdsWithNewConfig()
  1048. self?.startInterstitialScheduleIfNeeded()
  1049. }
  1050. return
  1051. }
  1052. guard let data = data else {
  1053. DispatchQueue.main.async { [weak self] in
  1054. self?.reloadAllAdsWithNewConfig()
  1055. self?.startInterstitialScheduleIfNeeded()
  1056. }
  1057. return
  1058. }
  1059. // Parse JSON: { code, message, data: { powerWaitTime, interstitialIntervalTime, takuBannerPid, takuInterstitialPid, takuNativePid, takuRewardPid, isAllowNativeAutoRefresh } }
  1060. var newPowerWait: Int?
  1061. var newInterstitial: Int?
  1062. var newNativeInterval: Int?
  1063. var newCanAccumulation: Int?
  1064. var newBannerPid: String?
  1065. var newInterstitialPid: String?
  1066. var newNativePid: String?
  1067. var newRewardPid: String?
  1068. var newIsAllowNativeAutoRefresh: Bool?
  1069. if let obj = try? JSONSerialization.jsonObject(with: data, options: []),
  1070. let dict = obj as? [String: Any],
  1071. let dataDict = dict["data"] as? [String: Any] {
  1072. if let p = dataDict["powerWaitTime"] as? Int { newPowerWait = p }
  1073. if let pStr = dataDict["powerWaitTime"] as? String, let p = Int(pStr) { newPowerWait = p }
  1074. if let i = dataDict["interstitialIntervalTime"] as? Int { newInterstitial = i }
  1075. if let iStr = dataDict["interstitialIntervalTime"] as? String, let i = Int(iStr) { newInterstitial = i }
  1076. if let n = dataDict["flowIntervalTime"] as? Int { newNativeInterval = n }
  1077. if let nStr = dataDict["flowIntervalTime"] as? String, let n = Int(nStr) { newNativeInterval = n }
  1078. if let c = dataDict["canAccumulation"] as? Int { newCanAccumulation = c }
  1079. if let cStr = dataDict["canAccumulation"] as? String, let c = Int(cStr) { newCanAccumulation = c }
  1080. if let cBool = dataDict["canAccumulation"] as? Bool { newCanAccumulation = cBool ? 1 : 0 }
  1081. // 获取广告placement ID
  1082. if let bannerPid = dataDict["takuBannerPid"] as? String, !bannerPid.isEmpty { newBannerPid = bannerPid }
  1083. if let interstitialPid = dataDict["takuInterstitialPid"] as? String, !interstitialPid.isEmpty { newInterstitialPid = interstitialPid }
  1084. if let nativePid = dataDict["takuNativePid"] as? String, !nativePid.isEmpty { newNativePid = nativePid }
  1085. if let rewardPid = dataDict["takuRewardPid"] as? String, !rewardPid.isEmpty { newRewardPid = rewardPid }
  1086. // 获取原生广告自动刷新开关
  1087. if let allowRefresh = dataDict["canAllowAutoRefresh"] as? Bool { newIsAllowNativeAutoRefresh = allowRefresh }
  1088. if let allowRefreshStr = dataDict["canAllowAutoRefresh"] as? String {
  1089. if allowRefreshStr.lowercased() == "true" { newIsAllowNativeAutoRefresh = true }
  1090. else if allowRefreshStr.lowercased() == "false" { newIsAllowNativeAutoRefresh = false }
  1091. }
  1092. if let allowRefreshInt = dataDict["canAllowAutoRefresh"] as? Int { newIsAllowNativeAutoRefresh = allowRefreshInt != 0 }
  1093. }
  1094. DispatchQueue.main.async { [weak self] in
  1095. if let p = newPowerWait, p > 0 {
  1096. self?.staminaWaitSec = p
  1097. print("[Config] Applied powerWaitTime = \(p)s")
  1098. }
  1099. self?.startGetPowerCountdownIfNeeded()
  1100. if let i = newInterstitial, i > 0 {
  1101. self?.interIntervalSec = i
  1102. print("[Config] Applied interstitialIntervalTime = \(i)s")
  1103. }
  1104. self?.startInterstitialScheduleIfNeeded()
  1105. if let n = newNativeInterval, n > 0 {
  1106. self?.nativeIntervalSec = n
  1107. print("[Config] Applied flowIntervalTime = \(n)s")
  1108. }
  1109. if let c = newCanAccumulation {
  1110. self?.canStackPower = c
  1111. print("[Config] Applied canAccumulation = \(c)")
  1112. }
  1113. // 更新广告placement ID
  1114. if let bannerPid = newBannerPid {
  1115. self?.bannerPlacementID = bannerPid
  1116. print("[Config] Applied takuBannerPid = \(bannerPid)")
  1117. }
  1118. if let interstitialPid = newInterstitialPid {
  1119. self?.interstitialPlacementID = interstitialPid
  1120. print("[Config] Applied takuInterstitialPid = \(interstitialPid)")
  1121. }
  1122. if let nativePid = newNativePid {
  1123. self?.nativeRenderPlacementID = nativePid
  1124. print("[Config] Applied takuNativePid = \(nativePid)")
  1125. }
  1126. if let rewardPid = newRewardPid {
  1127. self?.rewardPlacementID = rewardPid
  1128. print("[Config] Applied takuRewardPid = \(rewardPid)")
  1129. }
  1130. // 更新原生广告自动刷新开关
  1131. if let allowRefresh = newIsAllowNativeAutoRefresh {
  1132. self?.isAllowNativeAutoRefresh = allowRefresh
  1133. print("[Config] Applied canAllowAutoRefresh = \(allowRefresh)")
  1134. }
  1135. // 配置更新后,重新加载所有广告
  1136. self?.reloadAllAdsWithNewConfig()
  1137. }
  1138. }.resume()
  1139. }
  1140. private func startInterstitialScheduleIfNeeded() {
  1141. guard interstitialTimer == nil else { return }
  1142. let interval = max(5, TimeInterval(interIntervalSec)) // safety floor
  1143. interstitialTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(handleInterstitialTick), userInfo: nil, repeats: true)
  1144. if let t = interstitialTimer { RunLoop.main.add(t, forMode: .common) }
  1145. print("[Config] Interstitial timer started with interval = \(interval)s")
  1146. }
  1147. @objc private func handleInterstitialTick() {
  1148. // Attempt to show an interstitial; if not ready, trigger a load
  1149. if ATAdManager.shared().interstitialReady(forPlacementID: interstitialPlacementID) {
  1150. showInterstitialAd()
  1151. } else {
  1152. loadInterstitialAd()
  1153. }
  1154. }
  1155. // MARK: - Native Ad Auto Refresh
  1156. private func startNativeAdRefreshTimer() {
  1157. guard nativeTimer == nil else { return }
  1158. let interval = max(5, TimeInterval(nativeIntervalSec)) // safety floor
  1159. nativeTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(handleNativeAdTick), userInfo: nil, repeats: true)
  1160. if let t = nativeTimer { RunLoop.main.add(t, forMode: .common) }
  1161. print("[Config] Native ad timer started with interval = \(interval)s")
  1162. }
  1163. private func stopNativeAdRefreshTimer() {
  1164. nativeTimer?.invalidate()
  1165. nativeTimer = nil
  1166. print("[Config] Native ad timer stopped")
  1167. }
  1168. @objc private func handleNativeAdTick() {
  1169. // 刷新原生广告
  1170. removeNativeAd()
  1171. loadNativeAd()
  1172. print("[Config] Native ad refreshed")
  1173. }
  1174. // MARK: - Ad Reload with New Config
  1175. private func reloadAllAdsWithNewConfig() {
  1176. print("[Config] Reloading all ads with new placement IDs")
  1177. // 重新加载所有广告
  1178. loadBannerAd()
  1179. loadInterstitialAd()
  1180. loadNativeAd()
  1181. loadRewardAd()
  1182. }
  1183. // MARK: - Application Lifecycle
  1184. @objc private func appDidEnterForeground() {
  1185. print("[App] App did enter foreground, refreshing native ad")
  1186. // 应用从后台进入前台时刷新原生广告
  1187. refreshNativeAdOnAppBecomeActive()
  1188. }
  1189. private func refreshNativeAdOnAppBecomeActive() {
  1190. // 移除当前原生广告
  1191. removeNativeAd()
  1192. // 重新加载原生广告
  1193. loadNativeAd()
  1194. print("[App] Native ad refreshed on app become active")
  1195. }
  1196. // MARK: - Toast Helper
  1197. /// 延迟显示Toast消息,等待激励视频关闭后显示
  1198. private func showToastAfterVideoClosed(message: String) {
  1199. print("[Toast] Setting pending message: \(message), videoClosed: \(rewardVideoClosed)")
  1200. pendingToastText = message
  1201. // 如果视频已经关闭,立即显示
  1202. if rewardVideoClosed {
  1203. print("[Toast] Video already closed, showing immediately")
  1204. showPendingToast()
  1205. } else {
  1206. print("[Toast] Video not closed yet, waiting for close event")
  1207. }
  1208. }
  1209. /// 显示待显示的Toast消息
  1210. private func showPendingToast() {
  1211. guard let message = pendingToastText else {
  1212. print("[Toast] No pending message to show")
  1213. return
  1214. }
  1215. print("[Toast] Showing pending message: \(message)")
  1216. pendingToastText = nil
  1217. showToast(message: message)
  1218. }
  1219. private func showToast(message: String) {
  1220. // Avoid stacking multiple toasts
  1221. let tag = 9527
  1222. if let existing = view.viewWithTag(tag) {
  1223. existing.removeFromSuperview()
  1224. }
  1225. let toastLabel = UILabel()
  1226. toastLabel.text = message
  1227. toastLabel.font = UIFont.systemFont(ofSize: 15, weight: .medium)
  1228. toastLabel.textColor = .white
  1229. toastLabel.backgroundColor = UIColor.black.withAlphaComponent(0.8)
  1230. toastLabel.textAlignment = .center
  1231. toastLabel.alpha = 0.0
  1232. toastLabel.layer.cornerRadius = 16
  1233. toastLabel.clipsToBounds = true
  1234. toastLabel.numberOfLines = 0
  1235. toastLabel.tag = tag
  1236. toastLabel.translatesAutoresizingMaskIntoConstraints = false
  1237. view.addSubview(toastLabel)
  1238. // Padding
  1239. let horizontalPadding: CGFloat = 24
  1240. let verticalPadding: CGFloat = 12
  1241. let maxWidth = view.frame.width - 40
  1242. let size = toastLabel.sizeThatFits(CGSize(width: maxWidth - horizontalPadding * 2, height: CGFloat.greatestFiniteMagnitude))
  1243. let width = min(size.width + horizontalPadding * 2, maxWidth)
  1244. let height = size.height + verticalPadding * 2
  1245. // Center horizontally, bottom at ~20% above bottom
  1246. NSLayoutConstraint.activate([
  1247. toastLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  1248. toastLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
  1249. toastLabel.widthAnchor.constraint(equalToConstant: width),
  1250. toastLabel.heightAnchor.constraint(equalToConstant: height)
  1251. ])
  1252. UIView.animate(withDuration: 0.25, animations: {
  1253. toastLabel.alpha = 1.0
  1254. }) { _ in
  1255. DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
  1256. UIView.animate(withDuration: 0.25, animations: {
  1257. toastLabel.alpha = 0.0
  1258. }) { _ in
  1259. toastLabel.removeFromSuperview()
  1260. }
  1261. }
  1262. }
  1263. }
  1264. // MARK: - Image Loading (Icon)
  1265. private static let imageCache = NSCache<NSURL, UIImage>()
  1266. private func setIconImage(with urlString: String?) {
  1267. guard let s = urlString, !s.isEmpty, let url = URL(string: s) else {
  1268. self.iconImageView.image = UIImage(named: "catlogos")
  1269. return
  1270. }
  1271. if let cached = QuizStageController.imageCache.object(forKey: url as NSURL) {
  1272. self.iconImageView.image = cached
  1273. return
  1274. }
  1275. URLSession.shared.dataTask(with: url) { data, _, _ in
  1276. guard let data = data, let img = UIImage(data: data) else { return }
  1277. QuizStageController.imageCache.setObject(img, forKey: url as NSURL)
  1278. DispatchQueue.main.async { [weak self] in
  1279. self?.iconImageView.image = img
  1280. }
  1281. }.resume()
  1282. }
  1283. // MARK: - Question List
  1284. private func fetchQuestionList() {
  1285. guard var components = URLComponents(string: apiBaseURL + getQuestionListPath) else {
  1286. print("[Question] Invalid question list URL")
  1287. return
  1288. }
  1289. // If backend needs appId, pass it; harmless if ignored
  1290. components.queryItems = [ URLQueryItem(name: "appId", value: kTakuAppID) ]
  1291. guard let url = components.url else { return }
  1292. var request = URLRequest(url: url)
  1293. request.httpMethod = "GET"
  1294. print("[Question] Request URL: \(url)")
  1295. URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
  1296. if let http = response as? HTTPURLResponse {
  1297. print("[Question] Response Code: \(http.statusCode)")
  1298. print("[Question] Response Headers: \(http.allHeaderFields)")
  1299. }
  1300. if let data = data, let s = String(data: data, encoding: .utf8) {
  1301. print("[Question] Response Body: \(s)")
  1302. }
  1303. if let error = error {
  1304. print("[Question] fetch error: \(error)")
  1305. return
  1306. }
  1307. guard let data = data else { return }
  1308. let parsed = self?.parseQuestionList(from: data) ?? []
  1309. DispatchQueue.main.async { [weak self] in
  1310. guard let self = self else { return }
  1311. self.questions = parsed
  1312. // 根据登录返回的 lastQuestionId(或本地缓存)定位:从其"下一关"开始
  1313. let lastQid = self.lastQuestionIdFromLogin ?? UserDefaults.standard.string(forKey: "lastQuestionId")
  1314. if let lq = lastQid,
  1315. !lq.isEmpty,
  1316. let idx = self.questions.firstIndex(where: { $0.id == lq }) {
  1317. let nextIndex = idx + 1
  1318. self.currentQuestionIndex = (nextIndex < self.questions.count) ? nextIndex : 0
  1319. } else {
  1320. // 为空或没匹配上:从第一关开始
  1321. self.currentQuestionIndex = 0
  1322. }
  1323. self.renderCurrentQuestion()
  1324. }
  1325. }.resume()
  1326. }
  1327. private func parseQuestionList(from data: Data) -> [QAQuestion] {
  1328. var result: [QAQuestion] = []
  1329. guard let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
  1330. let arr = obj["data"] as? [[String: Any]] else { return result }
  1331. for q in arr {
  1332. let qid = (q["questionId"] as? String) ?? ""
  1333. let content = (q["questionContent"] as? String) ?? ""
  1334. let correct = (q["correctItem"] as? String) ?? ""
  1335. var items: [QAItem] = []
  1336. if let itemList = q["itemList"] as? [[String: Any]] {
  1337. for it in itemList {
  1338. let iid = (it["itemId"] as? String) ?? ""
  1339. let ic = (it["itemContent"] as? String) ?? ""
  1340. items.append(QAItem(id: iid, content: ic))
  1341. }
  1342. }
  1343. if !qid.isEmpty && !content.isEmpty && !items.isEmpty {
  1344. result.append(QAQuestion(id: qid, content: content, correctItemId: correct, items: items))
  1345. }
  1346. }
  1347. return result
  1348. }
  1349. private func renderCurrentQuestion() {
  1350. guard currentQuestionIndex >= 0, currentQuestionIndex < questions.count else { return }
  1351. let q = questions[currentQuestionIndex]
  1352. promptLabel.text = q.content
  1353. // Reset indicators
  1354. markA.isHidden = true
  1355. markB.isHidden = true
  1356. verdictLabel.text = "请选择正确答案"
  1357. // Fill two options (use前两个)
  1358. if q.items.indices.contains(0) {
  1359. choiceBtnA.setTitle(q.items[0].content, for: .normal)
  1360. option1ItemId = q.items[0].id
  1361. } else {
  1362. choiceBtnA.setTitle("选项A", for: .normal)
  1363. option1ItemId = nil
  1364. }
  1365. if q.items.indices.contains(1) {
  1366. choiceBtnB.setTitle(q.items[1].content, for: .normal)
  1367. option2ItemId = q.items[1].id
  1368. } else {
  1369. choiceBtnB.setTitle("选项B", for: .normal)
  1370. option2ItemId = nil
  1371. }
  1372. // Reset option button backgrounds to default
  1373. choiceBtnA.setBackgroundImage(UIImage(named: "res_o67j6d3t"), for: .normal)
  1374. choiceBtnB.setBackgroundImage(UIImage(named: "res_o67j6d3t"), for: .normal)
  1375. // Ensure disabled/gray state persists across questions
  1376. let currentPower = UserDefaults.standard.object(forKey: "power") as? Int ?? 0
  1377. applyPowerToOptionButtons(currentPower)
  1378. // 重置答题状态,允许答题
  1379. isAnswering = false
  1380. // 更新关卡显示
  1381. levelBadge.setTitle("关卡:\(currentQuestionIndex + 1)", for: .normal)
  1382. // 题目开始计时点
  1383. questionStartAt = Date()
  1384. }
  1385. private func handleAnswerSelection(selectedButton: UIButton) {
  1386. // 防止重复答题
  1387. guard !isAnswering else {
  1388. print("[答题] 正在处理中,忽略重复点击")
  1389. return
  1390. }
  1391. guard currentQuestionIndex < questions.count else { return }
  1392. let q = questions[currentQuestionIndex]
  1393. // 设置答题状态,禁用按钮
  1394. isAnswering = true
  1395. choiceBtnA.isEnabled = false
  1396. choiceBtnB.isEnabled = false
  1397. let selectedId: String?
  1398. let isFirst = (selectedButton == choiceBtnA)
  1399. if isFirst { selectedId = option1ItemId } else { selectedId = option2ItemId }
  1400. // Always显示正确答案标记
  1401. let correctImage = UIImage(named: "res_cwqxdp40")
  1402. let wrongImage = UIImage(named: "res_4h4cvu8h")
  1403. // 先隐藏
  1404. markA.isHidden = true
  1405. markB.isHidden = true
  1406. // 正确项标绿勾
  1407. if option1ItemId == q.correctItemId { markA.image = correctImage; markA.isHidden = false }
  1408. if option2ItemId == q.correctItemId { markB.image = correctImage; markB.isHidden = false }
  1409. // 如果选错,则在所选按钮旁显示红叉
  1410. let isCorrect = (selectedId == q.correctItemId)
  1411. if !isCorrect {
  1412. if isFirst { markA.image = wrongImage; markA.isHidden = false }
  1413. else { markB.image = wrongImage; markB.isHidden = false }
  1414. verdictLabel.text = "回答错误!"
  1415. } else {
  1416. verdictLabel.text = "回答正确!"
  1417. }
  1418. // Update selected button background based on correctness
  1419. if isCorrect {
  1420. selectedButton.setBackgroundImage(UIImage(named: "res_rqqxjzk3"), for: .normal)
  1421. } else {
  1422. selectedButton.setBackgroundImage(UIImage(named: "res_zup626a7"), for: .normal)
  1423. }
  1424. // 计算答题耗时并上报
  1425. let durationSec: Int = {
  1426. if let start = questionStartAt { return max(0, Int(Date().timeIntervalSince(start))) }
  1427. return 0
  1428. }()
  1429. if let sid = selectedId {
  1430. answerQuestion(questionId: q.id, itemId: sid, duration: durationSec)
  1431. }
  1432. // 1秒后进入下一题/结束
  1433. DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
  1434. self?.goToNextQuestion()
  1435. }
  1436. }
  1437. // MARK: - Answer Question API
  1438. private func answerQuestion(questionId: String, itemId: String, duration: Int) {
  1439. guard let url = URL(string: apiBaseURL + answerQuestionPath) else {
  1440. print("[Answer] Invalid URL")
  1441. return
  1442. }
  1443. let userId = accountInfo?.userId ?? (UserDefaults.standard.string(forKey: "roleID") ?? "")
  1444. guard !userId.isEmpty else {
  1445. print("[Answer] Missing userId")
  1446. DispatchQueue.main.async { [weak self] in
  1447. self?.showToast(message: "缺少用户ID,无法上报作答")
  1448. }
  1449. return
  1450. }
  1451. var request = URLRequest(url: url)
  1452. request.httpMethod = "POST"
  1453. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  1454. let body: [String: Any] = [
  1455. "duration": duration,
  1456. "itemId": itemId,
  1457. "questionId": questionId,
  1458. "userId": userId
  1459. ]
  1460. request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
  1461. // Logs
  1462. if let body = request.httpBody, let bodyString = String(data: body, encoding: .utf8) {
  1463. print("[Answer] Request URL: \(url)")
  1464. print("[Answer] Request Headers: \(request.allHTTPHeaderFields ?? [:])")
  1465. print("[Answer] Request Body: \(bodyString)")
  1466. }
  1467. URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
  1468. if let http = response as? HTTPURLResponse {
  1469. print("[Answer] Response Code: \(http.statusCode)")
  1470. print("[Answer] Response Headers: \(http.allHeaderFields)")
  1471. }
  1472. if let data = data, let s = String(data: data, encoding: .utf8) {
  1473. print("[Answer] Response Body: \(s)")
  1474. }
  1475. if let error = error {
  1476. print("[Answer] error: \(error)")
  1477. DispatchQueue.main.async { [weak self] in
  1478. self?.showToast(message: "网络异常,作答上报失败")
  1479. }
  1480. return
  1481. }
  1482. guard let data = data,
  1483. let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
  1484. return
  1485. }
  1486. let code = obj["code"] as? Int ?? 0
  1487. if code != 200 {
  1488. let msg = (obj["message"] as? String) ?? (obj["msg"] as? String) ?? "作答上报失败"
  1489. DispatchQueue.main.async { [weak self] in
  1490. self?.showToast(message: msg)
  1491. }
  1492. return
  1493. }
  1494. // 请求成功后扣除体力 + 同步今日/历史答题与作答时间记录
  1495. var returnedToday: Int?
  1496. var returnedHistory: Int?
  1497. var returnedLogs: [String]?
  1498. if let dataDict = obj["data"] as? [String: Any] {
  1499. if let v = dataDict["todayAnswerCount"] as? Int { returnedToday = v }
  1500. else if let s = dataDict["todayAnswerCount"] as? String, let i = Int(s) { returnedToday = i }
  1501. if let v = dataDict["historyAnswerCount"] as? Int { returnedHistory = v }
  1502. else if let s = dataDict["historyAnswerCount"] as? String, let i = Int(s) { returnedHistory = i }
  1503. if let arr = dataDict["answerRecordTimeList"] as? [String] { returnedLogs = arr }
  1504. else if let anyArr = dataDict["answerRecordTimeList"] as? [Any] { returnedLogs = anyArr.compactMap { String(describing: $0) } }
  1505. else if let arr = dataDict["answerRecordList"] as? [String] { returnedLogs = arr }
  1506. }
  1507. DispatchQueue.main.async { [weak self] in
  1508. guard let self = self else { return }
  1509. // 1) 扣体力
  1510. let currentPower = UserDefaults.standard.object(forKey: "power") as? Int ?? 0
  1511. let newPower = max(0, currentPower - 1)
  1512. self.staminaBadge.setTitle("体力:\(newPower)", for: .normal)
  1513. self.applyPowerToOptionButtons(newPower)
  1514. UserDefaults.standard.set(newPower, forKey: "power")
  1515. if newPower == 0 { self.showToast(message: "体力不足,请先领取") }
  1516. // 2) 更新右侧统计(若后端返回)
  1517. if let t = returnedToday {
  1518. self.todayCountLabel.text = "今日答题: \(t)题"
  1519. UserDefaults.standard.set(t, forKey: "todayAnswerCount")
  1520. }
  1521. if let h = returnedHistory {
  1522. self.historyCountLabel.text = "历史答题: \(h)题"
  1523. UserDefaults.standard.set(h, forKey: "historyAnswerCount")
  1524. }
  1525. if let logs = returnedLogs {
  1526. UserDefaults.standard.set(logs, forKey: "answerLogs")
  1527. }
  1528. // 3) 更新内存里的 loginUserInfo,用于后续弹窗展示
  1529. if let old = self.accountInfo {
  1530. let newInfo = LoginUserInfo(
  1531. nickName: old.nickName,
  1532. userId: old.userId,
  1533. registryTimeStr: old.registryTimeStr,
  1534. todayAnswerCount: returnedToday ?? old.todayAnswerCount,
  1535. historyAnswerCount: returnedHistory ?? old.historyAnswerCount,
  1536. headImgURL: old.headImgURL,
  1537. lastLoginTimeStr: old.lastLoginTimeStr,
  1538. answerLogs: returnedLogs ?? old.answerLogs
  1539. )
  1540. self.accountInfo = newInfo
  1541. // 若弹窗正在显示,实时刷新
  1542. self.activePowerPopup?.configure(with: newInfo)
  1543. } else if (returnedToday != nil) || (returnedHistory != nil) || (returnedLogs != nil) {
  1544. // 构造一个兜底的 LoginUserInfo
  1545. let nick = UserDefaults.standard.string(forKey: "nickname") ?? "XXXX"
  1546. let role = UserDefaults.standard.string(forKey: "roleID") ?? ""
  1547. let reg = UserDefaults.standard.string(forKey: "registryTimeStr") ?? "--"
  1548. let head = UserDefaults.standard.string(forKey: "headImgURL")
  1549. let last = UserDefaults.standard.string(forKey: "lastLoginTimeStr") ?? "--"
  1550. let t = returnedToday ?? (UserDefaults.standard.object(forKey: "todayAnswerCount") as? Int ?? 0)
  1551. let h = returnedHistory ?? (UserDefaults.standard.object(forKey: "historyAnswerCount") as? Int ?? 0)
  1552. let logs = returnedLogs ?? (UserDefaults.standard.stringArray(forKey: "answerLogs") ?? [])
  1553. let info = LoginUserInfo(nickName: nick, userId: role, registryTimeStr: reg, todayAnswerCount: t, historyAnswerCount: h, headImgURL: head, lastLoginTimeStr: last, answerLogs: logs)
  1554. self.accountInfo = info
  1555. self.activePowerPopup?.configure(with: info)
  1556. }
  1557. }
  1558. }.resume()
  1559. }
  1560. private func goToNextQuestion() {
  1561. currentQuestionIndex += 1
  1562. if currentQuestionIndex < questions.count {
  1563. renderCurrentQuestion()
  1564. } else {
  1565. // 题目答完:简单重置或提示
  1566. verdictLabel.text = "已答完全部题目"
  1567. // 也可以重置到第一题:
  1568. // currentQuestionIndex = 0; renderCurrentQuestion()
  1569. }
  1570. // 重置答题状态,允许下一题答题
  1571. isAnswering = false
  1572. }
  1573. // MARK: - Power → Option Buttons
  1574. private func applyPowerToOptionButtons(_ power: Int) {
  1575. let enabled = power > 0 && !isAnswering // 体力大于0且不在答题中
  1576. DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
  1577. guard let self = self else { return }
  1578. self.choiceBtnA.isEnabled = enabled
  1579. self.choiceBtnB.isEnabled = enabled
  1580. // 置灰/恢复
  1581. self.choiceBtnA.alpha = enabled ? 1.0 : 0.5
  1582. self.choiceBtnB.alpha = enabled ? 1.0 : 0.5
  1583. }
  1584. }
  1585. // MARK: - Add Power after Reward
  1586. private func addPower() {
  1587. guard var comps = URLComponents(string: apiBaseURL + addPowerPath) else {
  1588. print("[Power] Invalid addPower URL")
  1589. return
  1590. }
  1591. // 优先使用登录后返回的 userId(roleId),否则从持久化读取
  1592. let roleId = accountInfo?.userId ?? (UserDefaults.standard.string(forKey: "roleID") ?? "")
  1593. guard !roleId.isEmpty else {
  1594. print("[Power] Missing userId for addPower")
  1595. DispatchQueue.main.async { [weak self] in
  1596. self?.showToastAfterVideoClosed(message: "缺少用户ID,无法增加体力")
  1597. }
  1598. return
  1599. }
  1600. // GET /wx/addPower?userId=xxx
  1601. comps.queryItems = [ URLQueryItem(name: "userId", value: roleId) ]
  1602. guard let url = comps.url else {
  1603. print("[Power] Failed to build addPower URL with query")
  1604. return
  1605. }
  1606. var request = URLRequest(url: url)
  1607. request.httpMethod = "GET"
  1608. // Request logs (GET,无请求体)
  1609. print("[Power] addPower Request URL: \(url)")
  1610. print("[Power] addPower Request Headers: \(request.allHTTPHeaderFields ?? [:])")
  1611. URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
  1612. // Response logs
  1613. if let http = response as? HTTPURLResponse {
  1614. print("[Power] addPower Response Code: \(http.statusCode)")
  1615. print("[Power] addPower Response Headers: \(http.allHeaderFields)")
  1616. }
  1617. if let data = data, let s = String(data: data, encoding: .utf8) {
  1618. print("[Power] addPower Response Body: \(s)")
  1619. }
  1620. if let error = error {
  1621. print("[Power] addPower error: \(error)")
  1622. DispatchQueue.main.async { [weak self] in
  1623. self?.showToastAfterVideoClosed(message: "网络异常,体力更新失败")
  1624. }
  1625. return
  1626. }
  1627. guard let data = data,
  1628. let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { return }
  1629. let code = obj["code"] as? Int ?? 0
  1630. if code != 200 {
  1631. let msg = (obj["message"] as? String) ?? (obj["msg"] as? String) ?? "体力更新失败"
  1632. DispatchQueue.main.async { [weak self] in
  1633. self?.showToastAfterVideoClosed(message: msg)
  1634. }
  1635. return
  1636. }
  1637. var newPower = 0
  1638. if let dataDict = obj["data"] as? [String: Any] {
  1639. if let v = dataDict["power"] as? Int { newPower = v }
  1640. if let s = dataDict["power"] as? String, let i = Int(s) { newPower = i }
  1641. }
  1642. DispatchQueue.main.async { [weak self] in
  1643. guard let self = self else { return }
  1644. self.staminaBadge.setTitle("体力:\(newPower)", for: .normal)
  1645. self.applyPowerToOptionButtons(newPower)
  1646. UserDefaults.standard.set(newPower, forKey: "power")
  1647. // 激励成功 + 服务端加体力成功且激励视频关闭后,才启动获取体力按钮的倒计时
  1648. self.rewardPowerOK = true
  1649. self.didStartPowerCountdown = false
  1650. self.startCooldownIfRewardFlowDone()
  1651. // 延迟显示成功消息,等待激励视频关闭
  1652. self.showToastAfterVideoClosed(message: "体力增加成功!")
  1653. }
  1654. }.resume()
  1655. }
  1656. // 广告展示开始时间(毫秒),key 为 placementID
  1657. private var adStartTimes: [String: Int64] = [:]
  1658. // 当前时间戳(毫秒)
  1659. private func nowMillis() -> Int64 {
  1660. Int64(Date().timeIntervalSince1970 * 1000)
  1661. }
  1662. // 将毫秒时间戳格式化为 "yyyy-MM-dd HH:mm:ss"
  1663. private func formatMillisToString(_ ms: Int64) -> String {
  1664. let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000.0)
  1665. let df = DateFormatter()
  1666. df.locale = Locale(identifier: "en_US_POSIX")
  1667. df.timeZone = TimeZone.current // 如果后端要 UTC,就改成 TimeZone(secondsFromGMT: 0)
  1668. df.dateFormat = "yyyy-MM-dd HH:mm:ss"
  1669. return df.string(from: date)
  1670. }
  1671. // ===== 当天广告观看次数(持久化到 UserDefaults)=====
  1672. private func adCountKeyForToday() -> String {
  1673. let df = DateFormatter()
  1674. df.locale = Locale(identifier: "en_US_POSIX")
  1675. df.dateFormat = "yyyy-MM-dd" // 本地自然日
  1676. return "adCount_" + df.string(from: Date())
  1677. }
  1678. private func incrementTodayAdCount() {
  1679. let key = adCountKeyForToday()
  1680. let current = UserDefaults.standard.integer(forKey: key)
  1681. UserDefaults.standard.set(current + 1, forKey: key)
  1682. }
  1683. private func todayAdCount() -> Int {
  1684. let key = adCountKeyForToday()
  1685. return UserDefaults.standard.integer(forKey: key)
  1686. }
  1687. // MARK: - 数据模型
  1688. // 将 TopOn/AnyThink 收益回传转换为服务端接口字段并上报
  1689. private func uploadAdRevenue(placementID: String, extra: [AnyHashable: Any]) {
  1690. // 读取鉴权 token
  1691. // guard let userInfo = UserDefaults.standard.dictionary(forKey: "userInfo"),
  1692. // let token = userInfo["token"] as? String else {
  1693. // print("[AD-Upload] 无 token,跳过上报")
  1694. // return
  1695. // }
  1696. // 计算 begin / finish(毫秒)并格式化为 "yyyy-MM-dd HH:mm:ss"
  1697. let beginMs = adStartTimes[placementID] ?? nowMillis()
  1698. let finishMs = nowMillis()
  1699. print("testaa \(beginMs) \(String(describing: adStartTimes[placementID])) \(finishMs) \(placementID)")
  1700. adStartTimes.removeValue(forKey: placementID) // 避免复用旧开始时间
  1701. let beginTime = formatMillisToString(beginMs)
  1702. let finishTime = formatMillisToString(finishMs)
  1703. // 将 AnyHashable-key 的字典转为 String-key,且递归转为 JSON 可序列化类型
  1704. func jsonSafe(_ value: Any) -> Any {
  1705. if value is String || value is NSNumber || value is NSNull { return value }
  1706. if let d = value as? [String: Any] { return d.mapValues { jsonSafe($0) } }
  1707. if let d = value as? [AnyHashable: Any] {
  1708. var nd: [String: Any] = [:]
  1709. d.forEach { nd[String(describing: $0.key)] = jsonSafe($0.value) }
  1710. return nd
  1711. }
  1712. if let arr = value as? [Any] { return arr.map { jsonSafe($0) } }
  1713. if let v = value as? NSValue {
  1714. // 处理常见 CoreGraphics 结构体;其余转描述串
  1715. let type = String(cString: v.objCType)
  1716. #if canImport(CoreGraphics)
  1717. if type == "{CGSize=dd}" {
  1718. let s = v.cgSizeValue
  1719. return ["width": s.width, "height": s.height]
  1720. } else if type == "{CGPoint=dd}" {
  1721. let p = v.cgPointValue
  1722. return ["x": p.x, "y": p.y]
  1723. } else if type == "{CGRect={CGPoint=dd}{CGSize=dd}}" {
  1724. let r = v.cgRectValue
  1725. return ["x": r.origin.x, "y": r.origin.y, "width": r.size.width, "height": r.size.height]
  1726. }
  1727. #endif
  1728. return v.description
  1729. }
  1730. if let date = value as? Date { return ISO8601DateFormatter().string(from: date) }
  1731. if let url = value as? URL { return url.absoluteString }
  1732. return String(describing: value)
  1733. }
  1734. // 注意:这里用 jsonSafe 处理每个值
  1735. var extraDict: [String: Any] = [:]
  1736. extra.forEach { (k, v) in
  1737. extraDict[String(describing: k)] = jsonSafe(v)
  1738. }
  1739. // 便捷取值工具
  1740. func str(_ key: String) -> String {
  1741. if let v = extraDict[key] { return String(describing: v) }
  1742. return ""
  1743. }
  1744. func intVal(_ key: String) -> Int {
  1745. if let v = extraDict[key] as? Int { return v }
  1746. if let s = extraDict[key] as? String, let i = Int(s) { return i }
  1747. if let d = extraDict[key] as? Double { return Int(d) }
  1748. return 0
  1749. }
  1750. func dbl(_ key: String) -> Double {
  1751. if let v = extraDict[key] as? Double { return v }
  1752. if let s = extraDict[key] as? String, let d = Double(s) { return d }
  1753. if let i = extraDict[key] as? Int { return Double(i) }
  1754. return 0
  1755. }
  1756. // —— 字段映射(对齐安卓) ——
  1757. // adSourceId
  1758. let adSourceId = intVal("adsource_id")
  1759. // networkFormId / networkName / networkPlacementId
  1760. let networkFormId = intVal("network_firm_id")
  1761. let networkName = str("network_name")
  1762. let networkPlacementId = str("network_placement_id")
  1763. // placementId(iOS 回传里一般是 adunit_id;若无,用回调给的 placementID)
  1764. let placementIdValue = str("adunit_id").isEmpty ? placementID : str("adunit_id")
  1765. // recordId(有的 key 叫 requestId,也可能是 req_id,兜底)
  1766. let recordId = str("requestId").isEmpty ? str("req_id") : str("requestId")
  1767. // revenue(publisher_revenue)
  1768. let revenue = dbl("publisher_revenue")
  1769. // userId / nickName
  1770. let userId = UserDefaults.standard.string(forKey: "roleID") ?? ""
  1771. let iosId = UIDevice.current.identifierForVendor?.uuidString ?? ""
  1772. let nickName = UserDefaults.standard.string(forKey: "memberName") ?? ""
  1773. // ecpm:优先取 adsource_price;若没有,用 revenue*1000 估算一个(单位同 revenue)
  1774. var ecpmStr = str("adsource_price")
  1775. if ecpmStr.isEmpty {
  1776. let ecpm = revenue * 1000.0
  1777. ecpmStr = String(format: "%.6f", ecpm)
  1778. }
  1779. // adSourceIndex / adSourceType(优先 adsource_bid_type: 0=非竞价, 1=竞价)
  1780. let adSourceIndex = intVal("adsource_index")
  1781. // adSourceType 依据 adunit_format 映射:Native->0, RewardedVideo->1, Banner->2, Interstitial->3
  1782. let adUnitFormatRaw = str("adunit_format")
  1783. let adUnitFormat = adUnitFormatRaw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  1784. let adSourceType: Int
  1785. switch adUnitFormat {
  1786. case "native":
  1787. adSourceType = 0
  1788. case "rewardedvideo", "rewarded_video", "rewarded":
  1789. adSourceType = 1
  1790. case "banner":
  1791. adSourceType = 2
  1792. case "interstitial":
  1793. adSourceType = 3
  1794. default:
  1795. // 兜底逻辑:保持原有取值(若 SDK 提供 adsource_bid_type 则沿用)
  1796. adSourceType = intVal("adsource_bid_type")
  1797. }
  1798. // resultJson:把 SDK 回传完整塞进去,便于排查
  1799. let resultJsonData = try? JSONSerialization.data(withJSONObject: extraDict, options: [])
  1800. let resultJson = resultJsonData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
  1801. // appId:iOS 若无可用 App ID,可用 Bundle Identifier 兜底
  1802. let appId = kTakuAppID
  1803. let adCount = todayAdCount()
  1804. // 组装请求体(与安卓 map 字段完全一致)
  1805. let body: [String: Any] = [
  1806. "adSourceId": adSourceId,
  1807. "beginTime": beginTime,
  1808. "finishTime": finishTime,
  1809. "networkFormId": networkFormId,
  1810. "networkName": networkName,
  1811. "networkPlacementId": networkPlacementId,
  1812. "nickName": nickName,
  1813. "placementId": placementIdValue,
  1814. "recordId": recordId,
  1815. "revenue": revenue,
  1816. "userId": userId,
  1817. "ecpm": ecpmStr,
  1818. "adSourceIndex": adSourceIndex,
  1819. "adSourceType": adSourceType,
  1820. "resultJson": resultJson,
  1821. "appId": appId,
  1822. "adCount": adCount,
  1823. "iosId": iosId,
  1824. "begintimestamp": beginMs,
  1825. "finishtimestamp": finishMs
  1826. ]
  1827. // print("testggg \(body)")
  1828. // 发起上报
  1829. let urlString = apiBaseURL + adSaveRecordPath
  1830. // print("[AD-Upload] URL \(urlString)")
  1831. guard let url = URL(string: urlString) else {
  1832. print("[AD-Upload] URL 无效: \(urlString)")
  1833. return
  1834. }
  1835. var request = URLRequest(url: url)
  1836. request.httpMethod = "POST"
  1837. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  1838. // request.setValue(token, forHTTPHeaderField: "Authorization")
  1839. request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
  1840. if let body = request.httpBody,
  1841. let bodyString = String(data: body, encoding: .utf8) {
  1842. print("[AD-Upload] Request URL: \(url)")
  1843. print("[AD-Upload] Request Headers: \(request.allHTTPHeaderFields ?? [:])")
  1844. print("[AD-Upload] Request Body: \(bodyString)")
  1845. }
  1846. URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
  1847. if let error = error {
  1848. print("[AD-Upload] 网络错误: \(error.localizedDescription)")
  1849. return
  1850. }
  1851. if let http = response as? HTTPURLResponse {
  1852. print("[AD-Upload] 响应状态码: \(http.statusCode)")
  1853. }
  1854. if let data = data, let s = String(data: data, encoding: .utf8) {
  1855. print("[AD-Upload] 响应: \(s)")
  1856. // 解析 JSON,并在 code == 301 时弹 toast 并延迟跳转登录
  1857. if let obj = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
  1858. let codeVal: Int = {
  1859. if let i = obj["code"] as? Int { return i }
  1860. if let str = obj["code"] as? String, let i = Int(str) { return i }
  1861. return 0
  1862. }()
  1863. if codeVal == 301 {
  1864. let message = (obj["message"] as? String) ?? (obj["msg"] as? String) ?? "软件被手机系统限制!请稍后再试!"
  1865. DispatchQueue.main.async { [weak self] in
  1866. self?.showToast(message: message)
  1867. DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
  1868. self?.switchToLoginScreen()
  1869. }
  1870. }
  1871. }
  1872. }
  1873. }
  1874. }.resume()
  1875. }
  1876. private func switchToLoginScreen() {
  1877. // // 清除用户登录状态
  1878. // UserDefaults.standard.set(false, forKey: "isLoggedIn")
  1879. // UserDefaults.standard.removeObject(forKey: "userInfo")
  1880. // 创建登录界面
  1881. let loginVC = EntryGateController()
  1882. // let navController = UINavigationController(rootViewController: loginVC)
  1883. // 获取当前窗口
  1884. guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else {
  1885. return
  1886. }
  1887. // 设置根视图控制器为登录界面
  1888. window.rootViewController = loginVC
  1889. // 添加切换动画
  1890. UIView.transition(with: window,
  1891. duration: 0.5,
  1892. options: .transitionCrossDissolve,
  1893. animations: nil,
  1894. completion: nil)
  1895. }
  1896. // MARK: - OverlayPanelAgent
  1897. func overlayPanelDidDismiss(_ panel: OverlayPanelView) {
  1898. // 刷新横幅广告
  1899. removeBanner()
  1900. loadBannerAd()
  1901. // 刷新信息流(原生)广告
  1902. removeNativeAd()
  1903. loadNativeAd()
  1904. }
  1905. }
  1906. // MARK: - UIColor Extension
  1907. extension UIColor {
  1908. convenience init?(hexString: String) {
  1909. var hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
  1910. if hex.hasPrefix("#") {
  1911. hex.remove(at: hex.startIndex)
  1912. }
  1913. var rgbValue: UInt64 = 0
  1914. Scanner(string: hex).scanHexInt64(&rgbValue)
  1915. let r = CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0
  1916. let g = CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0
  1917. let b = CGFloat(rgbValue & 0x0000FF) / 255.0
  1918. self.init(red: r, green: g, blue: b, alpha: 1.0)
  1919. }
  1920. }