event-prepare.ts 18 KB

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