event-prepare.ts 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160
  1. import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
  2. import { getEventPlay, getPublicEventPlay, launchEvent, launchPublicEvent, type BackendCourseVariantSummary, type BackendEventPlayResult } from '../../utils/backendApi'
  3. import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
  4. import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy'
  5. import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
  6. import { reportBackendClientLog } from '../../utils/backendClientLogs'
  7. import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig'
  8. import { buildPreparePreviewScene, buildPreparePreviewSceneFromBackendPreview, buildPreparePreviewSceneFromVariantControls, type PreparePreviewControl, type PreparePreviewScene, type PreparePreviewTile } from '../../utils/prepareMapPreview'
  9. import { HeartRateController } from '../../engine/sensor/heartRateController'
  10. const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice'
  11. const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
  12. const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1'
  13. type EventPreparePageData = {
  14. eventId: string
  15. loading: boolean
  16. canLaunch: boolean
  17. launchInFlight: boolean
  18. launchProgressText: string
  19. launchProgressPercent: number
  20. titleText: string
  21. summaryText: string
  22. releaseText: string
  23. actionText: string
  24. statusText: string
  25. assignmentMode: string
  26. variantModeText: string
  27. variantSummaryText: string
  28. presentationText: string
  29. contentBundleText: string
  30. runtimePlaceText: string
  31. runtimeMapText: string
  32. runtimeVariantText: string
  33. runtimeRouteCodeText: string
  34. previewVisible: boolean
  35. previewLoading: boolean
  36. previewStatusText: string
  37. previewHintText: string
  38. previewVariantText: string
  39. previewTiles: Array<{
  40. url: string
  41. styleText: string
  42. }>
  43. previewControls: Array<{
  44. label: string
  45. styleText: string
  46. kindClass: string
  47. }>
  48. selectedVariantId: string
  49. selectedVariantText: string
  50. showVariantSelector: boolean
  51. variantSelectorEmptyText: string
  52. selectableVariants: Array<{
  53. id: string
  54. name: string
  55. routeCodeText: string
  56. descriptionText: string
  57. selected: boolean
  58. }>
  59. locationStatusText: string
  60. heartRateStatusText: string
  61. heartRateDeviceText: string
  62. heartRateScanText: string
  63. heartRateConnected: boolean
  64. showHeartRateDevicePicker: boolean
  65. locationPermissionGranted: boolean
  66. locationBackgroundPermissionGranted: boolean
  67. heartRateDiscoveredDevices: Array<{
  68. deviceId: string
  69. name: string
  70. rssiText: string
  71. preferred: boolean
  72. connected: boolean
  73. }>
  74. mockSourceStatusText: string
  75. showMockSourceSummary: boolean
  76. }
  77. type EventPreparePageContext = WechatMiniprogram.Page.Instance<EventPreparePageData, Record<string, never>> & {
  78. previewLoadSeq?: number
  79. lastPlayResult?: BackendEventPlayResult | null
  80. previewManifestUrl?: string | null
  81. previewConfigCache?: RemoteMapConfig | null
  82. previewSceneCache?: Record<string, PreparePreviewScene>
  83. launchAttemptSeq?: number
  84. launchTimeoutTimer?: number
  85. }
  86. const PREVIEW_WIDTH = 640
  87. const PREVIEW_HEIGHT = 360
  88. const PREPARE_LAUNCH_TIMEOUT_MS = 12000
  89. function toPercent(value: number, total: number): string {
  90. if (!total) {
  91. return '0%'
  92. }
  93. return `${(value / total) * 100}%`
  94. }
  95. function buildPreviewTileView(scene: PreparePreviewScene, tile: PreparePreviewTile) {
  96. const left = toPercent(tile.leftPx, scene.width)
  97. const top = toPercent(tile.topPx, scene.height)
  98. const width = toPercent(tile.sizePx, scene.width)
  99. const height = toPercent(tile.sizePx, scene.height)
  100. return {
  101. url: tile.url,
  102. styleText: `left:${left};top:${top};width:${width};height:${height};`,
  103. }
  104. }
  105. function buildPreviewControlView(scene: PreparePreviewScene, control: PreparePreviewControl) {
  106. let kindClass = 'preview-control--normal'
  107. if (control.kind === 'start') {
  108. kindClass = 'preview-control--start'
  109. } else if (control.kind === 'finish') {
  110. kindClass = 'preview-control--finish'
  111. }
  112. return {
  113. label: control.label,
  114. kindClass,
  115. styleText: `left:${toPercent(control.x, scene.width)};top:${toPercent(control.y, scene.height)};`,
  116. }
  117. }
  118. function resolvePreviewManifestUrl(result: BackendEventPlayResult): string {
  119. if (result.resolvedRelease && result.resolvedRelease.manifestUrl) {
  120. return result.resolvedRelease.manifestUrl
  121. }
  122. if (result.release && result.release.manifestUrl) {
  123. return result.release.manifestUrl
  124. }
  125. return ''
  126. }
  127. function canUseBackendPreview(result: BackendEventPlayResult): boolean {
  128. return !!(
  129. result.preview
  130. && result.preview.baseTiles
  131. && result.preview.baseTiles.tileBaseUrl
  132. && result.preview.viewport
  133. && typeof result.preview.viewport.minLon === 'number'
  134. && typeof result.preview.viewport.minLat === 'number'
  135. && typeof result.preview.viewport.maxLon === 'number'
  136. && typeof result.preview.viewport.maxLat === 'number'
  137. )
  138. }
  139. function resolveSelectedPreviewVariant(result: BackendEventPlayResult, selectedVariantId: string) {
  140. if (!result.preview || !result.preview.variants || !result.preview.variants.length) {
  141. return null
  142. }
  143. const normalizedVariantId = selectedVariantId || (result.preview.selectedVariantId || '')
  144. const exact = result.preview.variants.find((item) => {
  145. const candidateId = item.variantId || item.id || ''
  146. return candidateId === normalizedVariantId
  147. })
  148. if (exact) {
  149. return exact
  150. }
  151. return result.preview.variants[0]
  152. }
  153. function resolvePreviewHintText(result: BackendEventPlayResult, scene: PreparePreviewScene): string {
  154. if (detectMultiVariantContext(result)) {
  155. return scene.overlayAvailable
  156. ? '当前先展示低级别底图与已知赛道形态;多赛道最终以进入地图后的绑定结果为准。'
  157. : '当前活动支持多赛道;当前先展示底图与所选赛道信息,赛道点位预览待后端补齐每条赛道的预览数据后联动。'
  158. }
  159. return scene.overlayAvailable
  160. ? '当前先展示低级别底图与当前已知赛道,进入地图后按正式地图继续。'
  161. : '当前先展示地图范围预览,进入地图后再查看正式赛道。'
  162. }
  163. function detectMultiVariantContext(result: BackendEventPlayResult): boolean {
  164. const assignmentMode = result.play.assignmentMode
  165. if (assignmentMode === 'manual' || assignmentMode === 'random' || assignmentMode === 'server-assigned') {
  166. return true
  167. }
  168. const variants = result.play.courseVariants || []
  169. if (variants.length > 0) {
  170. return true
  171. }
  172. const haystacks = [
  173. result.event.displayName,
  174. result.event.summary,
  175. result.release ? result.release.configLabel : '',
  176. result.resolvedRelease ? result.resolvedRelease.configLabel : '',
  177. ]
  178. return haystacks.some((item) => typeof item === 'string' && item.indexOf('多赛道') >= 0)
  179. }
  180. function formatAssignmentMode(mode?: string | null): string {
  181. if (mode === 'manual') {
  182. return '手动选择'
  183. }
  184. if (mode === 'random') {
  185. return '随机分配'
  186. }
  187. if (mode === 'server-assigned') {
  188. return '后台指定'
  189. }
  190. return '默认单赛道'
  191. }
  192. function formatVariantSummary(result: BackendEventPlayResult): string {
  193. const variants = result.play.courseVariants || []
  194. if (!variants.length) {
  195. return '当前未声明额外赛道版本,启动时按默认赛道进入。'
  196. }
  197. const preview = variants.map((item) => {
  198. const title = item.routeCode || item.name
  199. return item.selectable === false ? `${title}(固定)` : title
  200. }).join(' / ')
  201. const selectableCount = variants.filter((item) => item.selectable !== false).length
  202. if (result.play.assignmentMode === 'manual') {
  203. return `当前活动支持 ${variants.length} 条赛道。本阶段前端先展示赛道信息,最终绑定以后端 launch 返回为准:${preview}`
  204. }
  205. if (result.play.assignmentMode === 'random') {
  206. return `当前活动支持 ${variants.length} 条赛道,进入地图前由后端随机绑定:${preview}`
  207. }
  208. if (result.play.assignmentMode === 'server-assigned') {
  209. return `当前活动赛道由后台预先指定:${preview}`
  210. }
  211. if (selectableCount > 1) {
  212. return `当前活动支持 ${variants.length} 条赛道。后端当前未明确返回赛道模式,前端先按手动选择兼容显示:${preview}`
  213. }
  214. return preview
  215. }
  216. function formatPresentationSummary(result: BackendEventPlayResult): string {
  217. const currentPresentation = result.currentPresentation
  218. if (!currentPresentation) {
  219. return '当前发布 release 未绑定展示版本,或当前尚未发布'
  220. }
  221. return `${currentPresentation.presentationId || '--'} / ${currentPresentation.templateKey || '--'} / ${currentPresentation.version || '--'}`
  222. }
  223. function formatContentBundleSummary(result: BackendEventPlayResult): string {
  224. const currentContentBundle = result.currentContentBundle
  225. if (!currentContentBundle) {
  226. return '当前发布 release 未绑定内容包版本,或当前尚未发布'
  227. }
  228. return `${currentContentBundle.bundleId || '--'} / ${currentContentBundle.bundleType || '--'} / ${currentContentBundle.version || '--'}`
  229. }
  230. function resolveSelectedVariantId(
  231. currentVariantId: string,
  232. assignmentMode?: string | null,
  233. variants?: BackendCourseVariantSummary[] | null,
  234. forceVisible?: boolean,
  235. ): string {
  236. if (!shouldShowVariantSelector(assignmentMode, variants, forceVisible)) {
  237. return ''
  238. }
  239. const selectable = (variants || []).filter((item) => item.selectable !== false)
  240. if (!selectable.length) {
  241. return ''
  242. }
  243. const currentStillExists = selectable.some((item) => item.id === currentVariantId)
  244. if (currentVariantId && currentStillExists) {
  245. return currentVariantId
  246. }
  247. return selectable[0].id
  248. }
  249. function buildSelectableVariants(
  250. selectedVariantId: string,
  251. assignmentMode?: string | null,
  252. variants?: BackendCourseVariantSummary[] | null,
  253. forceVisible?: boolean,
  254. ) {
  255. if (!shouldShowVariantSelector(assignmentMode, variants, forceVisible) || !variants || !variants.length) {
  256. return []
  257. }
  258. return variants
  259. .filter((item) => item.selectable !== false)
  260. .map((item) => ({
  261. id: item.id,
  262. name: item.name,
  263. routeCodeText: item.routeCode || '默认编码',
  264. descriptionText: item.description || '暂无赛道说明',
  265. selected: item.id === selectedVariantId,
  266. }))
  267. }
  268. function shouldShowVariantSelector(
  269. assignmentMode?: string | null,
  270. variants?: BackendCourseVariantSummary[] | null,
  271. forceVisible?: boolean,
  272. ): boolean {
  273. if (forceVisible) {
  274. return true
  275. }
  276. const normalizedVariants = variants || []
  277. if (!normalizedVariants.length) {
  278. return false
  279. }
  280. if (assignmentMode === 'manual') {
  281. return true
  282. }
  283. if (assignmentMode === 'random' || assignmentMode === 'server-assigned') {
  284. return false
  285. }
  286. return normalizedVariants.filter((item) => item.selectable !== false).length > 1
  287. }
  288. let prepareHeartRateController: HeartRateController | null = null
  289. function clearPrepareLaunchTimeout(page: EventPreparePageContext) {
  290. if (page.launchTimeoutTimer) {
  291. clearTimeout(page.launchTimeoutTimer)
  292. page.launchTimeoutTimer = 0
  293. }
  294. }
  295. function resetPrepareLaunchVisualState(page: EventPreparePageContext) {
  296. clearPrepareLaunchTimeout(page)
  297. page.launchAttemptSeq = 0
  298. page.setData({
  299. launchInFlight: false,
  300. launchProgressText: '待进入地图',
  301. launchProgressPercent: 0,
  302. })
  303. }
  304. function getAccessToken(): string | null {
  305. const app = getApp<IAppOption>()
  306. const tokens = app.globalData && app.globalData.backendAuthTokens
  307. ? app.globalData.backendAuthTokens
  308. : loadBackendAuthTokens()
  309. return tokens && tokens.accessToken ? tokens.accessToken : null
  310. }
  311. function loadPreferredHeartRateDeviceName(): string | null {
  312. try {
  313. const stored = wx.getStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY)
  314. if (!stored || typeof stored !== 'object') {
  315. return null
  316. }
  317. const normalized = stored as { name?: unknown }
  318. return typeof normalized.name === 'string' && normalized.name.trim().length > 0
  319. ? normalized.name.trim()
  320. : '心率带'
  321. } catch (_error) {
  322. return null
  323. }
  324. }
  325. function loadStoredMockChannelId(): string {
  326. try {
  327. const stored = wx.getStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY)
  328. if (typeof stored === 'string' && stored.trim().length > 0) {
  329. return stored.trim()
  330. }
  331. } catch (_error) {
  332. return 'default'
  333. }
  334. return 'default'
  335. }
  336. function loadMockAutoConnectEnabled(): boolean {
  337. try {
  338. return wx.getStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY) === true
  339. } catch (_error) {
  340. return false
  341. }
  342. }
  343. Page({
  344. data: {
  345. eventId: '',
  346. loading: false,
  347. canLaunch: false,
  348. launchInFlight: false,
  349. launchProgressText: '待进入地图',
  350. launchProgressPercent: 0,
  351. titleText: '开始前准备',
  352. summaryText: '未加载',
  353. releaseText: '--',
  354. actionText: '--',
  355. statusText: '待加载',
  356. assignmentMode: '',
  357. variantModeText: '--',
  358. variantSummaryText: '--',
  359. presentationText: '--',
  360. contentBundleText: '--',
  361. runtimePlaceText: '进入地图后确认',
  362. runtimeMapText: '进入地图后确认',
  363. runtimeVariantText: '进入地图后确认',
  364. runtimeRouteCodeText: '进入地图后确认',
  365. previewVisible: false,
  366. previewLoading: false,
  367. previewStatusText: '准备加载地图预览',
  368. previewHintText: '进入地图前先看地图范围与当前已知赛道。',
  369. previewVariantText: '预览将跟随当前赛道选择联动',
  370. previewTiles: [],
  371. previewControls: [],
  372. selectedVariantId: '',
  373. selectedVariantText: '当前无需手动指定赛道',
  374. showVariantSelector: false,
  375. variantSelectorEmptyText: '当前无需手动指定赛道',
  376. selectableVariants: [],
  377. locationStatusText: '待进入地图后校验定位权限与实时精度',
  378. heartRateStatusText: '局前心率带连接入口待接入,本轮先保留骨架',
  379. heartRateDeviceText: '--',
  380. heartRateScanText: '未扫描',
  381. heartRateConnected: false,
  382. showHeartRateDevicePicker: false,
  383. locationPermissionGranted: false,
  384. locationBackgroundPermissionGranted: false,
  385. heartRateDiscoveredDevices: [],
  386. mockSourceStatusText: '模拟源调试仍在地图页调试面板中使用',
  387. showMockSourceSummary: false,
  388. } as EventPreparePageData,
  389. onLoad(query: { eventId?: string }) {
  390. const eventId = query && query.eventId ? decodeURIComponent(query.eventId) : ''
  391. if (!eventId) {
  392. this.setData({
  393. statusText: '缺少 eventId',
  394. })
  395. return
  396. }
  397. this.setData({ eventId })
  398. this.ensurePrepareHeartRateController()
  399. this.refreshPreparationDeviceState()
  400. this.loadEventPlay(eventId)
  401. },
  402. onShow() {
  403. resetPrepareLaunchVisualState(this as unknown as EventPreparePageContext)
  404. this.refreshPreparationDeviceState()
  405. },
  406. onUnload() {
  407. resetPrepareLaunchVisualState(this as unknown as EventPreparePageContext)
  408. if (prepareHeartRateController) {
  409. prepareHeartRateController.destroy()
  410. prepareHeartRateController = null
  411. }
  412. },
  413. async loadEventPlay(eventId?: string) {
  414. const targetEventId = eventId || this.data.eventId
  415. const accessToken = getAccessToken()
  416. this.setData({
  417. loading: true,
  418. statusText: '正在加载局前准备信息',
  419. })
  420. try {
  421. const baseUrl = loadBackendBaseUrl()
  422. const result = accessToken
  423. ? await getEventPlay({
  424. baseUrl,
  425. eventId: targetEventId,
  426. accessToken,
  427. })
  428. : await getPublicEventPlay({
  429. baseUrl,
  430. eventId: targetEventId,
  431. })
  432. this.applyEventPlay(result)
  433. } catch (error) {
  434. const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
  435. this.setData({
  436. loading: false,
  437. statusText: `局前准备加载失败:${message}`,
  438. })
  439. }
  440. },
  441. applyEventPlay(result: BackendEventPlayResult) {
  442. ;(this as unknown as EventPreparePageContext).lastPlayResult = result
  443. const page = this as unknown as EventPreparePageContext
  444. const nextManifestUrl = resolvePreviewManifestUrl(result)
  445. if (page.previewManifestUrl !== nextManifestUrl) {
  446. page.previewManifestUrl = nextManifestUrl
  447. page.previewConfigCache = null
  448. page.previewSceneCache = {}
  449. }
  450. const multiVariantContext = detectMultiVariantContext(result)
  451. const selectedVariantId = resolveSelectedVariantId(
  452. this.data.selectedVariantId,
  453. result.play.assignmentMode,
  454. result.play.courseVariants,
  455. multiVariantContext,
  456. )
  457. const assignmentMode = result.play.assignmentMode ? result.play.assignmentMode : null
  458. const showVariantSelector = shouldShowVariantSelector(
  459. result.play.assignmentMode,
  460. result.play.courseVariants,
  461. multiVariantContext,
  462. )
  463. const logVariantId = assignmentMode === 'manual' && selectedVariantId ? selectedVariantId : null
  464. const selectableVariants = buildSelectableVariants(
  465. selectedVariantId,
  466. result.play.assignmentMode,
  467. result.play.courseVariants,
  468. multiVariantContext,
  469. )
  470. const selectedVariant = selectableVariants.find((item) => item.id === selectedVariantId) || null
  471. reportBackendClientLog({
  472. level: 'info',
  473. category: 'event-prepare',
  474. message: 'prepare play loaded',
  475. eventId: result.event.id || this.data.eventId || '',
  476. releaseId: result.resolvedRelease && result.resolvedRelease.releaseId
  477. ? result.resolvedRelease.releaseId
  478. : '',
  479. manifestUrl: result.resolvedRelease && result.resolvedRelease.manifestUrl
  480. ? result.resolvedRelease.manifestUrl
  481. : '',
  482. details: {
  483. guestMode: !getAccessToken(),
  484. pageEventId: this.data.eventId || '',
  485. resultEventId: result.event.id || '',
  486. selectedVariantId: logVariantId,
  487. assignmentMode,
  488. variantCount: result.play.courseVariants ? result.play.courseVariants.length : 0,
  489. selectableVariantCount: result.play.courseVariants
  490. ? result.play.courseVariants.filter((item) => item.selectable !== false).length
  491. : 0,
  492. showVariantSelector,
  493. multiVariantContext,
  494. },
  495. })
  496. const variantSelectorEmptyText = multiVariantContext
  497. ? '当前活动按多赛道处理,但后端暂未返回可选赛道,请稍后刷新或联系后台。'
  498. : '当前无需手动指定赛道'
  499. this.setData({
  500. loading: false,
  501. canLaunch: result.play.canLaunch,
  502. titleText: `${result.event.displayName} / 开始前准备`,
  503. summaryText: result.event.summary || '暂无活动简介',
  504. releaseText: result.resolvedRelease
  505. ? `${result.resolvedRelease.configLabel} / ${result.resolvedRelease.releaseId}`
  506. : '当前无可用 release',
  507. actionText: formatBackendPlayActionText(result.play.primaryAction, result.play.reason),
  508. statusText: formatBackendPlayStatusText(result.play.canLaunch, result.play.primaryAction, result.play.reason),
  509. assignmentMode: result.play.assignmentMode || '',
  510. variantModeText: result.play.assignmentMode
  511. ? formatAssignmentMode(result.play.assignmentMode)
  512. : (showVariantSelector ? '手动选择' : '默认单赛道'),
  513. variantSummaryText: formatVariantSummary(result),
  514. presentationText: formatPresentationSummary(result),
  515. contentBundleText: formatContentBundleSummary(result),
  516. runtimePlaceText: '进入地图后确认',
  517. runtimeMapText: '进入地图后确认',
  518. runtimeVariantText: selectedVariant
  519. ? selectedVariant.name
  520. : (result.play.courseVariants && result.play.courseVariants[0]
  521. ? result.play.courseVariants[0].name
  522. : '进入地图后确认'),
  523. runtimeRouteCodeText: selectedVariant
  524. ? selectedVariant.routeCodeText
  525. : (result.play.courseVariants && result.play.courseVariants[0] && result.play.courseVariants[0].routeCode
  526. ? result.play.courseVariants[0].routeCode || '进入地图后确认'
  527. : '进入地图后确认'),
  528. previewVisible: true,
  529. previewLoading: true,
  530. previewStatusText: '正在生成地图预览',
  531. previewHintText: '进入地图前先看地图范围与当前已知赛道。',
  532. previewVariantText: selectedVariant
  533. ? `当前预览赛道:${selectedVariant.name} / ${selectedVariant.routeCodeText}`
  534. : (multiVariantContext ? '当前预览赛道:待选择' : '当前预览赛道:默认赛道'),
  535. previewTiles: [],
  536. previewControls: [],
  537. selectedVariantId,
  538. selectedVariantText: selectedVariant
  539. ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
  540. : variantSelectorEmptyText,
  541. showVariantSelector,
  542. variantSelectorEmptyText,
  543. selectableVariants,
  544. })
  545. this.loadPrepareMapPreview(result)
  546. },
  547. async loadPrepareMapPreview(result: BackendEventPlayResult) {
  548. const page = this as unknown as EventPreparePageContext
  549. const seq = (page.previewLoadSeq || 0) + 1
  550. page.previewLoadSeq = seq
  551. const selectedVariantId = this.data.selectedVariantId || (result.preview && result.preview.selectedVariantId ? result.preview.selectedVariantId : '')
  552. const manifestUrl = resolvePreviewManifestUrl(result)
  553. let fallbackConfig: RemoteMapConfig | null = page.previewConfigCache || null
  554. const multiVariantContext = detectMultiVariantContext(result)
  555. if (multiVariantContext && canUseBackendPreview(result) && result.preview) {
  556. const sceneCacheKey = selectedVariantId || '__default__'
  557. const cachedScene = page.previewSceneCache && page.previewSceneCache[sceneCacheKey]
  558. if (cachedScene) {
  559. const previewTiles = cachedScene.tiles.map((item) => buildPreviewTileView(cachedScene, item))
  560. const previewControls = cachedScene.controls.map((item) => buildPreviewControlView(cachedScene, item))
  561. this.setData({
  562. previewVisible: true,
  563. previewLoading: false,
  564. previewStatusText: cachedScene.overlayAvailable ? '已加载地图与赛道预览' : '已加载地图范围预览',
  565. previewHintText: cachedScene.overlayAvailable
  566. ? '当前预览已按所选赛道联动显示点位,最终绑定以后端 launch 返回结果为准。'
  567. : '当前预览已切换到所选赛道的底图范围;该赛道暂未返回点位预览数据。',
  568. previewVariantText: selectedVariantId
  569. ? `当前预览赛道:${this.data.selectedVariantText}`
  570. : '当前预览赛道:默认赛道',
  571. previewTiles,
  572. previewControls,
  573. runtimePlaceText: result.event.displayName || this.data.runtimePlaceText,
  574. })
  575. return
  576. }
  577. if (manifestUrl) {
  578. if (!fallbackConfig) {
  579. try {
  580. fallbackConfig = await loadRemoteMapConfig(manifestUrl)
  581. page.previewConfigCache = fallbackConfig
  582. } catch (_error) {
  583. fallbackConfig = null
  584. }
  585. }
  586. }
  587. const selectedPreviewVariant = resolveSelectedPreviewVariant(result, selectedVariantId)
  588. const scene = fallbackConfig && selectedPreviewVariant && selectedPreviewVariant.controls
  589. ? buildPreparePreviewSceneFromVariantControls(
  590. fallbackConfig,
  591. PREVIEW_WIDTH,
  592. PREVIEW_HEIGHT,
  593. selectedPreviewVariant.controls,
  594. )
  595. : buildPreparePreviewSceneFromBackendPreview(
  596. result.preview,
  597. PREVIEW_WIDTH,
  598. PREVIEW_HEIGHT,
  599. selectedVariantId,
  600. fallbackConfig ? fallbackConfig.tileSource : null,
  601. )
  602. if (page.previewLoadSeq !== seq) {
  603. return
  604. }
  605. if (scene) {
  606. if (!page.previewSceneCache) {
  607. page.previewSceneCache = {}
  608. }
  609. page.previewSceneCache[sceneCacheKey] = scene
  610. const previewTiles = scene.tiles.map((item) => buildPreviewTileView(scene, item))
  611. const previewControls = scene.controls.map((item) => buildPreviewControlView(scene, item))
  612. this.setData({
  613. previewVisible: true,
  614. previewLoading: false,
  615. previewStatusText: scene.overlayAvailable ? '已加载地图与赛道预览' : '已加载地图范围预览',
  616. previewHintText: scene.overlayAvailable
  617. ? '当前预览已按所选赛道联动显示点位,最终绑定以后端 launch 返回结果为准。'
  618. : '当前预览已切换到所选赛道的底图范围;该赛道暂未返回点位预览数据。',
  619. previewVariantText: selectedVariantId
  620. ? `当前预览赛道:${this.data.selectedVariantText}`
  621. : '当前预览赛道:默认赛道',
  622. previewTiles,
  623. previewControls,
  624. runtimePlaceText: result.event.displayName || this.data.runtimePlaceText,
  625. })
  626. return
  627. }
  628. }
  629. if (!manifestUrl) {
  630. this.setData({
  631. previewVisible: true,
  632. previewLoading: false,
  633. previewStatusText: '当前发布未返回预览底图来源',
  634. previewHintText: '当前活动暂无可用地图预览,请稍后刷新或联系后台。',
  635. previewVariantText: '当前预览赛道:待进入地图后确认',
  636. previewTiles: [],
  637. previewControls: [],
  638. })
  639. return
  640. }
  641. try {
  642. const config = fallbackConfig || await loadRemoteMapConfig(manifestUrl)
  643. page.previewConfigCache = config
  644. if (page.previewLoadSeq !== seq) {
  645. return
  646. }
  647. const overlayEnabled = !multiVariantContext
  648. const scene = buildPreparePreviewScene(config, PREVIEW_WIDTH, PREVIEW_HEIGHT, overlayEnabled)
  649. const previewTiles = scene.tiles.map((item) => buildPreviewTileView(scene, item))
  650. const previewControls = scene.controls.map((item) => buildPreviewControlView(scene, item))
  651. const runtimeMapText = config.configTitle || '进入地图后确认'
  652. const runtimePlaceText = result.event.displayName || '进入地图后确认'
  653. this.setData({
  654. previewVisible: true,
  655. previewLoading: false,
  656. previewStatusText: scene.overlayAvailable ? '已加载地图与赛道预览' : '已加载地图范围预览',
  657. previewHintText: resolvePreviewHintText(result, scene),
  658. previewVariantText: this.data.selectedVariantId
  659. ? `当前预览赛道:${this.data.selectedVariantText}`
  660. : (result.play.courseVariants && result.play.courseVariants[0]
  661. ? `当前预览赛道:${result.play.courseVariants[0].name} / ${result.play.courseVariants[0].routeCode || '默认编码'}`
  662. : '当前预览赛道:默认赛道'),
  663. previewTiles,
  664. previewControls,
  665. runtimePlaceText,
  666. runtimeMapText,
  667. })
  668. } catch (error) {
  669. if (page.previewLoadSeq !== seq) {
  670. return
  671. }
  672. const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
  673. this.setData({
  674. previewVisible: true,
  675. previewLoading: false,
  676. previewStatusText: `地图预览加载失败:${message}`,
  677. previewHintText: '当前先展示文字摘要;预览底图可在刷新后重试。',
  678. previewVariantText: '当前预览赛道:待进入地图后确认',
  679. previewTiles: [],
  680. previewControls: [],
  681. })
  682. }
  683. },
  684. refreshPreparationDeviceState() {
  685. this.refreshLocationPermissionStatus()
  686. this.refreshHeartRatePreparationStatus()
  687. this.refreshMockSourcePreparationStatus()
  688. },
  689. ensurePrepareHeartRateController() {
  690. if (prepareHeartRateController) {
  691. return prepareHeartRateController
  692. }
  693. prepareHeartRateController = new HeartRateController({
  694. onHeartRate: () => {},
  695. onStatus: (message) => {
  696. this.setData({
  697. heartRateStatusText: message,
  698. })
  699. },
  700. onError: (message) => {
  701. this.setData({
  702. heartRateStatusText: message,
  703. })
  704. },
  705. onConnectionChange: (connected, deviceName) => {
  706. this.setData({
  707. heartRateConnected: connected,
  708. heartRateDeviceText: connected ? (deviceName || '心率带') : (deviceName || '--'),
  709. })
  710. this.refreshHeartRatePreparationStatus()
  711. },
  712. onDeviceListChange: (devices) => {
  713. this.setData({
  714. heartRateScanText: devices.length ? `已发现 ${devices.length} 个设备` : '未扫描',
  715. heartRateDiscoveredDevices: devices.map((device) => ({
  716. deviceId: device.deviceId,
  717. name: device.name,
  718. rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --',
  719. preferred: !!device.isPreferred,
  720. connected: !!prepareHeartRateController
  721. && !!prepareHeartRateController.currentDeviceId
  722. && prepareHeartRateController.currentDeviceId === device.deviceId
  723. && prepareHeartRateController.connected,
  724. })),
  725. })
  726. },
  727. })
  728. return prepareHeartRateController
  729. },
  730. refreshLocationPermissionStatus() {
  731. wx.getSetting({
  732. success: (result) => {
  733. const authSetting = result && result.authSetting
  734. ? result.authSetting as Record<string, boolean | undefined>
  735. : {}
  736. const hasForeground = authSetting['scope.userLocation'] === true
  737. const hasBackground = authSetting['scope.userLocationBackground'] === true
  738. let locationStatusText = '未请求定位权限'
  739. if (hasForeground && hasBackground) {
  740. locationStatusText = '已授权前后台定位'
  741. } else if (hasForeground) {
  742. locationStatusText = '已授权前台定位'
  743. } else if (authSetting['scope.userLocation'] === false) {
  744. locationStatusText = '定位权限被拒绝'
  745. }
  746. this.setData({
  747. locationStatusText,
  748. locationPermissionGranted: hasForeground,
  749. locationBackgroundPermissionGranted: hasBackground,
  750. })
  751. },
  752. fail: () => {
  753. this.setData({
  754. locationStatusText: '无法读取定位权限状态',
  755. locationPermissionGranted: false,
  756. locationBackgroundPermissionGranted: false,
  757. })
  758. },
  759. })
  760. },
  761. handleRequestLocationPermission() {
  762. wx.authorize({
  763. scope: 'scope.userLocation',
  764. success: () => {
  765. this.refreshLocationPermissionStatus()
  766. wx.showToast({
  767. title: '前台定位已授权',
  768. icon: 'none',
  769. })
  770. },
  771. fail: () => {
  772. this.refreshLocationPermissionStatus()
  773. wx.showToast({
  774. title: '请在设置中开启定位权限',
  775. icon: 'none',
  776. })
  777. },
  778. })
  779. },
  780. handleOpenLocationSettings() {
  781. wx.openSetting({
  782. success: () => {
  783. this.refreshLocationPermissionStatus()
  784. },
  785. fail: () => {
  786. wx.showToast({
  787. title: '无法打开设置面板',
  788. icon: 'none',
  789. })
  790. },
  791. })
  792. },
  793. refreshHeartRatePreparationStatus() {
  794. const controller = this.ensurePrepareHeartRateController()
  795. const preferredDeviceName = loadPreferredHeartRateDeviceName()
  796. this.setData({
  797. heartRateStatusText: controller.connected
  798. ? '局前心率带已连接'
  799. : preferredDeviceName
  800. ? `已记住首选设备:${preferredDeviceName}`
  801. : '未设置首选设备,可在此连接或进入地图后连接',
  802. heartRateDeviceText: controller.currentDeviceName || preferredDeviceName || '--',
  803. heartRateScanText: controller.scanning
  804. ? '扫描中'
  805. : (controller.discoveredDevices.length ? `已发现 ${controller.discoveredDevices.length} 个设备` : '未扫描'),
  806. heartRateConnected: controller.connected,
  807. heartRateDiscoveredDevices: controller.discoveredDevices.map((device) => ({
  808. deviceId: device.deviceId,
  809. name: device.name,
  810. rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --',
  811. preferred: !!device.isPreferred,
  812. connected: !!controller.currentDeviceId && controller.currentDeviceId === device.deviceId && controller.connected,
  813. })),
  814. })
  815. },
  816. refreshMockSourcePreparationStatus() {
  817. const channelId = loadStoredMockChannelId()
  818. const autoConnect = loadMockAutoConnectEnabled()
  819. const showMockSourceSummary = autoConnect || channelId !== 'default'
  820. this.setData({
  821. mockSourceStatusText: autoConnect
  822. ? `调试源自动连接已开启 / 通道 ${channelId}`
  823. : `当前使用调试通道 ${channelId}`,
  824. showMockSourceSummary,
  825. })
  826. },
  827. handleRefresh() {
  828. this.loadEventPlay()
  829. },
  830. handleBack() {
  831. wx.navigateBack()
  832. },
  833. handlePrepareHeartRateConnect() {
  834. const controller = this.ensurePrepareHeartRateController()
  835. controller.startScanAndConnect()
  836. this.refreshHeartRatePreparationStatus()
  837. },
  838. handleOpenHeartRateDevicePicker() {
  839. const controller = this.ensurePrepareHeartRateController()
  840. this.setData({
  841. showHeartRateDevicePicker: true,
  842. })
  843. if (!controller.scanning) {
  844. controller.startScanAndConnect()
  845. }
  846. this.refreshHeartRatePreparationStatus()
  847. },
  848. handleCloseHeartRateDevicePicker() {
  849. this.setData({
  850. showHeartRateDevicePicker: false,
  851. })
  852. },
  853. handlePrepareHeartRateDeviceConnect(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) {
  854. const deviceId = event.currentTarget.dataset.deviceId
  855. if (!deviceId) {
  856. return
  857. }
  858. const controller = this.ensurePrepareHeartRateController()
  859. controller.connectToDiscoveredDevice(deviceId)
  860. this.setData({
  861. showHeartRateDevicePicker: false,
  862. })
  863. this.refreshHeartRatePreparationStatus()
  864. },
  865. handlePrepareHeartRateDisconnect() {
  866. if (!prepareHeartRateController) {
  867. return
  868. }
  869. prepareHeartRateController.disconnect()
  870. this.setData({
  871. heartRateConnected: false,
  872. })
  873. this.refreshHeartRatePreparationStatus()
  874. },
  875. handlePrepareHeartRateClearPreferred() {
  876. const controller = this.ensurePrepareHeartRateController()
  877. controller.clearPreferredDevice()
  878. this.refreshHeartRatePreparationStatus()
  879. },
  880. handleSelectVariant(event: WechatMiniprogram.BaseEvent<{ variantId?: string }>) {
  881. const variantId = event.currentTarget.dataset.variantId
  882. if (!variantId) {
  883. return
  884. }
  885. const selectableVariants = this.data.selectableVariants.map((item) => ({
  886. ...item,
  887. selected: item.id === variantId,
  888. }))
  889. const selectedVariant = selectableVariants.find((item) => item.id === variantId) || null
  890. this.setData({
  891. selectedVariantId: variantId,
  892. selectedVariantText: selectedVariant
  893. ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
  894. : '当前无需手动指定赛道',
  895. runtimeVariantText: selectedVariant ? selectedVariant.name : '进入地图后确认',
  896. runtimeRouteCodeText: selectedVariant ? selectedVariant.routeCodeText : '进入地图后确认',
  897. previewHintText: selectedVariant
  898. ? (this.data.showVariantSelector
  899. ? `当前已选 ${selectedVariant.name} / ${selectedVariant.routeCodeText};预览底图会保留不变,最终赛道以 launch 绑定结果为准。`
  900. : `当前已选 ${selectedVariant.name} / ${selectedVariant.routeCodeText};最终地图以 launch 绑定结果为准。`)
  901. : this.data.previewHintText,
  902. previewStatusText: this.data.showVariantSelector ? '已加载地图范围预览' : this.data.previewStatusText,
  903. previewVariantText: selectedVariant
  904. ? `当前预览赛道:${selectedVariant.name} / ${selectedVariant.routeCodeText}`
  905. : '当前预览赛道:待选择',
  906. selectableVariants,
  907. })
  908. const page = this as unknown as EventPreparePageContext
  909. if (page.lastPlayResult) {
  910. this.loadPrepareMapPreview(page.lastPlayResult)
  911. }
  912. },
  913. async handleLaunch() {
  914. const page = this as unknown as EventPreparePageContext
  915. const accessToken = getAccessToken()
  916. if (this.data.launchInFlight) {
  917. wx.showToast({
  918. title: '正在进入地图,请稍候',
  919. icon: 'none',
  920. })
  921. return
  922. }
  923. if (!this.data.canLaunch) {
  924. this.setData({
  925. statusText: '当前发布状态不可进入地图',
  926. })
  927. wx.showToast({
  928. title: '当前发布状态不可进入地图',
  929. icon: 'none',
  930. })
  931. return
  932. }
  933. if (!this.data.locationPermissionGranted) {
  934. this.setData({
  935. statusText: '进入地图前请先完成定位授权',
  936. })
  937. wx.showToast({
  938. title: '请先授权定位',
  939. icon: 'none',
  940. })
  941. return
  942. }
  943. this.setData({
  944. launchInFlight: true,
  945. launchProgressText: '正在校验并创建本局',
  946. launchProgressPercent: 24,
  947. statusText: '正在创建 session 并进入地图',
  948. })
  949. const launchSeq = (page.launchAttemptSeq || 0) + 1
  950. page.launchAttemptSeq = launchSeq
  951. clearPrepareLaunchTimeout(page)
  952. page.launchTimeoutTimer = setTimeout(() => {
  953. if (page.launchAttemptSeq !== launchSeq) {
  954. return
  955. }
  956. this.setData({
  957. launchInFlight: false,
  958. launchProgressText: '进入地图超时',
  959. launchProgressPercent: 0,
  960. statusText: '进入地图超时,请稍后重试',
  961. })
  962. wx.showToast({
  963. title: '进入地图超时,请重试',
  964. icon: 'none',
  965. })
  966. }, PREPARE_LAUNCH_TIMEOUT_MS) as unknown as number
  967. try {
  968. const assignmentMode = this.data.assignmentMode ? this.data.assignmentMode : null
  969. const selectedVariantId = this.data.showVariantSelector && this.data.selectedVariantId
  970. ? this.data.selectedVariantId
  971. : null
  972. reportBackendClientLog({
  973. level: 'info',
  974. category: 'event-prepare',
  975. message: 'launch requested',
  976. eventId: this.data.eventId || '',
  977. details: {
  978. pageEventId: this.data.eventId || '',
  979. selectedVariantId,
  980. assignmentMode,
  981. phase: 'launch-requested',
  982. },
  983. })
  984. this.setData({
  985. launchProgressText: '已发起启动请求,正在等待服务器响应',
  986. launchProgressPercent: 52,
  987. })
  988. const app = getApp<IAppOption>()
  989. if (app.globalData) {
  990. const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName
  991. ? prepareHeartRateController.currentDeviceName
  992. : loadPreferredHeartRateDeviceName()
  993. app.globalData.pendingHeartRateAutoConnect = {
  994. enabled: !!pendingDeviceName,
  995. deviceName: pendingDeviceName || null,
  996. }
  997. }
  998. if (prepareHeartRateController) {
  999. prepareHeartRateController.destroy()
  1000. prepareHeartRateController = null
  1001. }
  1002. const result = accessToken
  1003. ? await launchEvent({
  1004. baseUrl: loadBackendBaseUrl(),
  1005. eventId: this.data.eventId,
  1006. accessToken,
  1007. variantId: this.data.showVariantSelector ? this.data.selectedVariantId : undefined,
  1008. clientType: 'wechat',
  1009. deviceKey: 'mini-dev-device-001',
  1010. })
  1011. : await launchPublicEvent({
  1012. baseUrl: loadBackendBaseUrl(),
  1013. eventId: this.data.eventId,
  1014. variantId: this.data.showVariantSelector ? this.data.selectedVariantId : undefined,
  1015. clientType: 'wechat',
  1016. deviceKey: 'mini-dev-device-001',
  1017. })
  1018. if (page.launchAttemptSeq !== launchSeq) {
  1019. return
  1020. }
  1021. clearPrepareLaunchTimeout(page)
  1022. this.setData({
  1023. launchProgressText: '启动成功,正在载入地图',
  1024. launchProgressPercent: 86,
  1025. })
  1026. reportBackendClientLog({
  1027. level: 'info',
  1028. category: 'event-prepare',
  1029. message: 'launch response received',
  1030. eventId: result.launch.business && result.launch.business.eventId ? result.launch.business.eventId : this.data.eventId || '',
  1031. releaseId: result.launch.config && result.launch.config.releaseId ? result.launch.config.releaseId : '',
  1032. sessionId: result.launch.business && result.launch.business.sessionId ? result.launch.business.sessionId : '',
  1033. manifestUrl: result.launch.resolvedRelease && result.launch.resolvedRelease.manifestUrl
  1034. ? result.launch.resolvedRelease.manifestUrl
  1035. : '',
  1036. details: {
  1037. guestMode: !accessToken,
  1038. pageEventId: this.data.eventId || '',
  1039. launchEventId: result.launch.business && result.launch.business.eventId ? result.launch.business.eventId : '',
  1040. launchSessionId: result.launch.business && result.launch.business.sessionId ? result.launch.business.sessionId : '',
  1041. configUrl: result.launch.config && result.launch.config.configUrl ? result.launch.config.configUrl : '',
  1042. releaseId: result.launch.config && result.launch.config.releaseId ? result.launch.config.releaseId : '',
  1043. resolvedReleaseId: result.launch.resolvedRelease && result.launch.resolvedRelease.releaseId
  1044. ? result.launch.resolvedRelease.releaseId
  1045. : '',
  1046. resolvedManifestUrl: result.launch.resolvedRelease && result.launch.resolvedRelease.manifestUrl
  1047. ? result.launch.resolvedRelease.manifestUrl
  1048. : '',
  1049. launchVariantId: result.launch.variant && result.launch.variant.id ? result.launch.variant.id : null,
  1050. phase: 'launch-response',
  1051. },
  1052. })
  1053. const envelope = adaptBackendLaunchResultToEnvelope(result)
  1054. wx.navigateTo({
  1055. url: prepareMapPageUrlForLaunch(envelope),
  1056. })
  1057. } catch (error) {
  1058. if (page.launchAttemptSeq !== launchSeq) {
  1059. return
  1060. }
  1061. clearPrepareLaunchTimeout(page)
  1062. const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
  1063. this.setData({
  1064. launchInFlight: false,
  1065. launchProgressText: '进入地图失败',
  1066. launchProgressPercent: 0,
  1067. statusText: `launch 失败:${message}`,
  1068. })
  1069. }
  1070. },
  1071. })