1161 lines
41 KiB
TypeScript
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}`,
|
|
})
|
|
}
|
|
},
|
|
})
|