完善多赛道联调与全局产品架构
This commit is contained in:
575
miniprogram/pages/event-prepare/event-prepare.ts
Normal file
575
miniprogram/pages/event-prepare/event-prepare.ts
Normal file
@@ -0,0 +1,575 @@
|
||||
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
|
||||
import { getEventPlay, launchEvent, type BackendCourseVariantSummary, type BackendEventPlayResult } from '../../utils/backendApi'
|
||||
import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
|
||||
import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
|
||||
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
|
||||
titleText: string
|
||||
summaryText: string
|
||||
releaseText: string
|
||||
actionText: string
|
||||
statusText: string
|
||||
assignmentMode: string
|
||||
variantModeText: string
|
||||
variantSummaryText: string
|
||||
selectedVariantId: string
|
||||
selectedVariantText: 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 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(' / ')
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
return preview
|
||||
}
|
||||
|
||||
function resolveSelectedVariantId(
|
||||
currentVariantId: string,
|
||||
assignmentMode?: string | null,
|
||||
variants?: BackendCourseVariantSummary[] | null,
|
||||
): string {
|
||||
if (assignmentMode !== 'manual' || !variants || !variants.length) {
|
||||
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,
|
||||
) {
|
||||
if (assignmentMode !== 'manual' || !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,
|
||||
}))
|
||||
}
|
||||
|
||||
let prepareHeartRateController: HeartRateController | null = null
|
||||
|
||||
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,
|
||||
titleText: '开始前准备',
|
||||
summaryText: '未加载',
|
||||
releaseText: '--',
|
||||
actionText: '--',
|
||||
statusText: '待加载',
|
||||
assignmentMode: '',
|
||||
variantModeText: '--',
|
||||
variantSummaryText: '--',
|
||||
selectedVariantId: '',
|
||||
selectedVariantText: '当前无需手动指定赛道',
|
||||
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 selectedVariantId = resolveSelectedVariantId(
|
||||
this.data.selectedVariantId,
|
||||
result.play.assignmentMode,
|
||||
result.play.courseVariants,
|
||||
)
|
||||
const selectableVariants = buildSelectableVariants(
|
||||
selectedVariantId,
|
||||
result.play.assignmentMode,
|
||||
result.play.courseVariants,
|
||||
)
|
||||
const selectedVariant = selectableVariants.find((item) => item.id === selectedVariantId) || null
|
||||
this.setData({
|
||||
loading: false,
|
||||
titleText: `${result.event.displayName} / 开始前准备`,
|
||||
summaryText: result.event.summary || '暂无活动简介',
|
||||
releaseText: result.resolvedRelease
|
||||
? `${result.resolvedRelease.configLabel} / ${result.resolvedRelease.releaseId}`
|
||||
: '当前无可用 release',
|
||||
actionText: `${result.play.primaryAction} / ${result.play.reason}`,
|
||||
statusText: result.play.canLaunch ? '准备完成,可进入地图' : '当前不可启动',
|
||||
assignmentMode: result.play.assignmentMode || '',
|
||||
variantModeText: formatAssignmentMode(result.play.assignmentMode),
|
||||
variantSummaryText: formatVariantSummary(result),
|
||||
selectedVariantId,
|
||||
selectedVariantText: selectedVariant
|
||||
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
|
||||
: '当前无需手动指定赛道',
|
||||
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<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()
|
||||
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}`
|
||||
: '当前无需手动指定赛道',
|
||||
selectableVariants,
|
||||
})
|
||||
},
|
||||
|
||||
async handleLaunch() {
|
||||
const accessToken = getAccessToken()
|
||||
if (!accessToken) {
|
||||
wx.redirectTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.data.locationPermissionGranted) {
|
||||
this.setData({
|
||||
statusText: '进入地图前请先完成定位授权',
|
||||
})
|
||||
wx.showToast({
|
||||
title: '请先授权定位',
|
||||
icon: 'none',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({
|
||||
statusText: '正在创建 session 并进入地图',
|
||||
})
|
||||
|
||||
try {
|
||||
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 = await launchEvent({
|
||||
baseUrl: loadBackendBaseUrl(),
|
||||
eventId: this.data.eventId,
|
||||
accessToken,
|
||||
variantId: this.data.assignmentMode === 'manual' ? this.data.selectedVariantId : undefined,
|
||||
clientType: 'wechat',
|
||||
deviceKey: 'mini-dev-device-001',
|
||||
})
|
||||
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}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user