完善多赛道联调与全局产品架构

This commit is contained in:
2026-04-02 18:11:43 +08:00
parent 6964e26ec9
commit 0e28f70bad
45 changed files with 4819 additions and 282 deletions

View File

@@ -4,7 +4,9 @@
"pages/login/login",
"pages/home/home",
"pages/event/event",
"pages/event-prepare/event-prepare",
"pages/result/result",
"pages/results/results",
"pages/map/map",
"pages/experience-webview/experience-webview",
"pages/webview-test/webview-test",

View File

@@ -6,6 +6,8 @@ App<IAppOption>({
telemetryPlayerProfile: null,
backendBaseUrl: null,
backendAuthTokens: null,
pendingResultSnapshot: null,
pendingHeartRateAutoConnect: null,
},
onLaunch() {
this.globalData.backendBaseUrl = loadBackendBaseUrl()

View 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}`,
})
}
},
})

View File

@@ -0,0 +1,105 @@
<scroll-view class="page" scroll-y>
<view class="shell">
<view class="hero">
<view class="hero__eyebrow">Prepare</view>
<view class="hero__title">{{titleText}}</view>
<view class="hero__desc">{{summaryText}}</view>
</view>
<view class="panel">
<view class="panel__title">活动与发布</view>
<view class="summary">Release{{releaseText}}</view>
<view class="summary">主动作:{{actionText}}</view>
<view class="summary">状态:{{statusText}}</view>
<view class="summary">赛道模式:{{variantModeText}}</view>
<view class="summary">赛道摘要:{{variantSummaryText}}</view>
<view class="summary">当前选择:{{selectedVariantText}}</view>
</view>
<view class="panel" wx:if="{{assignmentMode === 'manual' && selectableVariants.length}}">
<view class="panel__title">赛道选择</view>
<view class="summary">当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。</view>
<view class="variant-list">
<view wx:for="{{selectableVariants}}" wx:key="id" class="variant-card {{item.selected ? 'variant-card--active' : ''}}" data-variant-id="{{item.id}}" bindtap="handleSelectVariant">
<view class="variant-card__main">
<view class="variant-card__title-row">
<text class="variant-card__name">{{item.name}}</text>
<text class="variant-card__badge" wx:if="{{item.selected}}">已选中</text>
</view>
<text class="variant-card__meta">{{item.routeCodeText}}</text>
<text class="variant-card__meta">{{item.descriptionText}}</text>
</view>
</view>
</view>
</view>
<view class="panel">
<view class="panel__title">设备准备</view>
<view class="summary">这一页现在负责局前设备准备。定位权限先在这里确认,心率带支持先连后进图,地图内仍保留局中快速重连入口。</view>
<view class="row">
<view class="row__label">定位状态</view>
<view class="row__value">{{locationStatusText}}</view>
</view>
<view class="summary" wx:if="{{locationPermissionGranted && !locationBackgroundPermissionGranted}}">已完成前台定位授权;如果后续需要后台持续定位,请在系统设置中补齐后台权限。</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleRequestLocationPermission">申请定位权限</button>
<button class="btn btn--ghost" bindtap="handleOpenLocationSettings">打开系统设置</button>
</view>
<view class="row">
<view class="row__label">心率带</view>
<view class="row__value">{{heartRateStatusText}}</view>
</view>
<view class="row">
<view class="row__label">当前设备</view>
<view class="row__value">{{heartRateDeviceText}}</view>
</view>
<view class="row">
<view class="row__label">扫描状态</view>
<view class="row__value">{{heartRateScanText}}</view>
</view>
<view class="row">
<view class="row__label">模拟源</view>
<view class="row__value">{{mockSourceStatusText}}</view>
</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleOpenHeartRateDevicePicker">选择设备</button>
<button class="btn btn--ghost" bindtap="handlePrepareHeartRateConnect">重新扫描</button>
<button class="btn btn--ghost" bindtap="handlePrepareHeartRateDisconnect">断开连接</button>
<button class="btn btn--ghost" bindtap="handlePrepareHeartRateClearPreferred">清除首选</button>
</view>
</view>
<view class="panel">
<view class="panel__title">开始比赛</view>
<view class="summary">这一页先承担局前准备壳子,后面会继续接定位权限、心率带局前连接和设备检查。</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleBack">返回活动页</button>
<button class="btn btn--ghost" bindtap="handleRefresh">刷新</button>
<button class="btn btn--primary" bindtap="handleLaunch">进入地图</button>
</view>
</view>
</view>
<view wx:if="{{showHeartRateDevicePicker}}" class="picker-mask" bindtap="handleCloseHeartRateDevicePicker"></view>
<view wx:if="{{showHeartRateDevicePicker}}" class="picker-sheet">
<view class="picker-sheet__header">
<view class="picker-sheet__title">选择心率带设备</view>
<button class="picker-sheet__close" bindtap="handleCloseHeartRateDevicePicker">关闭</button>
</view>
<view class="summary">扫描状态:{{heartRateScanText}}</view>
<view wx:if="{{!heartRateDiscoveredDevices.length}}" class="summary">当前还没有发现设备,可先点“重新扫描”。</view>
<view wx:if="{{heartRateDiscoveredDevices.length}}" class="device-list">
<view wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId" class="device-card">
<view class="device-card__main">
<view class="device-card__title-row">
<text class="device-card__name">{{item.name}}</text>
<text class="device-card__badge" wx:if="{{item.preferred}}">首选</text>
<text class="device-card__badge device-card__badge--active" wx:if="{{item.connected}}">已连接</text>
</view>
<text class="device-card__meta">{{item.rssiText}}</text>
</view>
<button class="btn {{item.connected ? 'btn--ghost' : 'btn--secondary'}} device-card__action" data-device-id="{{item.deviceId}}" bindtap="handlePrepareHeartRateDeviceConnect">{{item.connected ? '已连接' : '连接'}}</button>
</view>
</view>
</view>
</scroll-view>

View File

@@ -0,0 +1,276 @@
page {
min-height: 100vh;
background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%);
}
.page {
min-height: 100vh;
}
.shell {
display: grid;
gap: 24rpx;
padding: 28rpx 24rpx 40rpx;
}
.hero,
.panel {
display: grid;
gap: 16rpx;
padding: 24rpx;
border-radius: 24rpx;
}
.hero {
background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%);
color: #ffffff;
}
.hero__eyebrow {
font-size: 22rpx;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
}
.hero__title {
font-size: 40rpx;
font-weight: 700;
}
.hero__desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.84);
line-height: 1.6;
}
.panel {
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08);
}
.panel__title {
font-size: 30rpx;
font-weight: 700;
color: #17345a;
}
.summary,
.row__label,
.row__value {
font-size: 24rpx;
line-height: 1.6;
color: #30465f;
}
.row {
display: flex;
justify-content: space-between;
gap: 16rpx;
padding: 10rpx 0;
border-bottom: 2rpx solid #edf2f7;
}
.row:last-child {
border-bottom: 0;
}
.row__value {
max-width: 70%;
text-align: right;
font-weight: 700;
color: #17345a;
}
.actions {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
}
.device-list {
display: grid;
gap: 14rpx;
}
.variant-list {
display: grid;
gap: 14rpx;
}
.variant-card {
display: grid;
gap: 8rpx;
padding: 18rpx;
border-radius: 18rpx;
background: #f6f9fc;
border: 2rpx solid transparent;
}
.variant-card--active {
background: #edf5ff;
border-color: #4f86c9;
}
.variant-card__main {
display: grid;
gap: 8rpx;
}
.variant-card__title-row {
display: flex;
gap: 10rpx;
align-items: center;
flex-wrap: wrap;
}
.variant-card__name {
font-size: 26rpx;
font-weight: 700;
color: #17345a;
}
.variant-card__badge {
padding: 4rpx 10rpx;
border-radius: 999rpx;
background: #dff3e8;
color: #1f6a45;
font-size: 20rpx;
}
.variant-card__meta {
font-size: 22rpx;
color: #5c7288;
line-height: 1.5;
}
.picker-mask {
position: fixed;
inset: 0;
background: rgba(10, 22, 38, 0.42);
z-index: 30;
}
.picker-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 31;
display: grid;
gap: 16rpx;
padding: 24rpx 24rpx 36rpx;
border-top-left-radius: 28rpx;
border-top-right-radius: 28rpx;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 -14rpx 36rpx rgba(22, 43, 71, 0.18);
}
.picker-sheet__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.picker-sheet__title {
font-size: 30rpx;
font-weight: 700;
color: #17345a;
}
.picker-sheet__close {
margin: 0;
min-height: 60rpx;
padding: 0 18rpx;
line-height: 60rpx;
border-radius: 999rpx;
font-size: 22rpx;
background: #eef3f8;
color: #455a72;
}
.picker-sheet__close::after {
border: 0;
}
.device-card {
display: flex;
justify-content: space-between;
gap: 16rpx;
align-items: center;
padding: 18rpx;
border-radius: 18rpx;
background: #f6f9fc;
}
.device-card__main {
display: grid;
gap: 8rpx;
min-width: 0;
flex: 1;
}
.device-card__title-row {
display: flex;
gap: 10rpx;
align-items: center;
flex-wrap: wrap;
}
.device-card__name {
font-size: 26rpx;
font-weight: 700;
color: #17345a;
}
.device-card__badge {
padding: 4rpx 10rpx;
border-radius: 999rpx;
background: #e1ecfa;
color: #35567d;
font-size: 20rpx;
}
.device-card__badge--active {
background: #dff3e8;
color: #1f6a45;
}
.device-card__meta {
font-size: 22rpx;
color: #5c7288;
}
.device-card__action {
flex: none;
}
.btn {
margin: 0;
min-height: 76rpx;
padding: 0 24rpx;
line-height: 76rpx;
border-radius: 18rpx;
font-size: 26rpx;
}
.btn::after {
border: 0;
}
.btn--primary {
background: #173d73;
color: #ffffff;
}
.btn--secondary {
background: #dfeaf8;
color: #173d73;
}
.btn--ghost {
background: #ffffff;
color: #52657d;
border: 2rpx solid #d8e2ec;
}

View File

@@ -1,7 +1,5 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getEventPlay, launchEvent, type BackendEventPlayResult } from '../../utils/backendApi'
import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
import { getEventPlay, type BackendEventPlayResult } from '../../utils/backendApi'
type EventPageData = {
eventId: string
@@ -11,6 +9,33 @@ type EventPageData = {
releaseText: string
actionText: string
statusText: string
variantModeText: string
variantSummaryText: 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 selectable = variants.filter((item) => item.selectable !== false)
const preview = variants.slice(0, 3).map((item) => item.routeCode || item.name).join(' / ')
const suffix = variants.length > 3 ? ' / ...' : ''
return `${variants.length} 条赛道,可选 ${selectable.length} 条:${preview}${suffix}`
}
function getAccessToken(): string | null {
@@ -30,6 +55,8 @@ Page({
releaseText: '--',
actionText: '--',
statusText: '待加载',
variantModeText: '--',
variantSummaryText: '--',
} as EventPageData,
onLoad(query: { eventId?: string }) {
@@ -83,6 +110,8 @@ Page({
: '当前无可用 release',
actionText: `${result.play.primaryAction} / ${result.play.reason}`,
statusText: result.play.canLaunch ? '可启动' : '当前不可启动',
variantModeText: formatAssignmentMode(result.play.assignmentMode),
variantSummaryText: formatVariantSummary(result),
})
},
@@ -91,33 +120,8 @@ Page({
},
async handleLaunch() {
const accessToken = getAccessToken()
if (!accessToken) {
wx.redirectTo({ url: '/pages/login/login' })
return
}
this.setData({
statusText: '正在创建 session 并进入地图',
wx.navigateTo({
url: `/pages/event-prepare/event-prepare?eventId=${encodeURIComponent(this.data.eventId)}`,
})
try {
const result = await launchEvent({
baseUrl: loadBackendBaseUrl(),
eventId: this.data.eventId,
accessToken,
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}`,
})
}
},
})

