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

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;

View File

@@ -1,16 +1,18 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getEventPlay, type BackendEventPlayResult } from '../../utils/backendApi'
import { getEventPlay, getPublicEventPlay, type BackendEventPlayResult } from '../../utils/backendApi'
import { formatBackendPlayActionText, formatBackendPlayStatusText } from '../../utils/backendPlayCopy'
import { reportBackendClientLog } from '../../utils/backendClientLogs'
type EventPageData = {
eventId: string
loading: boolean
canLaunch: boolean
titleText: string
summaryText: string
releaseText: string
actionText: string
statusText: string
primaryButtonText: string
variantModeText: string
variantSummaryText: string
presentationText: string
@@ -78,11 +80,13 @@ Page({
data: {
eventId: '',
loading: false,
canLaunch: false,
titleText: '活动详情',
summaryText: '未加载',
releaseText: '--',
actionText: '--',
statusText: '待加载',
primaryButtonText: '前往准备页',
variantModeText: '--',
variantSummaryText: '--',
presentationText: '--',
@@ -104,10 +108,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,
@@ -115,11 +115,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 : '未知错误'
@@ -144,6 +150,7 @@ Page({
? result.resolvedRelease.manifestUrl
: '',
details: {
guestMode: !getAccessToken(),
pageEventId: this.data.eventId || '',
resultEventId: result.event.id || '',
primaryAction: result.play.primaryAction || '',
@@ -161,6 +168,7 @@ Page({
})
this.setData({
loading: false,
canLaunch: result.play.canLaunch,
titleText: result.event.displayName,
summaryText: result.event.summary || '暂无活动简介',
releaseText: result.resolvedRelease
@@ -168,6 +176,7 @@ Page({
: '当前无可用 release',
actionText: formatBackendPlayActionText(result.play.primaryAction, result.play.reason),
statusText: formatBackendPlayStatusText(result.play.canLaunch, result.play.primaryAction, result.play.reason),
primaryButtonText: result.play.canLaunch ? '前往准备页' : '查看准备状态',
variantModeText: formatAssignmentMode(result.play.assignmentMode),
variantSummaryText: formatVariantSummary(result),
presentationText: formatPresentationSummary(result),

View File

@@ -7,18 +7,23 @@
</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="status-chip {{canLaunch ? 'status-chip--ready' : 'status-chip--blocked'}}">{{statusText}}</view>
<view class="summary">{{actionText}}</view>
<view class="summary">你可以先进入准备页查看赛道、设备和局前状态,再决定是否进入地图。</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
<button class="btn btn--primary" bindtap="handleLaunch">{{primaryButtonText}}</button>
</view>
</view>
<view class="panel">
<view class="panel__title">赛道与版本</view>
<view class="summary">当前发布版本:{{releaseText}}</view>
<view class="summary">赛道模式:{{variantModeText}}</view>
<view class="summary">赛道摘要:{{variantSummaryText}}</view>
<view class="summary">当前发布展示版本:{{presentationText}}</view>
<view class="summary">当前发布内容包版本:{{contentBundleText}}</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
<button class="btn btn--primary" bindtap="handleLaunch">前往准备页</button>
</view>
</view>
</view>
</scroll-view>

View File

@@ -61,6 +61,27 @@ page {
color: #30465f;
}
.status-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 52rpx;
padding: 0 18rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 700;
}
.status-chip--ready {
background: #ddf1e4;
color: #1f6a3a;
}
.status-chip--blocked {
background: #f8e7e3;
color: #8a3d28;
}
.actions {
display: flex;
gap: 16rpx;

View File

@@ -0,0 +1,195 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import {
getExperienceMapDetail,
getPublicExperienceMapDetail,
type BackendContentBundleSummary,
type BackendDefaultExperienceSummary,
type BackendExperienceMapDetail,
type BackendPresentationSummary,
} from '../../utils/backendApi'
import { reportBackendClientLog } from '../../utils/backendClientLogs'
type DefaultExperienceCardView = {
eventId: string
titleText: string
subtitleText: string
statusText: string
ctaText: string
eventTypeText: string
presentationText: string
contentBundleText: string
disabled: boolean
}
type ExperienceMapPageData = {
mapId: string
loading: boolean
statusText: string
placeText: string
mapText: string
summaryText: string
tileInfoText: string
cards: DefaultExperienceCardView[]
}
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 formatPresentationSummary(summary?: BackendPresentationSummary | null): string {
if (!summary) {
return '当前未声明展示版本'
}
return summary.version || summary.templateKey || summary.presentationId || '当前未声明展示版本'
}
function formatContentBundleSummary(summary?: BackendContentBundleSummary | null): string {
if (!summary) {
return '当前未声明内容包版本'
}
return summary.version || summary.bundleType || summary.bundleId || '当前未声明内容包版本'
}
function buildDefaultExperienceCard(item: BackendDefaultExperienceSummary): DefaultExperienceCardView {
const eventId = item.eventId || ''
return {
eventId,
titleText: item.title || '未命名体验活动',
subtitleText: item.subtitle || '当前暂无副标题',
statusText: item.status || item.statusCode || '状态待确认',
ctaText: item.ctaText || '查看体验',
eventTypeText: item.eventType || '类型待确认',
presentationText: formatPresentationSummary(item.currentPresentation),
contentBundleText: formatContentBundleSummary(item.currentContentBundle),
disabled: !eventId,
}
}
Page({
data: {
mapId: '',
loading: false,
statusText: '准备加载地图详情',
placeText: '地点待确认',
mapText: '地图待确认',
summaryText: '当前暂无地图摘要',
tileInfoText: '瓦片信息待确认',
cards: [],
} as ExperienceMapPageData,
onLoad(query: { mapId?: string }) {
const mapId = query && query.mapId ? decodeURIComponent(query.mapId) : ''
if (!mapId) {
this.setData({
statusText: '缺少 mapId',
})
return
}
this.setData({ mapId })
this.loadMapDetail(mapId)
},
onShow() {
if (this.data.mapId) {
this.loadMapDetail(this.data.mapId)
}
},
async loadMapDetail(mapId?: string) {
const targetMapId = mapId || this.data.mapId
const accessToken = getAccessToken()
this.setData({
loading: true,
statusText: '正在加载地图详情',
})
try {
const baseUrl = loadBackendBaseUrl()
const result = accessToken
? await getExperienceMapDetail({
baseUrl,
accessToken,
mapAssetId: targetMapId,
})
: await getPublicExperienceMapDetail({
baseUrl,
mapAssetId: targetMapId,
})
this.applyDetail(result)
} catch (error) {
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
this.setData({
loading: false,
statusText: `地图详情加载失败:${message}`,
cards: [],
})
}
},
applyDetail(result: BackendExperienceMapDetail) {
const cards = (result.defaultExperiences || []).map(buildDefaultExperienceCard)
reportBackendClientLog({
level: 'info',
category: 'experience-map-detail',
message: 'experience map detail loaded',
details: {
guestMode: !getAccessToken(),
mapId: result.mapId || this.data.mapId || '',
placeId: result.placeId || '',
defaultExperienceCount: cards.length,
defaultExperienceEventIds: (result.defaultExperiences || []).map((item) => item.eventId || ''),
},
})
const tileBase = result.tileBaseUrl || ''
const tileMeta = result.tileMetaUrl || ''
const tileInfoText = tileBase || tileMeta
? `底图 ${tileBase || '--'} / Meta ${tileMeta || '--'}`
: '当前未声明瓦片信息'
this.setData({
loading: false,
statusText: cards.length ? '地图详情加载完成' : '当前地图暂无默认体验活动',
placeText: result.placeName || result.placeId || '地点待确认',
mapText: result.mapName || result.mapId || '地图待确认',
summaryText: result.summary || '当前暂无地图摘要',
tileInfoText,
cards,
})
},
handleRefresh() {
this.loadMapDetail()
},
handleOpenExperience(event: WechatMiniprogram.TouchEvent) {
const eventId = event.currentTarget.dataset.eventId as string | undefined
reportBackendClientLog({
level: 'info',
category: 'experience-map-detail',
message: 'default experience clicked',
eventId: eventId || '',
details: {
mapId: this.data.mapId || '',
clickedEventId: eventId || '',
},
})
if (!eventId) {
wx.showToast({
title: '该体验活动暂无入口',
icon: 'none',
})
return
}
wx.navigateTo({
url: `/pages/event/event?eventId=${encodeURIComponent(eventId)}`,
})
},
})

View File

@@ -0,0 +1,42 @@
<scroll-view class="page" scroll-y>
<view class="shell">
<view class="hero">
<view class="hero__eyebrow">Map Detail</view>
<view class="hero__title">{{mapText}}</view>
<view class="hero__desc">{{placeText}}</view>
</view>
<view class="panel">
<view class="panel__title">地图信息</view>
<view class="summary">{{summaryText}}</view>
<view class="summary">{{tileInfoText}}</view>
<view class="summary">{{statusText}}</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新详情</button>
</view>
</view>
<view class="panel">
<view class="panel__title">默认体验活动</view>
<view wx:if="{{!cards.length}}" class="summary">当前暂无体验活动</view>
<view wx:for="{{cards}}" wx:key="eventId" class="card {{item.disabled ? 'card--disabled' : ''}}" bindtap="handleOpenExperience" data-event-id="{{item.eventId}}">
<view class="card__top">
<text class="card__badge">体验</text>
<text class="card__type">{{item.eventTypeText}}</text>
</view>
<view class="card__title">{{item.titleText}}</view>
<view class="card__subtitle">{{item.subtitleText}}</view>
<view class="card__meta-row">
<text class="card__meta">{{item.statusText}}</text>
</view>
<view class="card__meta-row">
<text class="card__meta">展示:{{item.presentationText}}</text>
</view>
<view class="card__meta-row">
<text class="card__meta">内容:{{item.contentBundleText}}</text>
</view>
<view class="card__cta">{{item.ctaText}}</view>
</view>
</view>
</view>
</scroll-view>

View File

@@ -0,0 +1,153 @@
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 {
font-size: 24rpx;
line-height: 1.6;
color: #30465f;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.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;
}
.card {
display: grid;
gap: 12rpx;
padding: 22rpx;
border-radius: 22rpx;
background: #f6f9fc;
}
.card--disabled {
opacity: 0.7;
}
.card__top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16rpx;
}
.card__badge {
display: inline-flex;
align-items: center;
min-height: 40rpx;
padding: 0 14rpx;
border-radius: 999rpx;
background: #dce9fb;
color: #173d73;
font-size: 22rpx;
font-weight: 700;
}
.card__type {
font-size: 22rpx;
color: #64748b;
}
.card__title {
font-size: 30rpx;
font-weight: 700;
color: #17345a;
}
.card__subtitle,
.card__meta,
.card__cta {
font-size: 24rpx;
line-height: 1.6;
}
.card__subtitle {
color: #4f627a;
}
.card__meta-row {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.card__meta {
color: #64748b;
}
.card__cta {
color: #173d73;
font-weight: 700;
}

View File

@@ -0,0 +1,131 @@
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getExperienceMaps, getPublicExperienceMaps, type BackendExperienceMapSummary } from '../../utils/backendApi'
import { reportBackendClientLog } from '../../utils/backendClientLogs'
type ExperienceMapCardView = {
mapId: string
placeText: string
mapText: string
summaryText: string
coverUrl: string
defaultExperienceText: string
disabled: boolean
}
type ExperienceMapsPageData = {
loading: boolean
statusText: string
cards: ExperienceMapCardView[]
}
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 buildCardView(item: BackendExperienceMapSummary): ExperienceMapCardView {
const mapId = item.mapId || ''
const defaultExperienceCount = typeof item.defaultExperienceCount === 'number' ? item.defaultExperienceCount : 0
return {
mapId,
placeText: item.placeName || item.placeId || '地点待确认',
mapText: item.mapName || item.mapId || '地图待确认',
summaryText: item.summary || '当前暂无地图摘要',
coverUrl: item.coverUrl || '',
defaultExperienceText: defaultExperienceCount > 0 ? `默认体验 ${defaultExperienceCount}` : '当前暂无默认体验活动',
disabled: !mapId,
}
}
Page({
data: {
loading: false,
statusText: '准备加载地图体验列表',
cards: [],
} as ExperienceMapsPageData,
onLoad() {
this.loadMaps()
},
onShow() {
this.loadMaps()
},
async loadMaps() {
const accessToken = getAccessToken()
this.setData({
loading: true,
statusText: '正在加载地图体验列表',
})
try {
const baseUrl = loadBackendBaseUrl()
const result = accessToken
? await getExperienceMaps({
baseUrl,
accessToken,
})
: await getPublicExperienceMaps({
baseUrl,
})
reportBackendClientLog({
level: 'info',
category: 'experience-maps',
message: 'experience maps loaded',
details: {
guestMode: !accessToken,
mapCount: result.length,
mapIds: result.map((item) => item.mapId || ''),
mapsWithDefaultExperience: result.filter((item) => {
return typeof item.defaultExperienceCount === 'number' && item.defaultExperienceCount > 0
}).length,
},
})
const cards = result.map(buildCardView)
this.setData({
loading: false,
statusText: cards.length ? '地图体验列表加载完成' : '当前没有可体验地图',
cards,
})
} catch (error) {
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
this.setData({
loading: false,
statusText: `地图体验列表加载失败:${message}`,
cards: [],
})
}
},
handleRefresh() {
this.loadMaps()
},
handleOpenMap(event: WechatMiniprogram.TouchEvent) {
const mapId = event.currentTarget.dataset.mapId as string | undefined
reportBackendClientLog({
level: 'info',
category: 'experience-maps',
message: 'experience map clicked',
details: {
clickedMapId: mapId || '',
},
})
if (!mapId) {
wx.showToast({
title: '该地图暂无详情入口',
icon: 'none',
})
return
}
wx.navigateTo({
url: `/pages/experience-map/experience-map?mapId=${encodeURIComponent(mapId)}`,
})
},
})

View File

@@ -0,0 +1,29 @@
<scroll-view class="page" scroll-y>
<view class="shell">
<view class="hero">
<view class="hero__eyebrow">Map Experience</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 class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新列表</button>
</view>
</view>
<view class="panel">
<view class="panel__title">地图卡片</view>
<view wx:if="{{!cards.length}}" class="summary">当前没有可体验地图</view>
<view wx:for="{{cards}}" wx:key="mapId" class="card {{item.disabled ? 'card--disabled' : ''}}" bindtap="handleOpenMap" data-map-id="{{item.mapId}}">
<image wx:if="{{item.coverUrl}}" class="card__cover" src="{{item.coverUrl}}" mode="aspectFill"></image>
<view class="card__title">{{item.mapText}}</view>
<view class="card__subtitle">{{item.placeText}}</view>
<view class="card__summary">{{item.summaryText}}</view>
<view class="card__meta">{{item.defaultExperienceText}}</view>
</view>
</view>
</view>
</scroll-view>

View File

@@ -0,0 +1,129 @@
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 {
font-size: 24rpx;
line-height: 1.6;
color: #30465f;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.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;
}
.card {
display: grid;
gap: 12rpx;
padding: 22rpx;
border-radius: 22rpx;
background: #f6f9fc;
}
.card--disabled {
opacity: 0.7;
}
.card__cover {
width: 100%;
height: 220rpx;
border-radius: 18rpx;
background: #d7e4f2;
}
.card__title {
font-size: 30rpx;
font-weight: 700;
color: #17345a;
}
.card__subtitle,
.card__summary,
.card__meta {
font-size: 24rpx;
line-height: 1.6;
}
.card__subtitle {
color: #4f627a;
}
.card__summary {
color: #30465f;
}
.card__meta {
color: #64748b;
}

View File

@@ -1,7 +1,9 @@
import { clearBackendAuthTokens, loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { getEntryHome, type BackendCardResult, type BackendEntryHomeResult } from '../../utils/backendApi'
import { finishSession, getEntryHome, type BackendCardResult, type BackendEntryHomeResult } from '../../utils/backendApi'
import { reportBackendClientLog } from '../../utils/backendClientLogs'
import { setGlobalMockDebugBridgeEnabled } from '../../utils/globalMockDebugBridge'
import { clearSessionRecoverySnapshot, loadSessionRecoverySnapshot } from '../../game/core/sessionRecovery'
import { getBackendSessionContextFromLaunchEnvelope, prepareMapPageUrlForRecovery } from '../../utils/gameLaunch'
const DEFAULT_CHANNEL_CODE = 'mini-demo'
const DEFAULT_CHANNEL_TYPE = 'wechat_mini'
@@ -16,6 +18,10 @@ type HomePageData = {
recentSessionText: string
ongoingRuntimeText: string
recentRuntimeText: string
ongoingActionHintText: string
showOngoingPanel: boolean
canRecoverOngoing: boolean
canAbandonOngoing: boolean
cards: BackendCardResult[]
}
@@ -50,6 +56,15 @@ function requireAuthToken(): string | null {
return tokens && tokens.accessToken ? tokens.accessToken : null
}
function getRecoverySnapshotSessionId(): string {
const snapshot = loadSessionRecoverySnapshot()
if (!snapshot) {
return ''
}
const context = getBackendSessionContextFromLaunchEnvelope(snapshot.launchEnvelope)
return context && context.sessionId ? context.sessionId : ''
}
Page({
data: {
loading: false,
@@ -61,6 +76,10 @@ Page({
recentSessionText: '无',
ongoingRuntimeText: '运行对象 --',
recentRuntimeText: '运行对象 --',
ongoingActionHintText: '当前没有可恢复的进行中对局',
showOngoingPanel: false,
canRecoverOngoing: false,
canAbandonOngoing: false,
cards: [],
} as HomePageData,
@@ -102,6 +121,16 @@ Page({
},
applyEntryHomeResult(result: BackendEntryHomeResult) {
const ongoingSession = result.ongoingSession || null
const recoverySnapshotSessionId = getRecoverySnapshotSessionId()
const canRecoverOngoing = !!ongoingSession && !!recoverySnapshotSessionId
&& ongoingSession.id === recoverySnapshotSessionId
const canAbandonOngoing = canRecoverOngoing
const ongoingActionHintText = !ongoingSession
? '当前没有可恢复的进行中对局'
: canRecoverOngoing
? '检测到本机仍保留这局的恢复记录,你可以继续恢复或主动放弃。'
: '检测到后端存在进行中对局,但本机当前没有匹配的恢复快照。'
reportBackendClientLog({
level: 'info',
category: 'entry-home',
@@ -112,6 +141,9 @@ Page({
recentSessionId: result.recentSession && result.recentSession.id ? result.recentSession.id : '',
recentEventId: result.recentSession && result.recentSession.eventId ? result.recentSession.eventId : '',
cardEventIds: (result.cards || []).map((item) => (item.event && item.event.id ? item.event.id : '')),
hasOngoingSession: !!ongoingSession,
recoverySnapshotSessionId,
canRecoverOngoing,
},
})
this.setData({
@@ -124,6 +156,10 @@ Page({
recentSessionText: formatSessionSummary(result.recentSession),
ongoingRuntimeText: formatRuntimeSummary(result.ongoingSession),
recentRuntimeText: formatRuntimeSummary(result.recentSession),
ongoingActionHintText,
showOngoingPanel: !!ongoingSession,
canRecoverOngoing,
canAbandonOngoing,
cards: result.cards || [],
})
},
@@ -159,6 +195,79 @@ Page({
})
},
handleOpenExperienceMaps() {
wx.navigateTo({
url: '/pages/experience-maps/experience-maps',
})
},
handleResumeOngoing() {
const snapshot = loadSessionRecoverySnapshot()
if (!snapshot) {
wx.showToast({
title: '本机未找到恢复快照',
icon: 'none',
})
this.loadEntryHome()
return
}
wx.navigateTo({
url: prepareMapPageUrlForRecovery(snapshot.launchEnvelope),
})
},
handleAbandonOngoing() {
const snapshot = loadSessionRecoverySnapshot()
if (!snapshot) {
wx.showToast({
title: '本机未找到恢复快照',
icon: 'none',
})
this.loadEntryHome()
return
}
const sessionContext = getBackendSessionContextFromLaunchEnvelope(snapshot.launchEnvelope)
if (!sessionContext) {
clearSessionRecoverySnapshot()
wx.showToast({
title: '已清理本机恢复记录',
icon: 'none',
})
this.loadEntryHome()
return
}
wx.showModal({
title: '放弃进行中的游戏',
content: '放弃后,这局游戏会记为已取消,且不会再出现在“进行中”。',
confirmText: '确认放弃',
cancelText: '先保留',
success: (result) => {
if (!result.confirm) {
return
}
finishSession({
baseUrl: loadBackendBaseUrl(),
sessionId: sessionContext.sessionId,
sessionToken: sessionContext.sessionToken,
status: 'cancelled',
summary: {},
}).catch(() => {
wx.showToast({
title: '取消上报失败,请稍后重试',
icon: 'none',
})
}).finally(() => {
clearSessionRecoverySnapshot()
this.loadEntryHome()
})
},
})
},
handleLogout() {
clearBackendAuthTokens()
setGlobalMockDebugBridgeEnabled(false)

View File

@@ -10,18 +10,31 @@
<view class="panel">
<view class="panel__title">当前状态</view>
<view class="summary">{{statusText}}</view>
<view class="summary">进行中:{{ongoingSessionText}}</view>
<view class="summary">进行中运行对象:{{ongoingRuntimeText}}</view>
<view wx:if="{{showOngoingPanel}}" class="summary">进行中:{{ongoingSessionText}}</view>
<view wx:if="{{showOngoingPanel}}" class="summary">进行中运行对象:{{ongoingRuntimeText}}</view>
<view wx:if="{{showOngoingPanel}}" class="summary">{{ongoingActionHintText}}</view>
<view class="summary">最近一局:{{recentSessionText}}</view>
<view class="summary">最近一局运行对象:{{recentRuntimeText}}</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleRefresh">刷新首页</button>
<button class="btn btn--ghost" bindtap="handleOpenEventList">活动列表</button>
<button class="btn btn--ghost" bindtap="handleOpenExperienceMaps">地图体验</button>
<button class="btn btn--ghost" bindtap="handleOpenRecentResult">查看结果</button>
<button class="btn btn--ghost" bindtap="handleLogout">退出登录</button>
</view>
</view>
<view wx:if="{{showOngoingPanel}}" class="panel">
<view class="panel__title">进行中的游戏</view>
<view class="summary">{{ongoingSessionText}}</view>
<view class="summary">{{ongoingRuntimeText}}</view>
<view class="summary">{{ongoingActionHintText}}</view>
<view class="actions">
<button class="btn btn--secondary" bindtap="handleResumeOngoing" disabled="{{!canRecoverOngoing}}">恢复</button>
<button class="btn btn--ghost" bindtap="handleAbandonOngoing" disabled="{{!canAbandonOngoing}}">放弃</button>
</view>
</view>
<view class="panel">
<view class="panel__title">活动入口</view>
<view wx:if="{{!cards.length}}" class="summary">当前没有首页卡片</view>

View File

@@ -1,16 +1,7 @@
import { finishSession } from '../../utils/backendApi'
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
import { clearSessionRecoverySnapshot, loadSessionRecoverySnapshot } from '../../game/core/sessionRecovery'
import { getBackendSessionContextFromLaunchEnvelope, prepareMapPageUrlForRecovery } from '../../utils/gameLaunch'
import { loadBackendAuthTokens } from '../../utils/backendAuth'
Page({
onLoad() {
const recoverySnapshot = loadSessionRecoverySnapshot()
if (recoverySnapshot) {
this.promptRecoveryAtEntry()
return
}
this.redirectToDefaultEntry()
},
@@ -21,59 +12,4 @@ Page({
: '/pages/login/login'
wx.redirectTo({ url })
},
promptRecoveryAtEntry() {
const recoverySnapshot = loadSessionRecoverySnapshot()
if (!recoverySnapshot) {
this.redirectToDefaultEntry()
return
}
wx.showModal({
title: '恢复对局',
content: '检测到上次有未正常结束的对局,是否继续恢复?',
confirmText: '继续恢复',
cancelText: '放弃',
success: (result) => {
if (result.confirm) {
wx.redirectTo({
url: prepareMapPageUrlForRecovery(recoverySnapshot.launchEnvelope),
})
return
}
const sessionContext = getBackendSessionContextFromLaunchEnvelope(recoverySnapshot.launchEnvelope)
if (!sessionContext) {
clearSessionRecoverySnapshot()
wx.showToast({
title: '已放弃上次对局',
icon: 'none',
duration: 1400,
})
this.redirectToDefaultEntry()
return
}
finishSession({
baseUrl: loadBackendBaseUrl(),
sessionId: sessionContext.sessionId,
sessionToken: sessionContext.sessionToken,
status: 'cancelled',
summary: {},
})
.catch(() => {
// 放弃恢复不阻塞进入业务页;失败只丢给后续状态页处理。
})
.finally(() => {
clearSessionRecoverySnapshot()
wx.showToast({
title: '已放弃上次对局',
icon: 'none',
duration: 1400,
})
this.redirectToDefaultEntry()
})
},
})
},
})

View File

@@ -126,4 +126,21 @@ Page({
statusText: '已清空登录态',
})
},
handleContinueAsGuest() {
const baseUrl = this.persistBaseUrl()
clearBackendAuthTokens()
setGlobalMockDebugBridgeEnabled(false)
const app = getApp<IAppOption>()
if (app.globalData) {
app.globalData.backendBaseUrl = baseUrl
app.globalData.backendAuthTokens = null
}
this.setData({
statusText: '已切换到游客模式,准备进入地图体验',
})
wx.redirectTo({
url: '/pages/experience-maps/experience-maps',
})
},
})

View File

@@ -23,6 +23,7 @@
<view class="actions">
<button class="btn btn--primary" bindtap="handleLoginWithDevCode">开发码登录</button>
<button class="btn btn--secondary" bindtap="handleLoginWithWechat">wx.login 登录</button>
<button class="btn btn--secondary" bindtap="handleContinueAsGuest">游客体验</button>
<button class="btn btn--ghost" bindtap="handleClearLoginState">清空登录态</button>
</view>
</view>

View File

@@ -863,26 +863,6 @@ function buildLaunchConfigSummaryRows(envelope: GameLaunchEnvelope): MapEngineGa
return rows
}
function emitSimulatorLaunchDiagnostic(
stage: string,
payload: Record<string, unknown>,
) {
reportBackendClientLog({
level: 'info',
category: 'launch-diagnostic',
message: stage,
eventId: typeof payload.launchEventId === 'string' ? payload.launchEventId : '',
releaseId: typeof payload.configReleaseId === 'string'
? payload.configReleaseId
: (typeof payload.resolvedReleaseId === 'string' ? payload.resolvedReleaseId : ''),
sessionId: typeof payload.launchSessionId === 'string' ? payload.launchSessionId : '',
manifestUrl: typeof payload.resolvedManifestUrl === 'string'
? payload.resolvedManifestUrl
: (typeof payload.configUrl === 'string' ? payload.configUrl : ''),
details: payload,
})
}
Page({
data: {
showDebugPanel: false,
@@ -1584,21 +1564,6 @@ Page({
},
loadGameLaunchEnvelope(envelope: GameLaunchEnvelope) {
emitSimulatorLaunchDiagnostic('loadGameLaunchEnvelope', {
launchEventId: envelope.business && envelope.business.eventId ? envelope.business.eventId : '',
launchSessionId: envelope.business && envelope.business.sessionId ? envelope.business.sessionId : '',
configUrl: envelope.config.configUrl || '',
configReleaseId: envelope.config.releaseId || '',
resolvedManifestUrl: envelope.resolvedRelease && envelope.resolvedRelease.manifestUrl
? envelope.resolvedRelease.manifestUrl
: '',
resolvedReleaseId: envelope.resolvedRelease && envelope.resolvedRelease.releaseId
? envelope.resolvedRelease.releaseId
: '',
launchVariantId: envelope.variant && envelope.variant.variantId ? envelope.variant.variantId : null,
launchVariantRouteCode: envelope.variant && envelope.variant.routeCode ? envelope.variant.routeCode : null,
runtimeCourseVariantId: envelope.runtime && envelope.runtime.courseVariantId ? envelope.runtime.courseVariantId : null,
})
this.loadMapConfigFromRemote(
envelope.config.configUrl,
envelope.config.configLabel,
@@ -2186,18 +2151,6 @@ Page({
return
}
emitSimulatorLaunchDiagnostic('loadRemoteMapConfig:resolved', {
launchEventId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.eventId
? currentGameLaunchEnvelope.business.eventId
: '',
configUrl,
configVersion: config.configVersion || '',
schemaVersion: config.configSchemaVersion || '',
playfieldKind: config.playfieldKind || '',
gameMode: config.gameMode || '',
configTitle: config.configTitle || '',
})
currentEngine.applyRemoteMapConfig(config)
this.applyConfiguredSystemSettings(config)
const compiledProfile = this.applyCompiledRuntimeProfiles(true, {
@@ -2248,14 +2201,6 @@ Page({
return
}
emitSimulatorLaunchDiagnostic('loadRemoteMapConfig:error', {
launchEventId: currentGameLaunchEnvelope.business && currentGameLaunchEnvelope.business.eventId
? currentGameLaunchEnvelope.business.eventId
: '',
configUrl,
message: error && error.message ? error.message : '未知错误',
})
const rawErrorMessage = error && error.message ? error.message : '未知错误'
const errorMessage = rawErrorMessage.indexOf('404') >= 0
? `release manifest 不存在或未发布 (${configLabel})`

View File

@@ -5,9 +5,13 @@ import type { GameLaunchEnvelope } from '../../utils/gameLaunch'
type ResultPageData = {
sessionId: string
eventId: string
guestMode: boolean
statusText: string
sessionTitleText: string
sessionSubtitleText: string
activitySummaryText: string
listButtonText: string
rows: Array<{ label: string; value: string }>
}
@@ -95,9 +99,13 @@ function loadPendingResultLaunchEnvelope(): GameLaunchEnvelope | null {
Page({
data: {
sessionId: '',
eventId: '',
guestMode: false,
statusText: '准备加载结果',
sessionTitleText: '结果页',
sessionSubtitleText: '未加载',
activitySummaryText: '你可以查看本局结果,也可以回到活动继续查看详情。',
listButtonText: '查看历史结果',
rows: [],
} as ResultPageData,
@@ -131,6 +139,16 @@ Page({
statusText: '正在加载结果',
sessionTitleText: snapshot.title,
sessionSubtitleText: snapshot.subtitle,
guestMode: !getAccessToken(),
eventId: pendingLaunchEnvelope && pendingLaunchEnvelope.business && pendingLaunchEnvelope.business.eventId
? pendingLaunchEnvelope.business.eventId
: '',
activitySummaryText: pendingLaunchEnvelope && pendingLaunchEnvelope.business && pendingLaunchEnvelope.business.eventId
? (!getAccessToken()
? '本局游客体验已结束,你可以回到活动继续查看,或返回地图体验。'
: '本局结果已生成,你可以继续查看详情,或回到活动页。')
: (!getAccessToken() ? '本局游客体验已结束,你可以返回地图体验。' : '本局结果已生成,你可以继续查看历史结果。'),
listButtonText: getAccessToken() ? '查看历史结果' : '返回地图体验',
rows: appendRuntimeRows([
{ label: snapshot.heroLabel, value: snapshot.heroValue },
...snapshot.rows.map((row) => ({
@@ -152,7 +170,11 @@ Page({
async loadSingleResult(sessionId: string) {
const accessToken = getAccessToken()
if (!accessToken) {
wx.redirectTo({ url: '/pages/login/login' })
this.setData({
guestMode: true,
statusText: '游客模式当前不加载后端单局结果,先展示本地结果摘要',
listButtonText: '返回地图体验',
})
return
}
@@ -169,8 +191,12 @@ Page({
const pendingLaunchEnvelope = loadPendingResultLaunchEnvelope()
this.setData({
statusText: '单局结果加载完成',
eventId: result.session.eventId || '',
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} / ${formatRouteSummary(result.session)}`,
activitySummaryText: result.session.eventId
? '你可以继续查看这场活动的详情,或回看历史结果。'
: '你可以继续回看历史结果。',
rows: appendRuntimeRows([
{ label: '赛道版本', value: formatRouteSummary(result.session) },
{ label: '最终得分', value: formatValue(result.result.finalScore) },
@@ -199,8 +225,27 @@ Page({
},
handleBackToList() {
if (this.data.guestMode) {
wx.redirectTo({
url: '/pages/experience-maps/experience-maps',
})
return
}
wx.redirectTo({
url: '/pages/results/results',
})
},
handleBackToEvent() {
if (!this.data.eventId) {
wx.showToast({
title: '当前结果未关联活动',
icon: 'none',
})
return
}
wx.redirectTo({
url: `/pages/event/event?eventId=${encodeURIComponent(this.data.eventId)}`,
})
},
})

View File

@@ -9,7 +9,11 @@
<view class="panel">
<view class="panel__title">当前状态</view>
<view class="summary">{{statusText}}</view>
<button class="btn btn--ghost" bindtap="handleBackToList">查看历史结果</button>
<view class="summary">{{activitySummaryText}}</view>
<view class="actions">
<button class="btn btn--secondary" wx:if="{{eventId}}" bindtap="handleBackToEvent">返回活动</button>
<button class="btn btn--ghost" bindtap="handleBackToList">{{listButtonText}}</button>
</view>
</view>
<view wx:if="{{rows.length}}" class="panel">

View File

@@ -6,6 +6,7 @@ type ResultsPageData = {
statusText: string
results: Array<{
sessionId: string
eventId: string
titleText: string
statusText: string
scoreText: string
@@ -51,6 +52,7 @@ function formatRuntimeSummary(result: BackendSessionResultView): string {
function buildResultCardView(result: BackendSessionResultView) {
return {
sessionId: result.session.id,
eventId: result.session.eventId || '',
titleText: result.session.eventName || result.session.id,
statusText: `${result.result.status} / ${result.session.status}`,
scoreText: `得分 ${result.result.finalScore || '--'} / 用时 ${result.result.finalDurationSec || '--'}s`,
@@ -115,4 +117,18 @@ Page({
url: `/pages/result/result?sessionId=${encodeURIComponent(sessionId)}`,
})
},
handleOpenEvent(event: WechatMiniprogram.TouchEvent) {
const eventId = event.currentTarget.dataset.eventId as string | undefined
if (!eventId) {
wx.showToast({
title: '当前结果未关联活动',
icon: 'none',
})
return
}
wx.navigateTo({
url: `/pages/event/event?eventId=${encodeURIComponent(eventId)}`,
})
},
})

View File

@@ -9,6 +9,7 @@
<view class="panel">
<view class="panel__title">当前状态</view>
<view class="summary">{{statusText}}</view>
<view class="summary">你可以回看最近完成的对局,也可以回到对应活动继续查看详情。</view>
</view>
<view class="panel">
@@ -20,6 +21,10 @@
<view class="result-card__meta">{{item.scoreText}}</view>
<view class="result-card__meta">{{item.routeText}}</view>
<view class="result-card__meta">{{item.runtimeText}}</view>
<view class="actions">
<button class="btn btn--secondary" data-session-id="{{item.sessionId}}" catchtap="handleOpenResult">查看单局结果</button>
<button class="btn btn--ghost" wx:if="{{item.eventId}}" data-event-id="{{item.eventId}}" catchtap="handleOpenEvent">返回活动</button>
</view>
</view>
</view>
</view>