Files
cmr-mini/miniprogram/pages/event-prepare/event-prepare.ts

1161 lines
41 KiB
TypeScript

import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getEventPlay, getPublicEventPlay, launchEvent, launchPublicEvent, 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 { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig'
import { buildPreparePreviewScene, buildPreparePreviewSceneFromBackendPreview, buildPreparePreviewSceneFromVariantControls, type PreparePreviewControl, type PreparePreviewScene, type PreparePreviewTile } from '../../utils/prepareMapPreview'
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
launchInFlight: boolean
launchProgressText: string
launchProgressPercent: number
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
previewVisible: boolean
previewLoading: boolean
previewStatusText: string
previewHintText: string
previewVariantText: string
previewTiles: Array<{
url: string
styleText: string
}>
previewControls: Array<{
label: string
styleText: string
kindClass: 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
showMockSourceSummary: boolean
}
type EventPreparePageContext = WechatMiniprogram.Page.Instance<EventPreparePageData, Record<string, never>> & {
previewLoadSeq?: number
lastPlayResult?: BackendEventPlayResult | null
previewManifestUrl?: string | null
previewConfigCache?: RemoteMapConfig | null
previewSceneCache?: Record<string, PreparePreviewScene>
launchAttemptSeq?: number
launchTimeoutTimer?: number
}
const PREVIEW_WIDTH = 640
const PREVIEW_HEIGHT = 360
const PREPARE_LAUNCH_TIMEOUT_MS = 12000
function toPercent(value: number, total: number): string {
if (!total) {
return '0%'
}
return `${(value / total) * 100}%`
}
function buildPreviewTileView(scene: PreparePreviewScene, tile: PreparePreviewTile) {
const left = toPercent(tile.leftPx, scene.width)
const top = toPercent(tile.topPx, scene.height)
const width = toPercent(tile.sizePx, scene.width)
const height = toPercent(tile.sizePx, scene.height)
return {
url: tile.url,
styleText: `left:${left};top:${top};width:${width};height:${height};`,
}
}
function buildPreviewControlView(scene: PreparePreviewScene, control: PreparePreviewControl) {
let kindClass = 'preview-control--normal'
if (control.kind === 'start') {
kindClass = 'preview-control--start'
} else if (control.kind === 'finish') {
kindClass = 'preview-control--finish'
}
return {
label: control.label,
kindClass,
styleText: `left:${toPercent(control.x, scene.width)};top:${toPercent(control.y, scene.height)};`,
}
}
function resolvePreviewManifestUrl(result: BackendEventPlayResult): string {
if (result.resolvedRelease && result.resolvedRelease.manifestUrl) {
return result.resolvedRelease.manifestUrl
}
if (result.release && result.release.manifestUrl) {
return result.release.manifestUrl
}
return ''
}
function canUseBackendPreview(result: BackendEventPlayResult): boolean {
return !!(
result.preview
&& result.preview.baseTiles
&& result.preview.baseTiles.tileBaseUrl
&& result.preview.viewport
&& typeof result.preview.viewport.minLon === 'number'
&& typeof result.preview.viewport.minLat === 'number'
&& typeof result.preview.viewport.maxLon === 'number'
&& typeof result.preview.viewport.maxLat === 'number'
)
}
function resolveSelectedPreviewVariant(result: BackendEventPlayResult, selectedVariantId: string) {
if (!result.preview || !result.preview.variants || !result.preview.variants.length) {
return null
}
const normalizedVariantId = selectedVariantId || (result.preview.selectedVariantId || '')
const exact = result.preview.variants.find((item) => {
const candidateId = item.variantId || item.id || ''
return candidateId === normalizedVariantId
})
if (exact) {
return exact
}
return result.preview.variants[0]
}
function resolvePreviewHintText(result: BackendEventPlayResult, scene: PreparePreviewScene): string {
if (detectMultiVariantContext(result)) {
return scene.overlayAvailable
? '当前先展示低级别底图与已知赛道形态;多赛道最终以进入地图后的绑定结果为准。'
: '当前活动支持多赛道;当前先展示底图与所选赛道信息,赛道点位预览待后端补齐每条赛道的预览数据后联动。'
}
return scene.overlayAvailable
? '当前先展示低级别底图与当前已知赛道,进入地图后按正式地图继续。'
: '当前先展示地图范围预览,进入地图后再查看正式赛道。'
}
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 clearPrepareLaunchTimeout(page: EventPreparePageContext) {
if (page.launchTimeoutTimer) {
clearTimeout(page.launchTimeoutTimer)
page.launchTimeoutTimer = 0
}
}
function resetPrepareLaunchVisualState(page: EventPreparePageContext) {
clearPrepareLaunchTimeout(page)
page.launchAttemptSeq = 0
page.setData({
launchInFlight: false,
launchProgressText: '待进入地图',
launchProgressPercent: 0,
})
}
function getAccessToken(): string | null {
const app = getApp<IAppOption>()
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,
launchInFlight: false,
launchProgressText: '待进入地图',
launchProgressPercent: 0,
titleText: '开始前准备',
summaryText: '未加载',
releaseText: '--',
actionText: '--',
statusText: '待加载',
assignmentMode: '',
variantModeText: '--',
variantSummaryText: '--',
presentationText: '--',
contentBundleText: '--',
runtimePlaceText: '进入地图后确认',
runtimeMapText: '进入地图后确认',
runtimeVariantText: '进入地图后确认',
runtimeRouteCodeText: '进入地图后确认',
previewVisible: false,
previewLoading: false,
previewStatusText: '准备加载地图预览',
previewHintText: '进入地图前先看地图范围与当前已知赛道。',
previewVariantText: '预览将跟随当前赛道选择联动',
previewTiles: [],
previewControls: [],
selectedVariantId: '',
selectedVariantText: '当前无需手动指定赛道',
showVariantSelector: false,
variantSelectorEmptyText: '当前无需手动指定赛道',
selectableVariants: [],
locationStatusText: '待进入地图后校验定位权限与实时精度',
heartRateStatusText: '局前心率带连接入口待接入,本轮先保留骨架',
heartRateDeviceText: '--',
heartRateScanText: '未扫描',
heartRateConnected: false,
showHeartRateDevicePicker: false,
locationPermissionGranted: false,
locationBackgroundPermissionGranted: false,
heartRateDiscoveredDevices: [],
mockSourceStatusText: '模拟源调试仍在地图页调试面板中使用',
showMockSourceSummary: false,
} 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() {
resetPrepareLaunchVisualState(this as unknown as EventPreparePageContext)
this.refreshPreparationDeviceState()
},
onUnload() {
resetPrepareLaunchVisualState(this as unknown as EventPreparePageContext)
if (prepareHeartRateController) {
prepareHeartRateController.destroy()
prepareHeartRateController = null
}
},
async loadEventPlay(eventId?: string) {
const targetEventId = eventId || this.data.eventId
const accessToken = getAccessToken()
this.setData({
loading: true,
statusText: '正在加载局前准备信息',
})
try {
const baseUrl = loadBackendBaseUrl()
const result = accessToken
? await getEventPlay({
baseUrl,
eventId: targetEventId,
accessToken,
})
: await getPublicEventPlay({
baseUrl,
eventId: targetEventId,
})
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) {
;(this as unknown as EventPreparePageContext).lastPlayResult = result
const page = this as unknown as EventPreparePageContext
const nextManifestUrl = resolvePreviewManifestUrl(result)
if (page.previewManifestUrl !== nextManifestUrl) {
page.previewManifestUrl = nextManifestUrl
page.previewConfigCache = null
page.previewSceneCache = {}
}
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: {
guestMode: !getAccessToken(),
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: '进入地图后确认',
runtimeMapText: '进入地图后确认',
runtimeVariantText: selectedVariant
? selectedVariant.name
: (result.play.courseVariants && result.play.courseVariants[0]
? result.play.courseVariants[0].name
: '进入地图后确认'),
runtimeRouteCodeText: selectedVariant
? selectedVariant.routeCodeText
: (result.play.courseVariants && result.play.courseVariants[0] && result.play.courseVariants[0].routeCode
? result.play.courseVariants[0].routeCode || '进入地图后确认'
: '进入地图后确认'),
previewVisible: true,
previewLoading: true,
previewStatusText: '正在生成地图预览',
previewHintText: '进入地图前先看地图范围与当前已知赛道。',
previewVariantText: selectedVariant
? `当前预览赛道:${selectedVariant.name} / ${selectedVariant.routeCodeText}`
: (multiVariantContext ? '当前预览赛道:待选择' : '当前预览赛道:默认赛道'),
previewTiles: [],
previewControls: [],
selectedVariantId,
selectedVariantText: selectedVariant
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
: variantSelectorEmptyText,
showVariantSelector,
variantSelectorEmptyText,
selectableVariants,
})
this.loadPrepareMapPreview(result)
},
async loadPrepareMapPreview(result: BackendEventPlayResult) {
const page = this as unknown as EventPreparePageContext
const seq = (page.previewLoadSeq || 0) + 1
page.previewLoadSeq = seq
const selectedVariantId = this.data.selectedVariantId || (result.preview && result.preview.selectedVariantId ? result.preview.selectedVariantId : '')
const manifestUrl = resolvePreviewManifestUrl(result)
let fallbackConfig: RemoteMapConfig | null = page.previewConfigCache || null
const multiVariantContext = detectMultiVariantContext(result)
if (multiVariantContext && canUseBackendPreview(result) && result.preview) {
const sceneCacheKey = selectedVariantId || '__default__'
const cachedScene = page.previewSceneCache && page.previewSceneCache[sceneCacheKey]
if (cachedScene) {
const previewTiles = cachedScene.tiles.map((item) => buildPreviewTileView(cachedScene, item))
const previewControls = cachedScene.controls.map((item) => buildPreviewControlView(cachedScene, item))
this.setData({
previewVisible: true,
previewLoading: false,
previewStatusText: cachedScene.overlayAvailable ? '已加载地图与赛道预览' : '已加载地图范围预览',
previewHintText: cachedScene.overlayAvailable
? '当前预览已按所选赛道联动显示点位,最终绑定以后端 launch 返回结果为准。'
: '当前预览已切换到所选赛道的底图范围;该赛道暂未返回点位预览数据。',
previewVariantText: selectedVariantId
? `当前预览赛道:${this.data.selectedVariantText}`
: '当前预览赛道:默认赛道',
previewTiles,
previewControls,
runtimePlaceText: result.event.displayName || this.data.runtimePlaceText,
})
return
}
if (manifestUrl) {
if (!fallbackConfig) {
try {
fallbackConfig = await loadRemoteMapConfig(manifestUrl)
page.previewConfigCache = fallbackConfig
} catch (_error) {
fallbackConfig = null
}
}
}
const selectedPreviewVariant = resolveSelectedPreviewVariant(result, selectedVariantId)
const scene = fallbackConfig && selectedPreviewVariant && selectedPreviewVariant.controls
? buildPreparePreviewSceneFromVariantControls(
fallbackConfig,
PREVIEW_WIDTH,
PREVIEW_HEIGHT,
selectedPreviewVariant.controls,
)
: buildPreparePreviewSceneFromBackendPreview(
result.preview,
PREVIEW_WIDTH,
PREVIEW_HEIGHT,
selectedVariantId,
fallbackConfig ? fallbackConfig.tileSource : null,
)
if (page.previewLoadSeq !== seq) {
return
}
if (scene) {
if (!page.previewSceneCache) {
page.previewSceneCache = {}
}
page.previewSceneCache[sceneCacheKey] = scene
const previewTiles = scene.tiles.map((item) => buildPreviewTileView(scene, item))
const previewControls = scene.controls.map((item) => buildPreviewControlView(scene, item))
this.setData({
previewVisible: true,
previewLoading: false,
previewStatusText: scene.overlayAvailable ? '已加载地图与赛道预览' : '已加载地图范围预览',
previewHintText: scene.overlayAvailable
? '当前预览已按所选赛道联动显示点位,最终绑定以后端 launch 返回结果为准。'
: '当前预览已切换到所选赛道的底图范围;该赛道暂未返回点位预览数据。',
previewVariantText: selectedVariantId
? `当前预览赛道:${this.data.selectedVariantText}`
: '当前预览赛道:默认赛道',
previewTiles,
previewControls,
runtimePlaceText: result.event.displayName || this.data.runtimePlaceText,
})
return
}
}
if (!manifestUrl) {
this.setData({
previewVisible: true,
previewLoading: false,
previewStatusText: '当前发布未返回预览底图来源',
previewHintText: '当前活动暂无可用地图预览,请稍后刷新或联系后台。',
previewVariantText: '当前预览赛道:待进入地图后确认',
previewTiles: [],
previewControls: [],
})
return
}
try {
const config = fallbackConfig || await loadRemoteMapConfig(manifestUrl)
page.previewConfigCache = config
if (page.previewLoadSeq !== seq) {
return
}
const overlayEnabled = !multiVariantContext
const scene = buildPreparePreviewScene(config, PREVIEW_WIDTH, PREVIEW_HEIGHT, overlayEnabled)
const previewTiles = scene.tiles.map((item) => buildPreviewTileView(scene, item))
const previewControls = scene.controls.map((item) => buildPreviewControlView(scene, item))
const runtimeMapText = config.configTitle || '进入地图后确认'
const runtimePlaceText = result.event.displayName || '进入地图后确认'
this.setData({
previewVisible: true,
previewLoading: false,
previewStatusText: scene.overlayAvailable ? '已加载地图与赛道预览' : '已加载地图范围预览',
previewHintText: resolvePreviewHintText(result, scene),
previewVariantText: this.data.selectedVariantId
? `当前预览赛道:${this.data.selectedVariantText}`
: (result.play.courseVariants && result.play.courseVariants[0]
? `当前预览赛道:${result.play.courseVariants[0].name} / ${result.play.courseVariants[0].routeCode || '默认编码'}`
: '当前预览赛道:默认赛道'),
previewTiles,
previewControls,
runtimePlaceText,
runtimeMapText,
})
} catch (error) {
if (page.previewLoadSeq !== seq) {
return
}
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
this.setData({
previewVisible: true,
previewLoading: false,
previewStatusText: `地图预览加载失败:${message}`,
previewHintText: '当前先展示文字摘要;预览底图可在刷新后重试。',
previewVariantText: '当前预览赛道:待进入地图后确认',
previewTiles: [],
previewControls: [],
})
}
},
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<string, boolean | undefined>
: {}
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()
const showMockSourceSummary = autoConnect || channelId !== 'default'
this.setData({
mockSourceStatusText: autoConnect
? `调试源自动连接已开启 / 通道 ${channelId}`
: `当前使用调试通道 ${channelId}`,
showMockSourceSummary,
})
},
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 : '进入地图后确认',
runtimeRouteCodeText: selectedVariant ? selectedVariant.routeCodeText : '进入地图后确认',
previewHintText: selectedVariant
? (this.data.showVariantSelector
? `当前已选 ${selectedVariant.name} / ${selectedVariant.routeCodeText};预览底图会保留不变,最终赛道以 launch 绑定结果为准。`
: `当前已选 ${selectedVariant.name} / ${selectedVariant.routeCodeText};最终地图以 launch 绑定结果为准。`)
: this.data.previewHintText,
previewStatusText: this.data.showVariantSelector ? '已加载地图范围预览' : this.data.previewStatusText,
previewVariantText: selectedVariant
? `当前预览赛道:${selectedVariant.name} / ${selectedVariant.routeCodeText}`
: '当前预览赛道:待选择',
selectableVariants,
})
const page = this as unknown as EventPreparePageContext
if (page.lastPlayResult) {
this.loadPrepareMapPreview(page.lastPlayResult)
}
},
async handleLaunch() {
const page = this as unknown as EventPreparePageContext
const accessToken = getAccessToken()
if (this.data.launchInFlight) {
wx.showToast({
title: '正在进入地图,请稍候',
icon: 'none',
})
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({
launchInFlight: true,
launchProgressText: '正在校验并创建本局',
launchProgressPercent: 24,
statusText: '正在创建 session 并进入地图',
})
const launchSeq = (page.launchAttemptSeq || 0) + 1
page.launchAttemptSeq = launchSeq
clearPrepareLaunchTimeout(page)
page.launchTimeoutTimer = setTimeout(() => {
if (page.launchAttemptSeq !== launchSeq) {
return
}
this.setData({
launchInFlight: false,
launchProgressText: '进入地图超时',
launchProgressPercent: 0,
statusText: '进入地图超时,请稍后重试',
})
wx.showToast({
title: '进入地图超时,请重试',
icon: 'none',
})
}, PREPARE_LAUNCH_TIMEOUT_MS) as unknown as number
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',
},
})
this.setData({
launchProgressText: '已发起启动请求,正在等待服务器响应',
launchProgressPercent: 52,
})
const app = getApp<IAppOption>()
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 = accessToken
? await launchEvent({
baseUrl: loadBackendBaseUrl(),
eventId: this.data.eventId,
accessToken,
variantId: this.data.showVariantSelector ? this.data.selectedVariantId : undefined,
clientType: 'wechat',
deviceKey: 'mini-dev-device-001',
})
: await launchPublicEvent({
baseUrl: loadBackendBaseUrl(),
eventId: this.data.eventId,
variantId: this.data.showVariantSelector ? this.data.selectedVariantId : undefined,
clientType: 'wechat',
deviceKey: 'mini-dev-device-001',
})
if (page.launchAttemptSeq !== launchSeq) {
return
}
clearPrepareLaunchTimeout(page)
this.setData({
launchProgressText: '启动成功,正在载入地图',
launchProgressPercent: 86,
})
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: {
guestMode: !accessToken,
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) {
if (page.launchAttemptSeq !== launchSeq) {
return
}
clearPrepareLaunchTimeout(page)
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
this.setData({
launchInFlight: false,
launchProgressText: '进入地图失败',
launchProgressPercent: 0,
statusText: `launch 失败:${message}`,
})
}
},
})