View File

@@ -11,9 +11,11 @@
<view class="summary">Release{{releaseText}}</view>
<view class="summary">主动作:{{actionText}}</view>
<view class="summary">状态:{{statusText}}</view>
<view class="summary">赛道模式:{{variantModeText}}</view>
<view class="summary">赛道摘要:{{variantSummaryText}}</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
<button class="btn btn--primary" bindtap="handleLaunch">开始比赛</button>
<button class="btn btn--primary" bindtap="handleLaunch">前往准备页</button>
</view>
</view>
</view>

View File

@@ -15,6 +15,17 @@ type HomePageData = {
cards: BackendCardResult[]
}
function formatSessionSummary(session?: BackendEntryHomeResult['ongoingSession'] | null): string {
if (!session) {
return '无'
}
const title = session.eventName || session.eventDisplayName || session.eventId || session.id || session.sessionId
const status = session.status || session.sessionStatus || '--'
const route = session.routeCode || session.variantName || '默认赛道'
return `${title} / ${status} / ${route}`
}
function requireAuthToken(): string | null {
const app = getApp<IAppOption>()
const tokens = app.globalData && app.globalData.backendAuthTokens
@@ -79,12 +90,8 @@ Page({
userNameText: result.user.nickname || result.user.publicId || result.user.id,
tenantText: `${result.tenant.name} (${result.tenant.code})`,
channelText: `${result.channel.displayName} / ${result.channel.code}`,
ongoingSessionText: result.ongoingSession
? `${result.ongoingSession.eventName || result.ongoingSession.eventDisplayName || result.ongoingSession.eventId || result.ongoingSession.id || result.ongoingSession.sessionId} / ${result.ongoingSession.status || result.ongoingSession.sessionStatus}`
: '无',
recentSessionText: result.recentSession
? `${result.recentSession.eventName || result.recentSession.eventDisplayName || result.recentSession.eventId || result.recentSession.id || result.recentSession.sessionId} / ${result.recentSession.status || result.recentSession.sessionStatus}`
: '无',
ongoingSessionText: formatSessionSummary(result.ongoingSession),
recentSessionText: formatSessionSummary(result.recentSession),
cards: result.cards || [],
})
},
@@ -110,7 +117,7 @@ Page({
handleOpenRecentResult() {
wx.navigateTo({
url: '/pages/result/result',
url: '/pages/results/results',
})
},

View File

@@ -71,6 +71,7 @@ type MapPageData = MapEngineViewState & {
showGameInfoPanel: boolean
showResultScene: boolean
showSystemSettingsPanel: boolean
showHeartRateDevicePicker: boolean
showCenterScaleRuler: boolean
showPunchHintBanner: boolean
punchHintFxClass: string
@@ -92,6 +93,7 @@ type MapPageData = MapEngineViewState & {
resultSceneHeroLabel: string
resultSceneHeroValue: string
resultSceneRows: MapEngineGameInfoRow[]
resultSceneCountdownText: string
panelTimerText: string
panelTimerMode: 'elapsed' | 'countdown'
panelMileageText: string
@@ -157,6 +159,7 @@ const PUNCH_HINT_AUTO_HIDE_MS = 30000
const PUNCH_HINT_FX_DURATION_MS = 420
const PUNCH_HINT_HAPTIC_GAP_MS = 2400
const SESSION_RECOVERY_PERSIST_INTERVAL_MS = 5000
const RESULT_EXIT_REDIRECT_DELAY_MS = 3000
let currentGameLaunchEnvelope: GameLaunchEnvelope = getDemoGameLaunchEnvelope()
let mapEngine: MapEngine | null = null
let stageCanvasAttached = false
@@ -172,6 +175,8 @@ let panelMileageFxTimer = 0
let panelSpeedFxTimer = 0
let panelHeartRateFxTimer = 0
let sessionRecoveryPersistTimer = 0
let resultExitRedirectTimer = 0
let resultExitCountdownTimer = 0
let lastPunchHintHapticAt = 0
let currentSystemSettingsConfig: SystemSettingsConfig | undefined
let currentRemoteMapConfig: RemoteMapConfig | undefined
@@ -179,6 +184,8 @@ let systemSettingsLockLifetimeActive = false
let syncedBackendSessionStartId = ''
let syncedBackendSessionFinishId = ''
let shouldAutoRestoreRecoverySnapshot = false
let redirectedToResultPage = false
let pendingHeartRateSwitchDeviceName: string | null = null
const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1'
let lastCenterScaleRulerStablePatch: Pick<
@@ -469,6 +476,34 @@ function clearSessionRecoveryPersistTimer() {
}
}
function clearResultExitRedirectTimer() {
if (resultExitRedirectTimer) {
clearTimeout(resultExitRedirectTimer)
resultExitRedirectTimer = 0
}
}
function clearResultExitCountdownTimer() {
if (resultExitCountdownTimer) {
clearInterval(resultExitCountdownTimer)
resultExitCountdownTimer = 0
}
}
function navigateAwayFromMapAfterCancel() {
const pages = getCurrentPages()
if (pages.length > 1) {
wx.navigateBack({
delta: 1,
})
return
}
wx.redirectTo({
url: '/pages/home/home',
})
}
function hasExplicitLaunchOptions(options?: MapPageLaunchOptions | null): boolean {
if (!options) {
return false
@@ -776,11 +811,12 @@ function buildEmptyResultSceneSnapshot(): MapEngineResultSnapshot {
Page({
data: {
showDebugPanel: false,
showGameInfoPanel: false,
showResultScene: false,
showSystemSettingsPanel: false,
showCenterScaleRuler: false,
showDebugPanel: false,
showGameInfoPanel: false,
showResultScene: false,
showSystemSettingsPanel: false,
showHeartRateDevicePicker: false,
showCenterScaleRuler: false,
statusBarHeight: 0,
topInsetHeight: 12,
hudPanelIndex: 0,
@@ -798,6 +834,7 @@ Page({
resultSceneHeroLabel: '本局用时',
resultSceneHeroValue: '--',
resultSceneRows: buildEmptyResultSceneSnapshot().rows,
resultSceneCountdownText: '',
panelTimerText: '00:00:00',
panelTimerMode: 'elapsed',
panelMileageText: '0m',
@@ -927,8 +964,11 @@ Page({
onLoad(options: MapPageLaunchOptions) {
clearSessionRecoveryPersistTimer()
clearResultExitRedirectTimer()
clearResultExitCountdownTimer()
syncedBackendSessionStartId = ''
syncedBackendSessionFinishId = ''
redirectedToResultPage = false
shouldAutoRestoreRecoverySnapshot = options && options.recoverSession === '1'
currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options)
if (!hasExplicitLaunchOptions(options)) {
@@ -959,6 +999,7 @@ Page({
const includeRulerFields = this.data.showCenterScaleRuler
let shouldSyncRuntimeSystemSettings = false
let nextLockLifetimeActive = isSystemSettingsLockLifetimeActive()
let heartRateSwitchToastText = ''
const nextData: Partial<MapPageData> = filterDebugOnlyPatch({
...nextPatch,
}, includeDebugFields, includeRulerFields)
@@ -1054,6 +1095,8 @@ Page({
: this.data.animationLevel
let shouldSyncBackendSessionStart = false
let backendSessionFinishStatus: 'finished' | 'failed' | null = null
let shouldOpenResultExitPrompt = false
let resultPageSnapshot: MapEngineResultSnapshot | null = null
if (nextAnimationLevel === 'lite') {
clearHudFxTimer('timer')
@@ -1112,13 +1155,24 @@ Page({
shouldSyncRuntimeSystemSettings = true
clearSessionRecoverySnapshot()
clearSessionRecoveryPersistTimer()
this.syncResultSceneSnapshot()
clearResultExitRedirectTimer()
clearResultExitCountdownTimer()
resultPageSnapshot = mapEngine ? mapEngine.getResultSceneSnapshot() : null
nextData.showResultScene = true
nextData.showDebugPanel = false
nextData.showGameInfoPanel = false
nextData.showSystemSettingsPanel = false
clearGameInfoPanelSyncTimer()
backendSessionFinishStatus = nextPatch.gameSessionStatus === 'finished' ? 'finished' : 'failed'
shouldOpenResultExitPrompt = true
if (resultPageSnapshot) {
nextData.resultSceneTitle = resultPageSnapshot.title
nextData.resultSceneSubtitle = resultPageSnapshot.subtitle
nextData.resultSceneHeroLabel = resultPageSnapshot.heroLabel
nextData.resultSceneHeroValue = resultPageSnapshot.heroValue
nextData.resultSceneRows = resultPageSnapshot.rows
}
nextData.resultSceneCountdownText = '3 秒后自动进入成绩页'
} else if (
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
&& nextPatch.gameSessionStatus === 'idle'
@@ -1128,6 +1182,8 @@ Page({
shouldSyncRuntimeSystemSettings = true
clearSessionRecoverySnapshot()
clearSessionRecoveryPersistTimer()
clearResultExitRedirectTimer()
clearResultExitCountdownTimer()
} else if (
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
&& nextPatch.gameSessionStatus === 'running'
@@ -1138,6 +1194,19 @@ Page({
}
}
if (
pendingHeartRateSwitchDeviceName
&& nextPatch.heartRateConnected === true
&& typeof nextPatch.heartRateDeviceText === 'string'
) {
const connectedDeviceName = nextPatch.heartRateDeviceText.trim()
if (connectedDeviceName && connectedDeviceName === pendingHeartRateSwitchDeviceName) {
heartRateSwitchToastText = `已切换到 ${connectedDeviceName}`
nextData.statusText = `已切换心率带:${connectedDeviceName}`
pendingHeartRateSwitchDeviceName = null
}
}
if (Object.keys(nextData).length || Object.keys(derivedPatch).length) {
this.setData({
...nextData,
@@ -1152,9 +1221,20 @@ Page({
if (backendSessionFinishStatus) {
this.syncBackendSessionFinish(backendSessionFinishStatus)
}
if (shouldSyncRuntimeSystemSettings) {
this.applyRuntimeSystemSettings(nextLockLifetimeActive)
}
if (shouldOpenResultExitPrompt && resultPageSnapshot) {
this.stashPendingResultSnapshot(resultPageSnapshot)
this.presentResultExitPrompt()
}
if (heartRateSwitchToastText) {
wx.showToast({
title: `${heartRateSwitchToastText},并设为首选设备`,
icon: 'none',
duration: 1800,
})
}
if (shouldSyncRuntimeSystemSettings) {
this.applyRuntimeSystemSettings(nextLockLifetimeActive)
}
if (this.data.showGameInfoPanel) {
this.scheduleGameInfoPanelSnapshotSync()
}
@@ -1169,6 +1249,10 @@ Page({
if (backendSessionFinishStatus) {
this.syncBackendSessionFinish(backendSessionFinishStatus)
}
if (shouldOpenResultExitPrompt && resultPageSnapshot) {
this.stashPendingResultSnapshot(resultPageSnapshot)
this.presentResultExitPrompt()
}
if (shouldSyncRuntimeSystemSettings) {
this.applyRuntimeSystemSettings(nextLockLifetimeActive)
}
@@ -1209,6 +1293,7 @@ Page({
...buildResolvedSystemSettingsPatch(systemSettingsState),
showDebugPanel: false,
showGameInfoPanel: false,
showResultScene: false,
showSystemSettingsPanel: false,
statusBarHeight,
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
@@ -1218,6 +1303,12 @@ Page({
gameInfoSubtitle: '未开始',
gameInfoLocalRows: [],
gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
resultSceneTitle: '本局结果',
resultSceneSubtitle: '未开始',
resultSceneHeroLabel: '本局用时',
resultSceneHeroValue: '--',
resultSceneRows: buildEmptyResultSceneSnapshot().rows,
resultSceneCountdownText: '',
panelTimerText: '00:00:00',
panelTimerMode: 'elapsed',
panelTimerFxClass: '',
@@ -1349,6 +1440,18 @@ Page({
stageCanvasAttached = false
this.measureStageAndCanvas()
this.loadGameLaunchEnvelope(currentGameLaunchEnvelope)
const app = getApp<IAppOption>()
const pendingHeartRateAutoConnect = app.globalData ? app.globalData.pendingHeartRateAutoConnect : null
if (pendingHeartRateAutoConnect && pendingHeartRateAutoConnect.enabled && mapEngine) {
const pendingDeviceName = pendingHeartRateAutoConnect.deviceName || '心率带'
app.globalData.pendingHeartRateAutoConnect = null
mapEngine.handleConnectHeartRate()
this.setData({
statusText: `正在自动连接局前设备:${pendingDeviceName}`,
heartRateStatusText: `正在自动连接 ${pendingDeviceName}`,
heartRateDeviceText: pendingDeviceName,
})
}
},
onShow() {
@@ -1360,6 +1463,8 @@ Page({
onHide() {
this.persistSessionRecoverySnapshot()
clearResultExitRedirectTimer()
clearResultExitCountdownTimer()
if (mapEngine) {
mapEngine.handleAppHide()
}
@@ -1368,6 +1473,8 @@ Page({
onUnload() {
this.persistSessionRecoverySnapshot()
clearSessionRecoveryPersistTimer()
clearResultExitRedirectTimer()
clearResultExitCountdownTimer()
syncedBackendSessionStartId = ''
syncedBackendSessionFinishId = ''
clearGameInfoPanelSyncTimer()
@@ -1388,6 +1495,7 @@ Page({
systemSettingsLockLifetimeActive = false
currentGameLaunchEnvelope = getDemoGameLaunchEnvelope()
shouldAutoRestoreRecoverySnapshot = false
redirectedToResultPage = false
stageCanvasAttached = false
},
@@ -1528,6 +1636,57 @@ Page({
})
},
stashPendingResultSnapshot(snapshot: MapEngineResultSnapshot) {
const app = getApp<IAppOption>()
if (app.globalData) {
app.globalData.pendingResultSnapshot = snapshot
}
},
redirectToResultPage() {
if (redirectedToResultPage) {
return
}
clearResultExitRedirectTimer()
clearResultExitCountdownTimer()
redirectedToResultPage = true
const sessionContext = getCurrentBackendSessionContext()
const resultUrl = sessionContext
? `/pages/result/result?sessionId=${encodeURIComponent(sessionContext.sessionId)}`
: '/pages/result/result'
wx.redirectTo({
url: resultUrl,
})
},
presentResultExitPrompt() {
clearResultExitRedirectTimer()
clearResultExitCountdownTimer()
let remainingSeconds = Math.ceil(RESULT_EXIT_REDIRECT_DELAY_MS / 1000)
this.setData({
showResultScene: true,
resultSceneCountdownText: `${remainingSeconds} 秒后自动进入成绩页`,
})
resultExitCountdownTimer = setInterval(() => {
remainingSeconds -= 1
if (remainingSeconds <= 0) {
clearResultExitCountdownTimer()
return
}
this.setData({
resultSceneCountdownText: `${remainingSeconds} 秒后自动进入成绩页`,
})
}, 1000) as unknown as number
resultExitRedirectTimer = setTimeout(() => {
resultExitRedirectTimer = 0
this.redirectToResultPage()
}, RESULT_EXIT_REDIRECT_DELAY_MS) as unknown as number
},
restoreRecoverySnapshot(snapshot: SessionRecoverySnapshot) {
systemSettingsLockLifetimeActive = true
this.applyRuntimeSystemSettings(true)
@@ -2052,20 +2211,53 @@ Page({
},
handleConnectHeartRate() {
if (mapEngine) {
mapEngine.handleConnectHeartRate()
}
},
handleDisconnectHeartRate() {
if (mapEngine) {
mapEngine.handleDisconnectHeartRate()
if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') {
return
}
if (mapEngine) {
mapEngine.handleConnectHeartRate()
}
},
handleOpenHeartRateDevicePicker() {
if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') {
return
}
this.setData({
showHeartRateDevicePicker: true,
})
if (mapEngine) {
mapEngine.handleConnectHeartRate()
}
},
handleCloseHeartRateDevicePicker() {
this.setData({
showHeartRateDevicePicker: false,
})
},
handleDisconnectHeartRate() {
if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') {
return
}
if (mapEngine) {
mapEngine.handleDisconnectHeartRate()
}
},
handleConnectHeartRateDevice(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) {
if (mapEngine && event.currentTarget && event.currentTarget.dataset && event.currentTarget.dataset.deviceId) {
mapEngine.handleConnectHeartRateDevice(event.currentTarget.dataset.deviceId)
const targetDeviceId = event.currentTarget.dataset.deviceId
const targetDevice = this.data.heartRateDiscoveredDevices.find((item) => item.deviceId === targetDeviceId)
pendingHeartRateSwitchDeviceName = targetDevice ? targetDevice.name : null
mapEngine.handleConnectHeartRateDevice(targetDeviceId)
this.setData({
showHeartRateDevicePicker: false,
statusText: targetDevice
? `正在切换到 ${targetDevice.name}`
: '正在切换心率带设备',
})
}
},
@@ -2174,9 +2366,21 @@ Page({
cancelText: '取消',
success: (result) => {
if (result.confirm && mapEngine) {
clearResultExitRedirectTimer()
clearResultExitCountdownTimer()
this.syncBackendSessionFinish('cancelled')
clearSessionRecoverySnapshot()
clearSessionRecoveryPersistTimer()
systemSettingsLockLifetimeActive = false
mapEngine.handleForceExitGame()
wx.showToast({
title: '已退出当前对局',
icon: 'none',
duration: 1000,
})
setTimeout(() => {
navigateAwayFromMapAfterCancel()
}, 180)
}
},
})
@@ -2312,24 +2516,11 @@ Page({
handleResultSceneTap() {},
handleCloseResultScene() {
this.setData({
showResultScene: false,
})
this.redirectToResultPage()
},
handleRestartFromResult() {
if (!mapEngine) {
return
}
this.setData({
showResultScene: false,
}, () => {
if (mapEngine) {
systemSettingsLockLifetimeActive = true
this.applyRuntimeSystemSettings(true)
mapEngine.handleStartGame()
}
})
this.redirectToResultPage()
},
handleOpenSystemSettingsPanel() {

View File

@@ -324,7 +324,7 @@
<view class="result-scene-modal" wx:if="{{showResultScene}}" bindtap="handleCloseResultScene">
<view class="result-scene-modal__dialog" catchtap="handleResultSceneTap">
<view class="result-scene-modal__eyebrow">RESULT</view>
<view class="result-scene-modal__eyebrow">FINISH</view>
<view class="result-scene-modal__title">{{resultSceneTitle}}</view>
<view class="result-scene-modal__subtitle">{{resultSceneSubtitle}}</view>
@@ -340,9 +340,10 @@
</view>
</view>
<view class="result-scene-modal__countdown">{{resultSceneCountdownText}}</view>
<view class="result-scene-modal__actions">
<view class="result-scene-modal__action result-scene-modal__action--secondary" bindtap="handleCloseResultScene">返回地图</view>
<view class="result-scene-modal__action result-scene-modal__action--primary" bindtap="handleRestartFromResult">再来一局</view>
<view class="result-scene-modal__action result-scene-modal__action--primary" bindtap="handleRestartFromResult">查看成绩</view>
</view>
</view>
</view>
@@ -726,13 +727,31 @@
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">16. 心率设备</view>
<view class="debug-section__desc">清除已记住的首选心率带设备,下次重新选择</view>
<view class="debug-section__desc">局内正式入口,可快速更换、重连或断开当前心率带</view>
</view>
<view class="debug-section__lock {{lockHeartRateDevice ? 'debug-section__lock--active' : ''}}">
<text class="debug-section__lock-text">{{lockHeartRateDevice ? '配置锁定' : '允许调整'}}</text>
</view>
</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前状态</text>
<text class="info-panel__value">{{heartRateStatusText}}{{heartRateSourceMode !== 'real' ? ' · 当前为模拟模式' : ''}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">当前设备</text>
<text class="info-panel__value">{{heartRateDeviceText}}</text>
</view>
<view class="info-panel__row" wx:if="{{heartRateSourceMode === 'real'}}">
<text class="info-panel__label">扫描状态</text>
<text class="info-panel__value">{{heartRateScanText}}</text>
</view>
<view class="summary" wx:if="{{heartRateSourceMode !== 'real'}}">当前为模拟心率模式,如需连接真实心率带,请先在调试面板切回“真实心率”。</view>
<view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
<view class="control-chip control-chip--secondary {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleOpenHeartRateDevicePicker">更换心率带</view>
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}} {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '重新扫描' : '连接心率带'}}</view>
<view class="control-chip control-chip--secondary {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleDisconnectHeartRate">断开心率带</view>
</view>
<view class="control-row">
<view class="control-chip control-chip--secondary {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleClearPreferredHeartRateDevice">清除首选设备</view>
</view>
@@ -897,25 +916,10 @@
<text class="info-panel__label">HR Scan</text>
<text class="info-panel__value">{{heartRateScanText}}</text>
</view>
<view class="debug-device-list" wx:if="{{heartRateSourceMode === 'real' && heartRateDiscoveredDevices.length}}">
<view class="debug-device-card" wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId">
<view class="debug-device-card__main">
<view class="debug-device-card__title-row">
<text class="debug-device-card__name">{{item.name}}</text>
<text class="debug-device-card__badge" wx:if="{{item.preferred}}">首选</text>
</view>
<text class="debug-device-card__meta">{{item.rssiText}}</text>
</view>
<view class="debug-device-card__action {{item.connected ? 'debug-device-card__action--active' : ''}}" data-device-id="{{item.deviceId}}" bindtap="handleConnectHeartRateDevice">{{item.connected ? '已连接' : '连接'}}</view>
</view>
</view>
<view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
</view>
<view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
<view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '重新扫描' : '连接心率带'}}</view>
</view>
<view class="summary" wx:if="{{heartRateSourceMode === 'real'}}">正式用户入口已放到系统设置;这里仅保留心率源切换与开发调试能力。</view>
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
<text class="info-panel__label">心率模拟状态</text>
<text class="info-panel__value">{{mockHeartRateBridgeStatusText}}</text>
@@ -1169,9 +1173,32 @@
<text class="info-panel__value">{{networkFetchCount}}</text>
</view>
</view>
</scroll-view>
</scroll-view>
</view>
</view>
<view wx:if="{{showHeartRateDevicePicker}}" class="picker-mask" bindtap="handleCloseHeartRateDevicePicker"></view>
<view wx:if="{{showHeartRateDevicePicker}}" class="picker-sheet">
<view class="picker-sheet__header">
<view class="picker-sheet__title">选择心率带设备</view>
<button class="picker-sheet__close" bindtap="handleCloseHeartRateDevicePicker">关闭</button>
</view>
<view class="summary">扫描状态:{{heartRateScanText}}</view>
<view wx:if="{{!heartRateDiscoveredDevices.length}}" class="summary">当前还没有发现设备,可先点“重新扫描”。</view>
<view wx:if="{{heartRateDiscoveredDevices.length}}" class="device-list">
<view wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId" class="device-card">
<view class="device-card__main">
<view class="device-card__title-row">
<text class="device-card__name">{{item.name}}</text>
<text class="device-card__badge" wx:if="{{item.preferred}}">首选</text>
<text class="device-card__badge device-card__badge--active" wx:if="{{item.connected}}">已连接</text>
</view>
<text class="device-card__meta">{{item.rssiText}}</text>
</view>
<button class="btn {{item.connected ? 'btn--ghost' : 'btn--secondary'}} device-card__action" data-device-id="{{item.deviceId}}" bindtap="handleConnectHeartRateDevice">{{item.connected ? '已连接' : '连接'}}</button>
</view>
</view>
</view>
</view>
</view>

View File

@@ -1458,6 +1458,14 @@
text-align: right;
}
.result-scene-modal__countdown {
margin-top: 18rpx;
text-align: center;
font-size: 22rpx;
line-height: 1.4;
color: #6a826f;
}
.result-scene-modal__actions {
margin-top: 28rpx;
display: flex;
@@ -1781,6 +1789,143 @@
color: #f7fbf2;
}
.picker-mask {
position: absolute;
inset: 0;
background: rgba(10, 22, 38, 0.42);
z-index: 90;
}
.picker-sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 91;
display: grid;
gap: 16rpx;
padding: 24rpx 24rpx 36rpx;
border-top-left-radius: 28rpx;
border-top-right-radius: 28rpx;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 -14rpx 36rpx rgba(22, 43, 71, 0.18);
}
.picker-sheet__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.picker-sheet__title {
font-size: 30rpx;
font-weight: 700;
color: #17345a;
}
.picker-sheet__close {
margin: 0;
min-height: 60rpx;
padding: 0 18rpx;
line-height: 60rpx;
border-radius: 999rpx;
font-size: 22rpx;
background: #eef3f8;
color: #455a72;
}
.picker-sheet__close::after {
border: 0;
}
.summary {
font-size: 24rpx;
line-height: 1.6;
color: #30465f;
}
.device-list {
display: grid;
gap: 14rpx;
}
.device-card {
display: flex;
justify-content: space-between;
gap: 16rpx;
align-items: center;
padding: 18rpx;
border-radius: 18rpx;
background: #f6f9fc;
}
.device-card__main {
display: grid;
gap: 8rpx;
min-width: 0;
flex: 1;
}
.device-card__title-row {
display: flex;
gap: 10rpx;
align-items: center;
flex-wrap: wrap;
}
.device-card__name {
font-size: 26rpx;
font-weight: 700;
color: #17345a;
}
.device-card__badge {
padding: 4rpx 10rpx;
border-radius: 999rpx;
background: #e1ecfa;
color: #35567d;
font-size: 20rpx;
}
.device-card__badge--active {
background: #dff3e8;
color: #1f6a45;
}
.device-card__meta {
font-size: 22rpx;
color: #5c7288;
}
.device-card__action {
flex: none;
}
.btn {
margin: 0;
min-height: 76rpx;
padding: 0 24rpx;
line-height: 76rpx;
border-radius: 18rpx;
font-size: 26rpx;
}
.btn::after {
border: 0;
}
.btn--secondary {
background: #dfeaf8;
color: #173d73;
}
.btn--ghost {
background: #ffffff;
color: #52657d;
border: 2rpx solid #d8e2ec;
}
.control-row {
display: flex;
gap: 14rpx;

View File

@@ -1,5 +1,6 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getMyResults, getSessionResult, type BackendSessionResultView } from '../../utils/backendApi'
import { getSessionResult } from '../../utils/backendApi'
import type { MapEngineResultSnapshot } from '../../engine/map/mapEngine'
type ResultPageData = {
sessionId: string
@@ -7,7 +8,6 @@ type ResultPageData = {
sessionTitleText: string
sessionSubtitleText: string
rows: Array<{ label: string; value: string }>
recentResults: BackendSessionResultView[]
}
function getAccessToken(): string | null {
@@ -25,6 +25,22 @@ function formatValue(value: unknown): string {
return String(value)
}
function formatRouteSummary(input: {
variantName?: string | null
routeCode?: string | null
}): string {
if (input.variantName && input.routeCode) {
return `${input.variantName} / ${input.routeCode}`
}
if (input.variantName) {
return input.variantName
}
if (input.routeCode) {
return input.routeCode
}
return '默认赛道'
}
Page({
data: {
sessionId: '',
@@ -32,17 +48,49 @@ Page({
sessionTitleText: '结果页',
sessionSubtitleText: '未加载',
rows: [],
recentResults: [],
} as ResultPageData,
onLoad(query: { sessionId?: string }) {
const sessionId = query && query.sessionId ? decodeURIComponent(query.sessionId) : ''
this.setData({ sessionId })
this.applyPendingResultSnapshot()
if (sessionId) {
this.loadSingleResult(sessionId)
return
}
this.loadRecentResults()
this.setData({
statusText: '未提供单局会话,已跳转历史结果',
})
wx.redirectTo({
url: '/pages/results/results',
})
},
applyPendingResultSnapshot() {
const app = getApp<IAppOption>()
const snapshot = app.globalData && app.globalData.pendingResultSnapshot
? app.globalData.pendingResultSnapshot as MapEngineResultSnapshot
: null
if (!snapshot) {
return
}
this.setData({
statusText: '正在加载结果',
sessionTitleText: snapshot.title,
sessionSubtitleText: snapshot.subtitle,
rows: [
{ label: snapshot.heroLabel, value: snapshot.heroValue },
...snapshot.rows.map((row) => ({
label: row.label,
value: row.value,
})),
],
})
if (app.globalData) {
app.globalData.pendingResultSnapshot = null
}
},
async loadSingleResult(sessionId: string) {
@@ -65,8 +113,9 @@ Page({
this.setData({
statusText: '单局结果加载完成',
sessionTitleText: result.session.eventName || result.session.eventDisplayName || result.session.eventId || result.session.id || result.session.sessionId,
sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status}`,
sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status} / ${formatRouteSummary(result.session)}`,
rows: [
{ label: '赛道版本', value: formatRouteSummary(result.session) },
{ label: '最终得分', value: formatValue(result.result.finalScore) },
{ label: '最终用时(秒)', value: formatValue(result.result.finalDurationSec) },
{ label: '完成点数', value: formatValue(result.result.completedControls) },
@@ -84,51 +133,9 @@ Page({
}
},
async loadRecentResults() {
const accessToken = getAccessToken()
if (!accessToken) {
wx.redirectTo({ url: '/pages/login/login' })
return
}
this.setData({
statusText: '正在加载最近结果',
})
try {
const results = await getMyResults({
baseUrl: loadBackendBaseUrl(),
accessToken,
limit: 20,
})
this.setData({
statusText: '最近结果加载完成',
sessionSubtitleText: '最近结果列表',
recentResults: results,
})
} catch (error) {
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
this.setData({
statusText: `结果加载失败:${message}`,
})
}
},
handleOpenResult(event: WechatMiniprogram.TouchEvent) {
const sessionId = event.currentTarget.dataset.sessionId as string | undefined
if (!sessionId) {
return
}
wx.redirectTo({
url: `/pages/result/result?sessionId=${encodeURIComponent(sessionId)}`,
})
},
handleBackToList() {
this.setData({
sessionId: '',
rows: [],
wx.redirectTo({
url: '/pages/results/results',
})
this.loadRecentResults()
},
})

View File

@@ -9,7 +9,7 @@
<view class="panel">
<view class="panel__title">当前状态</view>
<view class="summary">{{statusText}}</view>
<button wx:if="{{sessionId}}" class="btn btn--ghost" bindtap="handleBackToList">返回最近结果</button>
<button class="btn btn--ghost" bindtap="handleBackToList">查看历史结果</button>
</view>
<view wx:if="{{rows.length}}" class="panel">
@@ -19,15 +19,5 @@
<view class="row__value">{{item.value}}</view>
</view>
</view>
<view wx:if="{{!sessionId}}" class="panel">
<view class="panel__title">最近结果</view>
<view wx:if="{{!recentResults.length}}" class="summary">当前没有结果记录</view>
<view wx:for="{{recentResults}}" wx:key="session.id" class="result-card" bindtap="handleOpenResult" data-session-id="{{item.session.id}}">
<view class="result-card__title">{{item.session.eventName || item.session.id}}</view>
<view class="result-card__meta">{{item.result.status}} / {{item.session.status}}</view>
<view class="result-card__meta">得分 {{item.result.finalScore || '--'}} / 用时 {{item.result.finalDurationSec || '--'}}s</view>
</view>
</view>
</view>
</scroll-view>

View File

@@ -0,0 +1,104 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getMyResults, type BackendSessionResultView } from '../../utils/backendApi'
type ResultsPageData = {
loading: boolean
statusText: string
results: Array<{
sessionId: string
titleText: string
statusText: string
scoreText: string
routeText: string
}>
}
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 formatRouteSummary(result: BackendSessionResultView): string {
const session = result.session
if (session.variantName && session.routeCode) {
return `${session.variantName} / ${session.routeCode}`
}
if (session.variantName) {
return session.variantName
}
if (session.routeCode) {
return session.routeCode
}
return '默认赛道'
}
function buildResultCardView(result: BackendSessionResultView) {
return {
sessionId: result.session.id,
titleText: result.session.eventName || result.session.id,
statusText: `${result.result.status} / ${result.session.status}`,
scoreText: `得分 ${result.result.finalScore || '--'} / 用时 ${result.result.finalDurationSec || '--'}s`,
routeText: `赛道 ${formatRouteSummary(result)}`,
}
}
Page({
data: {
loading: false,
statusText: '准备加载历史结果',
results: [],
} as ResultsPageData,
onLoad() {
this.loadResults()
},
onShow() {
this.loadResults()
},
async loadResults() {
const accessToken = getAccessToken()
if (!accessToken) {
wx.redirectTo({ url: '/pages/login/login' })
return
}
this.setData({
loading: true,
statusText: '正在加载历史结果',
})
try {
const results = await getMyResults({
baseUrl: loadBackendBaseUrl(),
accessToken,
limit: 20,
})
this.setData({
loading: false,
statusText: `历史结果加载完成,共 ${results.length}`,
results: results.map(buildResultCardView),
})
} catch (error) {
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
this.setData({
loading: false,
statusText: `历史结果加载失败:${message}`,
})
}
},
handleOpenResult(event: WechatMiniprogram.TouchEvent) {
const sessionId = event.currentTarget.dataset.sessionId as string | undefined
if (!sessionId) {
return
}
wx.navigateTo({
url: `/pages/result/result?sessionId=${encodeURIComponent(sessionId)}`,
})
},
})

View File

@@ -0,0 +1,25 @@
<scroll-view class="page" scroll-y>
<view class="shell">
<view class="hero">
<view class="hero__eyebrow">Results</view>
<view class="hero__title">历史结果</view>
<view class="hero__desc">查看最近联调与正式对局结果</view>
</view>
<view class="panel">
<view class="panel__title">当前状态</view>
<view class="summary">{{statusText}}</view>
</view>
<view class="panel">
<view class="panel__title">结果列表</view>
<view wx:if="{{!results.length}}" class="summary">当前没有结果记录</view>
<view wx:for="{{results}}" wx:key="sessionId" class="result-card" bindtap="handleOpenResult" data-session-id="{{item.sessionId}}">
<view class="result-card__title">{{item.titleText}}</view>
<view class="result-card__meta">{{item.statusText}}</view>
<view class="result-card__meta">{{item.scoreText}}</view>
<view class="result-card__meta">{{item.routeText}}</view>
</view>
</view>
</view>
</scroll-view>

View File

@@ -0,0 +1,76 @@
page {
min-height: 100vh;
background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%);
}
.page {
min-height: 100vh;
}
.shell {
display: grid;
gap: 24rpx;
padding: 28rpx 24rpx 40rpx;
}
.hero,
.panel {
display: grid;
gap: 16rpx;
padding: 24rpx;
border-radius: 24rpx;
}
.hero {
background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%);
color: #ffffff;
}
.hero__eyebrow {
font-size: 22rpx;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
}
.hero__title {
font-size: 40rpx;
font-weight: 700;
}
.hero__desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.84);
}
.panel {
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08);
}
.panel__title {
font-size: 30rpx;
font-weight: 700;
color: #17345a;
}
.summary,
.result-card__meta {
font-size: 24rpx;
line-height: 1.6;
color: #30465f;
}
.result-card {
display: grid;
gap: 8rpx;
padding: 18rpx;
border-radius: 18rpx;
background: #f6f9fc;
}
.result-card__title {
font-size: 28rpx;
font-weight: 700;
color: #17345a;
}

View File

@@ -30,6 +30,21 @@ export interface BackendResolvedRelease {
routeCode?: string | null
}
export interface BackendCourseVariantSummary {
id: string
name: string
description?: string | null
routeCode?: string | null
selectable?: boolean
}
export interface BackendLaunchVariantSummary {
id: string
name: string
routeCode?: string | null
assignmentMode?: string | null
}
export interface BackendEntrySessionSummary {
id: string
status: string
@@ -38,6 +53,8 @@ export interface BackendEntrySessionSummary {
releaseId?: string | null
configLabel?: string | null
routeCode?: string | null
variantId?: string | null
variantName?: string | null
launchedAt?: string | null
startedAt?: string | null
endedAt?: string | null
@@ -111,6 +128,8 @@ export interface BackendEventPlayResult {
primaryAction: string
reason: string
launchSource?: string
assignmentMode?: string | null
courseVariants?: BackendCourseVariantSummary[] | null
ongoingSession?: BackendEntrySessionSummary | null
recentSession?: BackendEntrySessionSummary | null
}
@@ -139,6 +158,7 @@ export interface BackendLaunchResult {
sessionTokenExpiresAt: string
routeCode?: string | null
}
variant?: BackendLaunchVariantSummary | null
}
}
@@ -294,6 +314,7 @@ export function launchEvent(input: {
eventId: string
accessToken: string
releaseId?: string
variantId?: string
clientType: string
deviceKey: string
}): Promise<BackendLaunchResult> {
@@ -304,6 +325,9 @@ export function launchEvent(input: {
if (input.releaseId) {
body.releaseId = input.releaseId
}
if (input.variantId) {
body.variantId = input.variantId
}
return requestBackend<BackendLaunchResult>({
method: 'POST',
baseUrl: input.baseUrl,

View File

@@ -17,5 +17,17 @@ export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult):
sessionToken: result.launch.business.sessionToken,
sessionTokenExpiresAt: result.launch.business.sessionTokenExpiresAt,
},
variant: result.launch.variant
? {
variantId: result.launch.variant.id,
variantName: result.launch.variant.name,
routeCode: result.launch.variant.routeCode || result.launch.config.routeCode || result.launch.business.routeCode || null,
assignmentMode: result.launch.variant.assignmentMode || null,
}
: (result.launch.config.routeCode || result.launch.business.routeCode)
? {
routeCode: result.launch.config.routeCode || result.launch.business.routeCode || null,
}
: null,
}
}

View File

@@ -22,9 +22,17 @@ export interface BusinessLaunchContext {
realtimeToken?: string | null
}
export interface GameVariantLaunchContext {
variantId?: string | null
variantName?: string | null
routeCode?: string | null
assignmentMode?: string | null
}
export interface GameLaunchEnvelope {
config: GameConfigLaunchRequest
business: BusinessLaunchContext | null
variant?: GameVariantLaunchContext | null
}
export interface MapPageLaunchOptions {
@@ -46,6 +54,9 @@ export interface MapPageLaunchOptions {
sessionTokenExpiresAt?: string
realtimeEndpoint?: string
realtimeToken?: string
variantId?: string
variantName?: string
assignmentMode?: string
}
type PendingGameLaunchStore = Record<string, GameLaunchEnvelope>
@@ -121,6 +132,28 @@ function buildBusinessLaunchContext(options?: MapPageLaunchOptions | null): Busi
}
}
function buildVariantLaunchContext(options?: MapPageLaunchOptions | null): GameVariantLaunchContext | null {
if (!options) {
return null
}
const variantId = normalizeOptionalString(options.variantId)
const variantName = normalizeOptionalString(options.variantName)
const routeCode = normalizeOptionalString(options.routeCode)
const assignmentMode = normalizeOptionalString(options.assignmentMode)
if (!variantId && !variantName && !routeCode && !assignmentMode) {
return null
}
return {
variantId,
variantName,
routeCode,
assignmentMode,
}
}
function loadPendingGameLaunchStore(): PendingGameLaunchStore {
try {
const stored = wx.getStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY)
@@ -146,6 +179,7 @@ export function getDemoGameLaunchEnvelope(preset: DemoGamePreset = 'classic'): G
business: {
source: 'demo',
},
variant: null,
}
}
@@ -217,6 +251,7 @@ export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null)
routeCode: normalizeOptionalString(options ? options.routeCode : undefined),
},
business: buildBusinessLaunchContext(options),
variant: buildVariantLaunchContext(options),
}
}