推进活动系统最小成品闭环与游客体验

This commit is contained in:
2026-04-07 19:05:18 +08:00
parent 1a6008449e
commit 6cd16f08dd
102 changed files with 16087 additions and 3556 deletions

View File

@@ -1,9 +1,11 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getEventPlay, launchEvent, type BackendCourseVariantSummary, type BackendEventPlayResult } from '../../utils/backendApi'
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'
@@ -14,6 +16,9 @@ type EventPreparePageData = {
eventId: string
loading: boolean
canLaunch: boolean
launchInFlight: boolean
launchProgressText: string
launchProgressPercent: number
titleText: string
summaryText: string
releaseText: string
@@ -28,6 +33,20 @@ type EventPreparePageData = {
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
@@ -55,6 +74,104 @@ type EventPreparePageData = {
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 {
@@ -212,6 +329,23 @@ function shouldShowVariantSelector(
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
@@ -260,6 +394,9 @@ Page({
eventId: '',
loading: false,
canLaunch: false,
launchInFlight: false,
launchProgressText: '待进入地图',
launchProgressPercent: 0,
titleText: '开始前准备',
summaryText: '未加载',
releaseText: '--',
@@ -270,10 +407,17 @@ Page({
variantSummaryText: '--',
presentationText: '--',
contentBundleText: '--',
runtimePlaceText: '待 launch 确认',
runtimeMapText: '待 launch 确认',
runtimeVariantText: '待 launch 确认',
runtimeRouteCodeText: '待 launch 确认',
runtimePlaceText: '进入地图后确认',
runtimeMapText: '进入地图后确认',
runtimeVariantText: '进入地图后确认',
runtimeRouteCodeText: '进入地图后确认',
previewVisible: false,
previewLoading: false,
previewStatusText: '准备加载地图预览',
previewHintText: '进入地图前先看地图范围与当前已知赛道。',
previewVariantText: '预览将跟随当前赛道选择联动',
previewTiles: [],
previewControls: [],
selectedVariantId: '',
selectedVariantText: '当前无需手动指定赛道',
showVariantSelector: false,
@@ -289,6 +433,7 @@ Page({
locationBackgroundPermissionGranted: false,
heartRateDiscoveredDevices: [],
mockSourceStatusText: '模拟源调试仍在地图页调试面板中使用',
showMockSourceSummary: false,
} as EventPreparePageData,
onLoad(query: { eventId?: string }) {
@@ -306,10 +451,12 @@ Page({
},
onShow() {
resetPrepareLaunchVisualState(this as unknown as EventPreparePageContext)
this.refreshPreparationDeviceState()
},
onUnload() {
resetPrepareLaunchVisualState(this as unknown as EventPreparePageContext)
if (prepareHeartRateController) {
prepareHeartRateController.destroy()
prepareHeartRateController = null
@@ -319,10 +466,6 @@ Page({
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,
@@ -330,11 +473,17 @@ Page({
})
try {
const result = await getEventPlay({
baseUrl: loadBackendBaseUrl(),
eventId: targetEventId,
accessToken,
})
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 : '未知错误'
@@ -346,6 +495,14 @@ Page({
},
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,
@@ -379,6 +536,7 @@ Page({
? result.resolvedRelease.manifestUrl
: '',
details: {
guestMode: !getAccessToken(),
pageEventId: this.data.eventId || '',
resultEventId: result.event.id || '',
selectedVariantId: logVariantId,
@@ -411,18 +569,27 @@ Page({
variantSummaryText: formatVariantSummary(result),
presentationText: formatPresentationSummary(result),
contentBundleText: formatContentBundleSummary(result),
runtimePlaceText: '待 launch.runtime 确认',
runtimeMapText: '待 launch.runtime 确认',
runtimePlaceText: '进入地图后确认',
runtimeMapText: '进入地图后确认',
runtimeVariantText: selectedVariant
? selectedVariant.name
: (result.play.courseVariants && result.play.courseVariants[0]
? result.play.courseVariants[0].name
: '待 launch 确认'),
: '进入地图后确认'),
runtimeRouteCodeText: selectedVariant
? selectedVariant.routeCodeText
: (result.play.courseVariants && result.play.courseVariants[0] && result.play.courseVariants[0].routeCode
? result.play.courseVariants[0].routeCode || '待 launch 确认'
: '待 launch 确认'),
? 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}`
@@ -431,6 +598,153 @@ Page({
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() {
@@ -576,10 +890,12 @@ Page({
refreshMockSourcePreparationStatus() {
const channelId = loadStoredMockChannelId()
const autoConnect = loadMockAutoConnectEnabled()
const showMockSourceSummary = autoConnect || channelId !== 'default'
this.setData({
mockSourceStatusText: autoConnect
? `自动连接已开启 / 通道 ${channelId}`
: `自动连接未开启 / 通道 ${channelId}`,
? `调试源自动连接已开启 / 通道 ${channelId}`
: `当前使用调试通道 ${channelId}`,
showMockSourceSummary,
})
},
@@ -660,19 +976,36 @@ Page({
selectedVariantText: selectedVariant
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
: '当前无需手动指定赛道',
runtimeVariantText: selectedVariant ? selectedVariant.name : '待 launch 确认',
runtimeRouteCodeText: selectedVariant ? selectedVariant.routeCodeText : '待 launch 确认',
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 (!accessToken) {
wx.redirectTo({ url: '/pages/login/login' })
if (this.data.launchInFlight) {
wx.showToast({
title: '正在进入地图,请稍候',
icon: 'none',
})
return
}
if (!this.data.canLaunch) {
this.setData({
statusText: '当前发布状态不可进入地图',
@@ -696,8 +1029,29 @@ Page({
}
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
@@ -716,6 +1070,10 @@ Page({
phase: 'launch-requested',
},
})
this.setData({
launchProgressText: '已发起启动请求,正在等待服务器响应',
launchProgressPercent: 52,
})
const app = getApp<IAppOption>()
if (app.globalData) {
const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName
@@ -730,13 +1088,29 @@ Page({
prepareHeartRateController.destroy()
prepareHeartRateController = null
}
const result = await launchEvent({
baseUrl: loadBackendBaseUrl(),
eventId: this.data.eventId,
accessToken,
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',
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',
@@ -749,6 +1123,7 @@ Page({
? 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 : '',
@@ -769,8 +1144,15 @@ Page({
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}`,
})
}

View File

@@ -7,18 +7,28 @@
</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="panel__title">当前准备状态</view>
<view class="summary">先确认赛道、设备和权限,再进入地图开始本局。</view>
<view class="row">
<view class="row__label">当前发布</view>
<view class="row__value">{{releaseText}}</view>
</view>
<view class="row">
<view class="row__label">当前动作</view>
<view class="row__value">{{actionText}}</view>
</view>
<view class="row">
<view class="row__label">进入状态</view>
<view class="row__value">{{statusText}}</view>
</view>
<view class="summary">赛道模式:{{variantModeText}}</view>
<view class="summary">赛道摘要:{{variantSummaryText}}</view>
<view class="summary">当前选择:{{selectedVariantText}}</view>
</view>
<view class="panel">
<view class="panel__title">活动运营摘要</view>
<view class="summary">当前阶段先展示当前发布 release 绑定的活动运营对象摘要,不展开复杂 schema。</view>
<view class="panel__title">活动版本摘要</view>
<view class="summary">这里展示本次进入地图将会使用的发布对象摘要,方便你确认当前活动版本。</view>
<view class="row">
<view class="row__label">当前发布展示版本</view>
<view class="row__value">{{presentationText}}</view>
@@ -30,8 +40,8 @@
</view>
<view class="panel">
<view class="panel__title">运行对象摘要</view>
<view class="summary">当前阶段以前端已知信息预览,最终绑定以后端 `launch.runtime` 为准。</view>
<view class="panel__title">本局对象预览</view>
<view class="summary">进入地图前先用已知信息预览,最终绑定以后端 launch 返回结果为准。</view>
<view class="row">
<view class="row__label">地点</view>
<view class="row__value">{{runtimePlaceText}}</view>
@@ -48,11 +58,39 @@
<view class="row__label">RouteCode</view>
<view class="row__value">{{runtimeRouteCodeText}}</view>
</view>
<view class="preview-card" wx:if="{{previewVisible}}">
<view class="preview-card__header">
<view class="preview-card__title">地图预览</view>
<view class="preview-card__status">{{previewStatusText}}</view>
</view>
<view class="preview-card__variant">{{previewVariantText}}</view>
<view class="preview-frame">
<view class="preview-stage">
<image
wx:for="{{previewTiles}}"
wx:key="url"
class="preview-tile"
src="{{item.url}}"
mode="scaleToFill"
style="{{item.styleText}}"
/>
<view class="preview-wash"></view>
<view
wx:for="{{previewControls}}"
wx:key="styleText"
class="preview-control {{item.kindClass}}"
style="{{item.styleText}}"
></view>
<view wx:if="{{previewLoading}}" class="preview-loading">预览加载中...</view>
</view>
</view>
<view class="summary">{{previewHintText}}</view>
</view>
</view>
<view class="panel" wx:if="{{showVariantSelector}}">
<view class="panel__title">赛道选择</view>
<view class="summary">当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。</view>
<view class="summary">如果当前活动支持手动选赛道,请先在这里确认你的本局路线。</view>
<view wx:if="{{!selectableVariants.length}}" class="summary">{{variantSelectorEmptyText}}</view>
<view wx:if="{{selectableVariants.length}}" 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">
@@ -70,7 +108,7 @@
<view class="panel">
<view class="panel__title">设备准备</view>
<view class="summary">这一页现在负责局前设备准备。定位权限先在这里确认,心率带支持先连后进图,地图内仍保留局中快速重连入口。</view>
<view class="summary">定位权限建议先在这里确认;如果需要心率带,也建议先连接后再进入地图。</view>
<view class="row">
<view class="row__label">定位状态</view>
<view class="row__value">{{locationStatusText}}</view>
@@ -92,7 +130,7 @@
<view class="row__label">扫描状态</view>
<view class="row__value">{{heartRateScanText}}</view>
</view>
<view class="row">
<view class="row" wx:if="{{showMockSourceSummary}}">
<view class="row__label">模拟源</view>
<view class="row__value">{{mockSourceStatusText}}</view>
</view>
@@ -105,12 +143,21 @@
</view>
<view class="panel">
<view class="panel__title">开始比赛</view>
<view class="summary">这一页先承担局前准备壳子,后面会继续接定位权限、心率带局前连接和设备检查。</view>
<view class="panel__title">进入地图</view>
<view class="summary">进入地图后无需再次点开始;按玩法规则前往开始点,即可正式开始比赛。</view>
<view wx:if="{{launchInFlight}}" class="launch-progress">
<view class="launch-progress__row">
<text class="launch-progress__label">当前进度</text>
<text class="launch-progress__value">{{launchProgressText}}</text>
</view>
<view class="launch-progress__track">
<view class="launch-progress__fill" style="width: {{launchProgressPercent}}%;"></view>
</view>
</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" disabled="{{!canLaunch}}">进入地图</button>
<button class="btn btn--secondary" bindtap="handleBack" disabled="{{launchInFlight}}">返回活动页</button>
<button class="btn btn--ghost" bindtap="handleRefresh" disabled="{{launchInFlight}}">刷新</button>
<button class="btn btn--primary" bindtap="handleLaunch" disabled="{{!canLaunch || launchInFlight}}">{{launchInFlight ? '正在进入地图...' : '进入地图'}}</button>
</view>
</view>
</view>

View File

@@ -88,6 +88,50 @@ page {
flex-wrap: wrap;
}
.launch-progress {
display: grid;
gap: 12rpx;
padding: 18rpx 20rpx;
border-radius: 18rpx;
background: #f4f8fc;
}
.launch-progress__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.launch-progress__label,
.launch-progress__value {
font-size: 22rpx;
line-height: 1.6;
color: #35567d;
}
.launch-progress__value {
font-weight: 700;
text-align: right;
}
.launch-progress__track {
position: relative;
height: 12rpx;
overflow: hidden;
border-radius: 999rpx;
background: #d7e4f1;
}
.launch-progress__fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 999rpx;
background: linear-gradient(90deg, #1e5ca1 0%, #2d78cf 100%);
}
.device-list {
display: grid;
gap: 14rpx;
@@ -98,6 +142,106 @@ page {
gap: 14rpx;
}
.preview-card {
display: grid;
gap: 14rpx;
margin-top: 8rpx;
}
.preview-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.preview-card__title {
font-size: 26rpx;
font-weight: 700;
color: #17345a;
}
.preview-card__status {
font-size: 22rpx;
color: #5c7288;
text-align: right;
}
.preview-card__variant {
justify-self: start;
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: #eef4fb;
color: #24486f;
font-size: 22rpx;
line-height: 1.5;
}
.preview-frame {
position: relative;
width: 100%;
padding-top: 56.25%;
overflow: hidden;
border-radius: 22rpx;
background: #d9e4ef;
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.35);
}
.preview-stage {
position: absolute;
inset: 0;
overflow: hidden;
background: #d7e1ec;
}
.preview-tile {
position: absolute;
}
.preview-wash {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.34);
pointer-events: none;
}
.preview-control {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 28rpx;
height: 28rpx;
margin-left: -14rpx;
margin-top: -14rpx;
border-radius: 999rpx;
border: 4rpx solid #e05f36;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 6rpx 14rpx rgba(23, 52, 90, 0.12);
}
.preview-control--start {
border-color: #1f6a45;
background: rgba(225, 245, 235, 0.96);
}
.preview-control--finish {
border-color: #8f1f4c;
background: rgba(255, 230, 239, 0.96);
}
.preview-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(244, 248, 252, 0.76);
font-size: 24rpx;
color: #35567d;
}
.variant-card {
display: grid;
gap: 8rpx;