import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' import { getEventPlay, launchEvent, type BackendCourseVariantSummary, type BackendEventPlayResult } from '../../utils/backendApi' import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter' import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy' import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch' import { reportBackendClientLog } from '../../utils/backendClientLogs' import { HeartRateController } from '../../engine/sensor/heartRateController' const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice' const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1' const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1' type EventPreparePageData = { eventId: string loading: boolean canLaunch: boolean titleText: string summaryText: string releaseText: string actionText: string statusText: string assignmentMode: string variantModeText: string variantSummaryText: string presentationText: string contentBundleText: string runtimePlaceText: string runtimeMapText: string runtimeVariantText: string runtimeRouteCodeText: string selectedVariantId: string selectedVariantText: string showVariantSelector: boolean variantSelectorEmptyText: string selectableVariants: Array<{ id: string name: string routeCodeText: string descriptionText: string selected: boolean }> locationStatusText: string heartRateStatusText: string heartRateDeviceText: string heartRateScanText: string heartRateConnected: boolean showHeartRateDevicePicker: boolean locationPermissionGranted: boolean locationBackgroundPermissionGranted: boolean heartRateDiscoveredDevices: Array<{ deviceId: string name: string rssiText: string preferred: boolean connected: boolean }> mockSourceStatusText: string } function detectMultiVariantContext(result: BackendEventPlayResult): boolean { const assignmentMode = result.play.assignmentMode if (assignmentMode === 'manual' || assignmentMode === 'random' || assignmentMode === 'server-assigned') { return true } const variants = result.play.courseVariants || [] if (variants.length > 0) { return true } const haystacks = [ result.event.displayName, result.event.summary, result.release ? result.release.configLabel : '', result.resolvedRelease ? result.resolvedRelease.configLabel : '', ] return haystacks.some((item) => typeof item === 'string' && item.indexOf('多赛道') >= 0) } function formatAssignmentMode(mode?: string | null): string { if (mode === 'manual') { return '手动选择' } if (mode === 'random') { return '随机分配' } if (mode === 'server-assigned') { return '后台指定' } return '默认单赛道' } function formatVariantSummary(result: BackendEventPlayResult): string { const variants = result.play.courseVariants || [] if (!variants.length) { return '当前未声明额外赛道版本,启动时按默认赛道进入。' } const preview = variants.map((item) => { const title = item.routeCode || item.name return item.selectable === false ? `${title}(固定)` : title }).join(' / ') const selectableCount = variants.filter((item) => item.selectable !== false).length if (result.play.assignmentMode === 'manual') { return `当前活动支持 ${variants.length} 条赛道。本阶段前端先展示赛道信息,最终绑定以后端 launch 返回为准:${preview}` } if (result.play.assignmentMode === 'random') { return `当前活动支持 ${variants.length} 条赛道,进入地图前由后端随机绑定:${preview}` } if (result.play.assignmentMode === 'server-assigned') { return `当前活动赛道由后台预先指定:${preview}` } if (selectableCount > 1) { return `当前活动支持 ${variants.length} 条赛道。后端当前未明确返回赛道模式,前端先按手动选择兼容显示:${preview}` } return preview } function formatPresentationSummary(result: BackendEventPlayResult): string { const currentPresentation = result.currentPresentation if (!currentPresentation) { return '当前发布 release 未绑定展示版本,或当前尚未发布' } return `${currentPresentation.presentationId || '--'} / ${currentPresentation.templateKey || '--'} / ${currentPresentation.version || '--'}` } function formatContentBundleSummary(result: BackendEventPlayResult): string { const currentContentBundle = result.currentContentBundle if (!currentContentBundle) { return '当前发布 release 未绑定内容包版本,或当前尚未发布' } return `${currentContentBundle.bundleId || '--'} / ${currentContentBundle.bundleType || '--'} / ${currentContentBundle.version || '--'}` } function resolveSelectedVariantId( currentVariantId: string, assignmentMode?: string | null, variants?: BackendCourseVariantSummary[] | null, forceVisible?: boolean, ): string { if (!shouldShowVariantSelector(assignmentMode, variants, forceVisible)) { return '' } const selectable = (variants || []).filter((item) => item.selectable !== false) if (!selectable.length) { return '' } const currentStillExists = selectable.some((item) => item.id === currentVariantId) if (currentVariantId && currentStillExists) { return currentVariantId } return selectable[0].id } function buildSelectableVariants( selectedVariantId: string, assignmentMode?: string | null, variants?: BackendCourseVariantSummary[] | null, forceVisible?: boolean, ) { if (!shouldShowVariantSelector(assignmentMode, variants, forceVisible) || !variants || !variants.length) { return [] } return variants .filter((item) => item.selectable !== false) .map((item) => ({ id: item.id, name: item.name, routeCodeText: item.routeCode || '默认编码', descriptionText: item.description || '暂无赛道说明', selected: item.id === selectedVariantId, })) } function shouldShowVariantSelector( assignmentMode?: string | null, variants?: BackendCourseVariantSummary[] | null, forceVisible?: boolean, ): boolean { if (forceVisible) { return true } const normalizedVariants = variants || [] if (!normalizedVariants.length) { return false } if (assignmentMode === 'manual') { return true } if (assignmentMode === 'random' || assignmentMode === 'server-assigned') { return false } return normalizedVariants.filter((item) => item.selectable !== false).length > 1 } let prepareHeartRateController: HeartRateController | null = null function getAccessToken(): string | null { const app = getApp() const tokens = app.globalData && app.globalData.backendAuthTokens ? app.globalData.backendAuthTokens : loadBackendAuthTokens() return tokens && tokens.accessToken ? tokens.accessToken : null } function loadPreferredHeartRateDeviceName(): string | null { try { const stored = wx.getStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY) if (!stored || typeof stored !== 'object') { return null } const normalized = stored as { name?: unknown } return typeof normalized.name === 'string' && normalized.name.trim().length > 0 ? normalized.name.trim() : '心率带' } catch (_error) { return null } } function loadStoredMockChannelId(): string { try { const stored = wx.getStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY) if (typeof stored === 'string' && stored.trim().length > 0) { return stored.trim() } } catch (_error) { return 'default' } return 'default' } function loadMockAutoConnectEnabled(): boolean { try { return wx.getStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY) === true } catch (_error) { return false } } Page({ data: { eventId: '', loading: false, canLaunch: false, titleText: '开始前准备', summaryText: '未加载', releaseText: '--', actionText: '--', statusText: '待加载', assignmentMode: '', variantModeText: '--', variantSummaryText: '--', presentationText: '--', contentBundleText: '--', runtimePlaceText: '待 launch 确认', runtimeMapText: '待 launch 确认', runtimeVariantText: '待 launch 确认', runtimeRouteCodeText: '待 launch 确认', selectedVariantId: '', selectedVariantText: '当前无需手动指定赛道', showVariantSelector: false, variantSelectorEmptyText: '当前无需手动指定赛道', selectableVariants: [], locationStatusText: '待进入地图后校验定位权限与实时精度', heartRateStatusText: '局前心率带连接入口待接入,本轮先保留骨架', heartRateDeviceText: '--', heartRateScanText: '未扫描', heartRateConnected: false, showHeartRateDevicePicker: false, locationPermissionGranted: false, locationBackgroundPermissionGranted: false, heartRateDiscoveredDevices: [], mockSourceStatusText: '模拟源调试仍在地图页调试面板中使用', } as EventPreparePageData, onLoad(query: { eventId?: string }) { const eventId = query && query.eventId ? decodeURIComponent(query.eventId) : '' if (!eventId) { this.setData({ statusText: '缺少 eventId', }) return } this.setData({ eventId }) this.ensurePrepareHeartRateController() this.refreshPreparationDeviceState() this.loadEventPlay(eventId) }, onShow() { this.refreshPreparationDeviceState() }, onUnload() { if (prepareHeartRateController) { prepareHeartRateController.destroy() prepareHeartRateController = null } }, async loadEventPlay(eventId?: string) { const targetEventId = eventId || this.data.eventId const accessToken = getAccessToken() if (!accessToken) { wx.redirectTo({ url: '/pages/login/login' }) return } this.setData({ loading: true, statusText: '正在加载局前准备信息', }) try { const result = await getEventPlay({ baseUrl: loadBackendBaseUrl(), eventId: targetEventId, accessToken, }) this.applyEventPlay(result) } catch (error) { const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误' this.setData({ loading: false, statusText: `局前准备加载失败:${message}`, }) } }, applyEventPlay(result: BackendEventPlayResult) { const multiVariantContext = detectMultiVariantContext(result) const selectedVariantId = resolveSelectedVariantId( this.data.selectedVariantId, result.play.assignmentMode, result.play.courseVariants, multiVariantContext, ) const assignmentMode = result.play.assignmentMode ? result.play.assignmentMode : null const showVariantSelector = shouldShowVariantSelector( result.play.assignmentMode, result.play.courseVariants, multiVariantContext, ) const logVariantId = assignmentMode === 'manual' && selectedVariantId ? selectedVariantId : null const selectableVariants = buildSelectableVariants( selectedVariantId, result.play.assignmentMode, result.play.courseVariants, multiVariantContext, ) const selectedVariant = selectableVariants.find((item) => item.id === selectedVariantId) || null reportBackendClientLog({ level: 'info', category: 'event-prepare', message: 'prepare play loaded', eventId: result.event.id || this.data.eventId || '', releaseId: result.resolvedRelease && result.resolvedRelease.releaseId ? result.resolvedRelease.releaseId : '', manifestUrl: result.resolvedRelease && result.resolvedRelease.manifestUrl ? result.resolvedRelease.manifestUrl : '', details: { pageEventId: this.data.eventId || '', resultEventId: result.event.id || '', selectedVariantId: logVariantId, assignmentMode, variantCount: result.play.courseVariants ? result.play.courseVariants.length : 0, selectableVariantCount: result.play.courseVariants ? result.play.courseVariants.filter((item) => item.selectable !== false).length : 0, showVariantSelector, multiVariantContext, }, }) const variantSelectorEmptyText = multiVariantContext ? '当前活动按多赛道处理,但后端暂未返回可选赛道,请稍后刷新或联系后台。' : '当前无需手动指定赛道' this.setData({ loading: false, canLaunch: result.play.canLaunch, titleText: `${result.event.displayName} / 开始前准备`, summaryText: result.event.summary || '暂无活动简介', releaseText: result.resolvedRelease ? `${result.resolvedRelease.configLabel} / ${result.resolvedRelease.releaseId}` : '当前无可用 release', actionText: formatBackendPlayActionText(result.play.primaryAction, result.play.reason), statusText: formatBackendPlayStatusText(result.play.canLaunch, result.play.primaryAction, result.play.reason), assignmentMode: result.play.assignmentMode || '', variantModeText: result.play.assignmentMode ? formatAssignmentMode(result.play.assignmentMode) : (showVariantSelector ? '手动选择' : '默认单赛道'), variantSummaryText: formatVariantSummary(result), presentationText: formatPresentationSummary(result), contentBundleText: formatContentBundleSummary(result), runtimePlaceText: '待 launch.runtime 确认', runtimeMapText: '待 launch.runtime 确认', runtimeVariantText: selectedVariant ? selectedVariant.name : (result.play.courseVariants && result.play.courseVariants[0] ? result.play.courseVariants[0].name : '待 launch 确认'), runtimeRouteCodeText: selectedVariant ? selectedVariant.routeCodeText : (result.play.courseVariants && result.play.courseVariants[0] && result.play.courseVariants[0].routeCode ? result.play.courseVariants[0].routeCode || '待 launch 确认' : '待 launch 确认'), selectedVariantId, selectedVariantText: selectedVariant ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}` : variantSelectorEmptyText, showVariantSelector, variantSelectorEmptyText, selectableVariants, }) }, refreshPreparationDeviceState() { this.refreshLocationPermissionStatus() this.refreshHeartRatePreparationStatus() this.refreshMockSourcePreparationStatus() }, ensurePrepareHeartRateController() { if (prepareHeartRateController) { return prepareHeartRateController } prepareHeartRateController = new HeartRateController({ onHeartRate: () => {}, onStatus: (message) => { this.setData({ heartRateStatusText: message, }) }, onError: (message) => { this.setData({ heartRateStatusText: message, }) }, onConnectionChange: (connected, deviceName) => { this.setData({ heartRateConnected: connected, heartRateDeviceText: connected ? (deviceName || '心率带') : (deviceName || '--'), }) this.refreshHeartRatePreparationStatus() }, onDeviceListChange: (devices) => { this.setData({ heartRateScanText: devices.length ? `已发现 ${devices.length} 个设备` : '未扫描', heartRateDiscoveredDevices: devices.map((device) => ({ deviceId: device.deviceId, name: device.name, rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --', preferred: !!device.isPreferred, connected: !!prepareHeartRateController && !!prepareHeartRateController.currentDeviceId && prepareHeartRateController.currentDeviceId === device.deviceId && prepareHeartRateController.connected, })), }) }, }) return prepareHeartRateController }, refreshLocationPermissionStatus() { wx.getSetting({ success: (result) => { const authSetting = result && result.authSetting ? result.authSetting as Record : {} const hasForeground = authSetting['scope.userLocation'] === true const hasBackground = authSetting['scope.userLocationBackground'] === true let locationStatusText = '未请求定位权限' if (hasForeground && hasBackground) { locationStatusText = '已授权前后台定位' } else if (hasForeground) { locationStatusText = '已授权前台定位' } else if (authSetting['scope.userLocation'] === false) { locationStatusText = '定位权限被拒绝' } this.setData({ locationStatusText, locationPermissionGranted: hasForeground, locationBackgroundPermissionGranted: hasBackground, }) }, fail: () => { this.setData({ locationStatusText: '无法读取定位权限状态', locationPermissionGranted: false, locationBackgroundPermissionGranted: false, }) }, }) }, handleRequestLocationPermission() { wx.authorize({ scope: 'scope.userLocation', success: () => { this.refreshLocationPermissionStatus() wx.showToast({ title: '前台定位已授权', icon: 'none', }) }, fail: () => { this.refreshLocationPermissionStatus() wx.showToast({ title: '请在设置中开启定位权限', icon: 'none', }) }, }) }, handleOpenLocationSettings() { wx.openSetting({ success: () => { this.refreshLocationPermissionStatus() }, fail: () => { wx.showToast({ title: '无法打开设置面板', icon: 'none', }) }, }) }, refreshHeartRatePreparationStatus() { const controller = this.ensurePrepareHeartRateController() const preferredDeviceName = loadPreferredHeartRateDeviceName() this.setData({ heartRateStatusText: controller.connected ? '局前心率带已连接' : preferredDeviceName ? `已记住首选设备:${preferredDeviceName}` : '未设置首选设备,可在此连接或进入地图后连接', heartRateDeviceText: controller.currentDeviceName || preferredDeviceName || '--', heartRateScanText: controller.scanning ? '扫描中' : (controller.discoveredDevices.length ? `已发现 ${controller.discoveredDevices.length} 个设备` : '未扫描'), heartRateConnected: controller.connected, heartRateDiscoveredDevices: controller.discoveredDevices.map((device) => ({ deviceId: device.deviceId, name: device.name, rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --', preferred: !!device.isPreferred, connected: !!controller.currentDeviceId && controller.currentDeviceId === device.deviceId && controller.connected, })), }) }, refreshMockSourcePreparationStatus() { const channelId = loadStoredMockChannelId() const autoConnect = loadMockAutoConnectEnabled() this.setData({ mockSourceStatusText: autoConnect ? `自动连接已开启 / 通道 ${channelId}` : `自动连接未开启 / 通道 ${channelId}`, }) }, handleRefresh() { this.loadEventPlay() }, handleBack() { wx.navigateBack() }, handlePrepareHeartRateConnect() { const controller = this.ensurePrepareHeartRateController() controller.startScanAndConnect() this.refreshHeartRatePreparationStatus() }, handleOpenHeartRateDevicePicker() { const controller = this.ensurePrepareHeartRateController() this.setData({ showHeartRateDevicePicker: true, }) if (!controller.scanning) { controller.startScanAndConnect() } this.refreshHeartRatePreparationStatus() }, handleCloseHeartRateDevicePicker() { this.setData({ showHeartRateDevicePicker: false, }) }, handlePrepareHeartRateDeviceConnect(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) { const deviceId = event.currentTarget.dataset.deviceId if (!deviceId) { return } const controller = this.ensurePrepareHeartRateController() controller.connectToDiscoveredDevice(deviceId) this.setData({ showHeartRateDevicePicker: false, }) this.refreshHeartRatePreparationStatus() }, handlePrepareHeartRateDisconnect() { if (!prepareHeartRateController) { return } prepareHeartRateController.disconnect() this.setData({ heartRateConnected: false, }) this.refreshHeartRatePreparationStatus() }, handlePrepareHeartRateClearPreferred() { const controller = this.ensurePrepareHeartRateController() controller.clearPreferredDevice() this.refreshHeartRatePreparationStatus() }, handleSelectVariant(event: WechatMiniprogram.BaseEvent<{ variantId?: string }>) { const variantId = event.currentTarget.dataset.variantId if (!variantId) { return } const selectableVariants = this.data.selectableVariants.map((item) => ({ ...item, selected: item.id === variantId, })) const selectedVariant = selectableVariants.find((item) => item.id === variantId) || null this.setData({ selectedVariantId: variantId, selectedVariantText: selectedVariant ? `${selectedVariant.name} / ${selectedVariant.routeCodeText}` : '当前无需手动指定赛道', runtimeVariantText: selectedVariant ? selectedVariant.name : '待 launch 确认', runtimeRouteCodeText: selectedVariant ? selectedVariant.routeCodeText : '待 launch 确认', selectableVariants, }) }, async handleLaunch() { const accessToken = getAccessToken() if (!accessToken) { wx.redirectTo({ url: '/pages/login/login' }) return } if (!this.data.canLaunch) { this.setData({ statusText: '当前发布状态不可进入地图', }) wx.showToast({ title: '当前发布状态不可进入地图', icon: 'none', }) return } if (!this.data.locationPermissionGranted) { this.setData({ statusText: '进入地图前请先完成定位授权', }) wx.showToast({ title: '请先授权定位', icon: 'none', }) return } this.setData({ statusText: '正在创建 session 并进入地图', }) try { const assignmentMode = this.data.assignmentMode ? this.data.assignmentMode : null const selectedVariantId = this.data.showVariantSelector && this.data.selectedVariantId ? this.data.selectedVariantId : null reportBackendClientLog({ level: 'info', category: 'event-prepare', message: 'launch requested', eventId: this.data.eventId || '', details: { pageEventId: this.data.eventId || '', selectedVariantId, assignmentMode, phase: 'launch-requested', }, }) const app = getApp() if (app.globalData) { const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName ? prepareHeartRateController.currentDeviceName : loadPreferredHeartRateDeviceName() app.globalData.pendingHeartRateAutoConnect = { enabled: !!pendingDeviceName, deviceName: pendingDeviceName || null, } } if (prepareHeartRateController) { prepareHeartRateController.destroy() prepareHeartRateController = null } const result = await launchEvent({ baseUrl: loadBackendBaseUrl(), eventId: this.data.eventId, accessToken, variantId: this.data.showVariantSelector ? this.data.selectedVariantId : undefined, clientType: 'wechat', deviceKey: 'mini-dev-device-001', }) reportBackendClientLog({ level: 'info', category: 'event-prepare', message: 'launch response received', eventId: result.launch.business && result.launch.business.eventId ? result.launch.business.eventId : this.data.eventId || '', releaseId: result.launch.config && result.launch.config.releaseId ? result.launch.config.releaseId : '', sessionId: result.launch.business && result.launch.business.sessionId ? result.launch.business.sessionId : '', manifestUrl: result.launch.resolvedRelease && result.launch.resolvedRelease.manifestUrl ? result.launch.resolvedRelease.manifestUrl : '', details: { pageEventId: this.data.eventId || '', launchEventId: result.launch.business && result.launch.business.eventId ? result.launch.business.eventId : '', launchSessionId: result.launch.business && result.launch.business.sessionId ? result.launch.business.sessionId : '', configUrl: result.launch.config && result.launch.config.configUrl ? result.launch.config.configUrl : '', releaseId: result.launch.config && result.launch.config.releaseId ? result.launch.config.releaseId : '', resolvedReleaseId: result.launch.resolvedRelease && result.launch.resolvedRelease.releaseId ? result.launch.resolvedRelease.releaseId : '', resolvedManifestUrl: result.launch.resolvedRelease && result.launch.resolvedRelease.manifestUrl ? result.launch.resolvedRelease.manifestUrl : '', launchVariantId: result.launch.variant && result.launch.variant.id ? result.launch.variant.id : null, phase: 'launch-response', }, }) const envelope = adaptBackendLaunchResultToEnvelope(result) wx.navigateTo({ url: prepareMapPageUrlForLaunch(envelope), }) } catch (error) { const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误' this.setData({ statusText: `launch 失败:${message}`, }) } }, })