event-prepare.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622
  1. import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
  2. import { getEventPlay, launchEvent, 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 { HeartRateController } from '../../engine/sensor/heartRateController'
  7. const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice'
  8. const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
  9. const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1'
  10. type EventPreparePageData = {
  11. eventId: string
  12. loading: boolean
  13. titleText: string
  14. summaryText: string
  15. releaseText: string
  16. actionText: string
  17. statusText: string
  18. assignmentMode: string
  19. variantModeText: string
  20. variantSummaryText: string
  21. presentationText: string
  22. contentBundleText: string
  23. runtimePlaceText: string
  24. runtimeMapText: string
  25. runtimeVariantText: string
  26. runtimeRouteCodeText: string
  27. selectedVariantId: string
  28. selectedVariantText: string
  29. selectableVariants: Array<{
  30. id: string
  31. name: string
  32. routeCodeText: string
  33. descriptionText: string
  34. selected: boolean
  35. }>
  36. locationStatusText: string
  37. heartRateStatusText: string
  38. heartRateDeviceText: string
  39. heartRateScanText: string
  40. heartRateConnected: boolean
  41. showHeartRateDevicePicker: boolean
  42. locationPermissionGranted: boolean
  43. locationBackgroundPermissionGranted: boolean
  44. heartRateDiscoveredDevices: Array<{
  45. deviceId: string
  46. name: string
  47. rssiText: string
  48. preferred: boolean
  49. connected: boolean
  50. }>
  51. mockSourceStatusText: string
  52. }
  53. function formatAssignmentMode(mode?: string | null): string {
  54. if (mode === 'manual') {
  55. return '手动选择'
  56. }
  57. if (mode === 'random') {
  58. return '随机分配'
  59. }
  60. if (mode === 'server-assigned') {
  61. return '后台指定'
  62. }
  63. return '默认单赛道'
  64. }
  65. function formatVariantSummary(result: BackendEventPlayResult): string {
  66. const variants = result.play.courseVariants || []
  67. if (!variants.length) {
  68. return '当前未声明额外赛道版本,启动时按默认赛道进入。'
  69. }
  70. const preview = variants.map((item) => {
  71. const title = item.routeCode || item.name
  72. return item.selectable === false ? `${title}(固定)` : title
  73. }).join(' / ')
  74. if (result.play.assignmentMode === 'manual') {
  75. return `当前活动支持 ${variants.length} 条赛道。本阶段前端先展示赛道信息,最终绑定以后端 launch 返回为准:${preview}`
  76. }
  77. if (result.play.assignmentMode === 'random') {
  78. return `当前活动支持 ${variants.length} 条赛道,进入地图前由后端随机绑定:${preview}`
  79. }
  80. if (result.play.assignmentMode === 'server-assigned') {
  81. return `当前活动赛道由后台预先指定:${preview}`
  82. }
  83. return preview
  84. }
  85. function formatPresentationSummary(result: BackendEventPlayResult): string {
  86. const currentPresentation = result.currentPresentation
  87. if (!currentPresentation) {
  88. return '当前未声明展示版本'
  89. }
  90. return `${currentPresentation.presentationId || '--'} / ${currentPresentation.templateKey || '--'} / ${currentPresentation.version || '--'}`
  91. }
  92. function formatContentBundleSummary(result: BackendEventPlayResult): string {
  93. const currentContentBundle = result.currentContentBundle
  94. if (!currentContentBundle) {
  95. return '当前未声明内容包版本'
  96. }
  97. return `${currentContentBundle.bundleId || '--'} / ${currentContentBundle.bundleType || '--'} / ${currentContentBundle.version || '--'}`
  98. }
  99. function resolveSelectedVariantId(
  100. currentVariantId: string,
  101. assignmentMode?: string | null,
  102. variants?: BackendCourseVariantSummary[] | null,
  103. ): string {
  104. if (assignmentMode !== 'manual' || !variants || !variants.length) {
  105. return ''
  106. }
  107. const selectable = variants.filter((item) => item.selectable !== false)
  108. if (!selectable.length) {
  109. return ''
  110. }
  111. const currentStillExists = selectable.some((item) => item.id === currentVariantId)
  112. if (currentVariantId && currentStillExists) {
  113. return currentVariantId
  114. }
  115. return selectable[0].id
  116. }
  117. function buildSelectableVariants(
  118. selectedVariantId: string,
  119. assignmentMode?: string | null,
  120. variants?: BackendCourseVariantSummary[] | null,
  121. ) {
  122. if (assignmentMode !== 'manual' || !variants || !variants.length) {
  123. return []
  124. }
  125. return variants
  126. .filter((item) => item.selectable !== false)
  127. .map((item) => ({
  128. id: item.id,
  129. name: item.name,
  130. routeCodeText: item.routeCode || '默认编码',
  131. descriptionText: item.description || '暂无赛道说明',
  132. selected: item.id === selectedVariantId,
  133. }))
  134. }
  135. let prepareHeartRateController: HeartRateController | null = null
  136. function getAccessToken(): string | null {
  137. const app = getApp<IAppOption>()
  138. const tokens = app.globalData && app.globalData.backendAuthTokens
  139. ? app.globalData.backendAuthTokens
  140. : loadBackendAuthTokens()
  141. return tokens && tokens.accessToken ? tokens.accessToken : null
  142. }
  143. function loadPreferredHeartRateDeviceName(): string | null {
  144. try {
  145. const stored = wx.getStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY)
  146. if (!stored || typeof stored !== 'object') {
  147. return null
  148. }
  149. const normalized = stored as { name?: unknown }
  150. return typeof normalized.name === 'string' && normalized.name.trim().length > 0
  151. ? normalized.name.trim()
  152. : '心率带'
  153. } catch (_error) {
  154. return null
  155. }
  156. }
  157. function loadStoredMockChannelId(): string {
  158. try {
  159. const stored = wx.getStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY)
  160. if (typeof stored === 'string' && stored.trim().length > 0) {
  161. return stored.trim()
  162. }
  163. } catch (_error) {
  164. return 'default'
  165. }
  166. return 'default'
  167. }
  168. function loadMockAutoConnectEnabled(): boolean {
  169. try {
  170. return wx.getStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY) === true
  171. } catch (_error) {
  172. return false
  173. }
  174. }
  175. Page({
  176. data: {
  177. eventId: '',
  178. loading: false,
  179. titleText: '开始前准备',
  180. summaryText: '未加载',
  181. releaseText: '--',
  182. actionText: '--',
  183. statusText: '待加载',
  184. assignmentMode: '',
  185. variantModeText: '--',
  186. variantSummaryText: '--',
  187. presentationText: '--',
  188. contentBundleText: '--',
  189. runtimePlaceText: '待 launch 确认',
  190. runtimeMapText: '待 launch 确认',
  191. runtimeVariantText: '待 launch 确认',
  192. runtimeRouteCodeText: '待 launch 确认',
  193. selectedVariantId: '',
  194. selectedVariantText: '当前无需手动指定赛道',
  195. selectableVariants: [],
  196. locationStatusText: '待进入地图后校验定位权限与实时精度',
  197. heartRateStatusText: '局前心率带连接入口待接入,本轮先保留骨架',
  198. heartRateDeviceText: '--',
  199. heartRateScanText: '未扫描',
  200. heartRateConnected: false,
  201. showHeartRateDevicePicker: false,
  202. locationPermissionGranted: false,
  203. locationBackgroundPermissionGranted: false,
  204. heartRateDiscoveredDevices: [],
  205. mockSourceStatusText: '模拟源调试仍在地图页调试面板中使用',
  206. } as EventPreparePageData,
  207. onLoad(query: { eventId?: string }) {
  208. const eventId = query && query.eventId ? decodeURIComponent(query.eventId) : ''
  209. if (!eventId) {
  210. this.setData({
  211. statusText: '缺少 eventId',
  212. })
  213. return
  214. }
  215. this.setData({ eventId })
  216. this.ensurePrepareHeartRateController()
  217. this.refreshPreparationDeviceState()
  218. this.loadEventPlay(eventId)
  219. },
  220. onShow() {
  221. this.refreshPreparationDeviceState()
  222. },
  223. onUnload() {
  224. if (prepareHeartRateController) {
  225. prepareHeartRateController.destroy()
  226. prepareHeartRateController = null
  227. }
  228. },
  229. async loadEventPlay(eventId?: string) {
  230. const targetEventId = eventId || this.data.eventId
  231. const accessToken = getAccessToken()
  232. if (!accessToken) {
  233. wx.redirectTo({ url: '/pages/login/login' })
  234. return
  235. }
  236. this.setData({
  237. loading: true,
  238. statusText: '正在加载局前准备信息',
  239. })
  240. try {
  241. const result = await getEventPlay({
  242. baseUrl: loadBackendBaseUrl(),
  243. eventId: targetEventId,
  244. accessToken,
  245. })
  246. this.applyEventPlay(result)
  247. } catch (error) {
  248. const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
  249. this.setData({
  250. loading: false,
  251. statusText: `局前准备加载失败:${message}`,
  252. })
  253. }
  254. },
  255. applyEventPlay(result: BackendEventPlayResult) {
  256. const selectedVariantId = resolveSelectedVariantId(
  257. this.data.selectedVariantId,
  258. result.play.assignmentMode,
  259. result.play.courseVariants,
  260. )
  261. const selectableVariants = buildSelectableVariants(
  262. selectedVariantId,
  263. result.play.assignmentMode,
  264. result.play.courseVariants,
  265. )
  266. const selectedVariant = selectableVariants.find((item) => item.id === selectedVariantId) || null
  267. this.setData({
  268. loading: false,
  269. titleText: `${result.event.displayName} / 开始前准备`,
  270. summaryText: result.event.summary || '暂无活动简介',
  271. releaseText: result.resolvedRelease
  272. ? `${result.resolvedRelease.configLabel} / ${result.resolvedRelease.releaseId}`
  273. : '当前无可用 release',
  274. actionText: formatBackendPlayActionText(result.play.primaryAction, result.play.reason),
  275. statusText: formatBackendPlayStatusText(result.play.canLaunch, result.play.primaryAction, result.play.reason),
  276. assignmentMode: result.play.assignmentMode || '',
  277. variantModeText: formatAssignmentMode(result.play.assignmentMode),
  278. variantSummaryText: formatVariantSummary(result),
  279. presentationText: formatPresentationSummary(result),
  280. contentBundleText: formatContentBundleSummary(result),
  281. runtimePlaceText: '待 launch.runtime 确认',
  282. runtimeMapText: '待 launch.runtime 确认',
  283. runtimeVariantText: selectedVariant
  284. ? selectedVariant.name
  285. : (result.play.courseVariants && result.play.courseVariants[0]
  286. ? result.play.courseVariants[0].name
  287. : '待 launch 确认'),
  288. runtimeRouteCodeText: selectedVariant
  289. ? selectedVariant.routeCodeText
  290. : (result.play.courseVariants && result.play.courseVariants[0] && result.play.courseVariants[0].routeCode
  291. ? result.play.courseVariants[0].routeCode || '待 launch 确认'
  292. : '待 launch 确认'),
  293. selectedVariantId,
  294. selectedVariantText: selectedVariant
  295. ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
  296. : '当前无需手动指定赛道',
  297. selectableVariants,
  298. })
  299. },
  300. refreshPreparationDeviceState() {
  301. this.refreshLocationPermissionStatus()
  302. this.refreshHeartRatePreparationStatus()
  303. this.refreshMockSourcePreparationStatus()
  304. },
  305. ensurePrepareHeartRateController() {
  306. if (prepareHeartRateController) {
  307. return prepareHeartRateController
  308. }
  309. prepareHeartRateController = new HeartRateController({
  310. onHeartRate: () => {},
  311. onStatus: (message) => {
  312. this.setData({
  313. heartRateStatusText: message,
  314. })
  315. },
  316. onError: (message) => {
  317. this.setData({
  318. heartRateStatusText: message,
  319. })
  320. },
  321. onConnectionChange: (connected, deviceName) => {
  322. this.setData({
  323. heartRateConnected: connected,
  324. heartRateDeviceText: connected ? (deviceName || '心率带') : (deviceName || '--'),
  325. })
  326. this.refreshHeartRatePreparationStatus()
  327. },
  328. onDeviceListChange: (devices) => {
  329. this.setData({
  330. heartRateScanText: devices.length ? `已发现 ${devices.length} 个设备` : '未扫描',
  331. heartRateDiscoveredDevices: devices.map((device) => ({
  332. deviceId: device.deviceId,
  333. name: device.name,
  334. rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --',
  335. preferred: !!device.isPreferred,
  336. connected: !!prepareHeartRateController
  337. && !!prepareHeartRateController.currentDeviceId
  338. && prepareHeartRateController.currentDeviceId === device.deviceId
  339. && prepareHeartRateController.connected,
  340. })),
  341. })
  342. },
  343. })
  344. return prepareHeartRateController
  345. },
  346. refreshLocationPermissionStatus() {
  347. wx.getSetting({
  348. success: (result) => {
  349. const authSetting = result && result.authSetting
  350. ? result.authSetting as Record<string, boolean | undefined>
  351. : {}
  352. const hasForeground = authSetting['scope.userLocation'] === true
  353. const hasBackground = authSetting['scope.userLocationBackground'] === true
  354. let locationStatusText = '未请求定位权限'
  355. if (hasForeground && hasBackground) {
  356. locationStatusText = '已授权前后台定位'
  357. } else if (hasForeground) {
  358. locationStatusText = '已授权前台定位'
  359. } else if (authSetting['scope.userLocation'] === false) {
  360. locationStatusText = '定位权限被拒绝'
  361. }
  362. this.setData({
  363. locationStatusText,
  364. locationPermissionGranted: hasForeground,
  365. locationBackgroundPermissionGranted: hasBackground,
  366. })
  367. },
  368. fail: () => {
  369. this.setData({
  370. locationStatusText: '无法读取定位权限状态',
  371. locationPermissionGranted: false,
  372. locationBackgroundPermissionGranted: false,
  373. })
  374. },
  375. })
  376. },
  377. handleRequestLocationPermission() {
  378. wx.authorize({
  379. scope: 'scope.userLocation',
  380. success: () => {
  381. this.refreshLocationPermissionStatus()
  382. wx.showToast({
  383. title: '前台定位已授权',
  384. icon: 'none',
  385. })
  386. },
  387. fail: () => {
  388. this.refreshLocationPermissionStatus()
  389. wx.showToast({
  390. title: '请在设置中开启定位权限',
  391. icon: 'none',
  392. })
  393. },
  394. })
  395. },
  396. handleOpenLocationSettings() {
  397. wx.openSetting({
  398. success: () => {
  399. this.refreshLocationPermissionStatus()
  400. },
  401. fail: () => {
  402. wx.showToast({
  403. title: '无法打开设置面板',
  404. icon: 'none',
  405. })
  406. },
  407. })
  408. },
  409. refreshHeartRatePreparationStatus() {
  410. const controller = this.ensurePrepareHeartRateController()
  411. const preferredDeviceName = loadPreferredHeartRateDeviceName()
  412. this.setData({
  413. heartRateStatusText: controller.connected
  414. ? '局前心率带已连接'
  415. : preferredDeviceName
  416. ? `已记住首选设备:${preferredDeviceName}`
  417. : '未设置首选设备,可在此连接或进入地图后连接',
  418. heartRateDeviceText: controller.currentDeviceName || preferredDeviceName || '--',
  419. heartRateScanText: controller.scanning
  420. ? '扫描中'
  421. : (controller.discoveredDevices.length ? `已发现 ${controller.discoveredDevices.length} 个设备` : '未扫描'),
  422. heartRateConnected: controller.connected,
  423. heartRateDiscoveredDevices: controller.discoveredDevices.map((device) => ({
  424. deviceId: device.deviceId,
  425. name: device.name,
  426. rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --',
  427. preferred: !!device.isPreferred,
  428. connected: !!controller.currentDeviceId && controller.currentDeviceId === device.deviceId && controller.connected,
  429. })),
  430. })
  431. },
  432. refreshMockSourcePreparationStatus() {
  433. const channelId = loadStoredMockChannelId()
  434. const autoConnect = loadMockAutoConnectEnabled()
  435. this.setData({
  436. mockSourceStatusText: autoConnect
  437. ? `自动连接已开启 / 通道 ${channelId}`
  438. : `自动连接未开启 / 通道 ${channelId}`,
  439. })
  440. },
  441. handleRefresh() {
  442. this.loadEventPlay()
  443. },
  444. handleBack() {
  445. wx.navigateBack()
  446. },
  447. handlePrepareHeartRateConnect() {
  448. const controller = this.ensurePrepareHeartRateController()
  449. controller.startScanAndConnect()
  450. this.refreshHeartRatePreparationStatus()
  451. },
  452. handleOpenHeartRateDevicePicker() {
  453. const controller = this.ensurePrepareHeartRateController()
  454. this.setData({
  455. showHeartRateDevicePicker: true,
  456. })
  457. if (!controller.scanning) {
  458. controller.startScanAndConnect()
  459. }
  460. this.refreshHeartRatePreparationStatus()
  461. },
  462. handleCloseHeartRateDevicePicker() {
  463. this.setData({
  464. showHeartRateDevicePicker: false,
  465. })
  466. },
  467. handlePrepareHeartRateDeviceConnect(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) {
  468. const deviceId = event.currentTarget.dataset.deviceId
  469. if (!deviceId) {
  470. return
  471. }
  472. const controller = this.ensurePrepareHeartRateController()
  473. controller.connectToDiscoveredDevice(deviceId)
  474. this.setData({
  475. showHeartRateDevicePicker: false,
  476. })
  477. this.refreshHeartRatePreparationStatus()
  478. },
  479. handlePrepareHeartRateDisconnect() {
  480. if (!prepareHeartRateController) {
  481. return
  482. }
  483. prepareHeartRateController.disconnect()
  484. this.setData({
  485. heartRateConnected: false,
  486. })
  487. this.refreshHeartRatePreparationStatus()
  488. },
  489. handlePrepareHeartRateClearPreferred() {
  490. const controller = this.ensurePrepareHeartRateController()
  491. controller.clearPreferredDevice()
  492. this.refreshHeartRatePreparationStatus()
  493. },
  494. handleSelectVariant(event: WechatMiniprogram.BaseEvent<{ variantId?: string }>) {
  495. const variantId = event.currentTarget.dataset.variantId
  496. if (!variantId) {
  497. return
  498. }
  499. const selectableVariants = this.data.selectableVariants.map((item) => ({
  500. ...item,
  501. selected: item.id === variantId,
  502. }))
  503. const selectedVariant = selectableVariants.find((item) => item.id === variantId) || null
  504. this.setData({
  505. selectedVariantId: variantId,
  506. selectedVariantText: selectedVariant
  507. ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
  508. : '当前无需手动指定赛道',
  509. runtimeVariantText: selectedVariant ? selectedVariant.name : '待 launch 确认',
  510. runtimeRouteCodeText: selectedVariant ? selectedVariant.routeCodeText : '待 launch 确认',
  511. selectableVariants,
  512. })
  513. },
  514. async handleLaunch() {
  515. const accessToken = getAccessToken()
  516. if (!accessToken) {
  517. wx.redirectTo({ url: '/pages/login/login' })
  518. return
  519. }
  520. if (!this.data.locationPermissionGranted) {
  521. this.setData({
  522. statusText: '进入地图前请先完成定位授权',
  523. })
  524. wx.showToast({
  525. title: '请先授权定位',
  526. icon: 'none',
  527. })
  528. return
  529. }
  530. this.setData({
  531. statusText: '正在创建 session 并进入地图',
  532. })
  533. try {
  534. const app = getApp<IAppOption>()
  535. if (app.globalData) {
  536. const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName
  537. ? prepareHeartRateController.currentDeviceName
  538. : loadPreferredHeartRateDeviceName()
  539. app.globalData.pendingHeartRateAutoConnect = {
  540. enabled: !!pendingDeviceName,
  541. deviceName: pendingDeviceName || null,
  542. }
  543. }
  544. if (prepareHeartRateController) {
  545. prepareHeartRateController.destroy()
  546. prepareHeartRateController = null
  547. }
  548. const result = await launchEvent({
  549. baseUrl: loadBackendBaseUrl(),
  550. eventId: this.data.eventId,
  551. accessToken,
  552. variantId: this.data.assignmentMode === 'manual' ? this.data.selectedVariantId : undefined,
  553. clientType: 'wechat',
  554. deviceKey: 'mini-dev-device-001',
  555. })
  556. const envelope = adaptBackendLaunchResultToEnvelope(result)
  557. wx.navigateTo({
  558. url: prepareMapPageUrlForLaunch(envelope),
  559. })
  560. } catch (error) {
  561. const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
  562. this.setData({
  563. statusText: `launch 失败:${message}`,
  564. })
  565. }
  566. },
  567. })