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

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

@@ -4,6 +4,8 @@
"pages/login/login",
"pages/home/home",
"pages/events/events",
"pages/experience-maps/experience-maps",
"pages/experience-map/experience-map",
"pages/event/event",
"pages/event-prepare/event-prepare",
"pages/result/result",

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>

View File

@@ -63,12 +63,92 @@ export interface BackendPresentationSummary {
version?: string | null
}
export interface BackendPreviewControlSummary {
id?: string | null
label?: string | null
kind?: string | null
lon?: number | null
lat?: number | null
}
export interface BackendPreviewLegSummary {
fromLon?: number | null
fromLat?: number | null
toLon?: number | null
toLat?: number | null
}
export interface BackendPreviewVariantSummary {
variantId?: string | null
id?: string | null
name?: string | null
routeCode?: string | null
controls?: BackendPreviewControlSummary[] | null
legs?: BackendPreviewLegSummary[] | null
}
export interface BackendPreviewSummary {
mode?: string | null
baseTiles?: {
tileBaseUrl?: string | null
zoom?: number | null
tileSize?: number | null
} | null
viewport?: {
width?: number | null
height?: number | null
minLon?: number | null
minLat?: number | null
maxLon?: number | null
maxLat?: number | null
} | null
variants?: BackendPreviewVariantSummary[] | null
selectedVariantId?: string | null
}
export interface BackendContentBundleSummary {
bundleId?: string | null
bundleType?: string | null
version?: string | null
}
export interface BackendExperienceMapSummary {
placeId?: string | null
placeName?: string | null
mapId?: string | null
mapName?: string | null
coverUrl?: string | null
summary?: string | null
defaultExperienceCount?: number | null
defaultExperienceEventIds?: string[] | null
}
export interface BackendDefaultExperienceSummary {
eventId?: string | null
title?: string | null
subtitle?: string | null
eventType?: string | null
status?: string | null
statusCode?: string | null
ctaText?: string | null
isDefaultExperience?: boolean
showInEventList?: boolean
currentPresentation?: BackendPresentationSummary | null
currentContentBundle?: BackendContentBundleSummary | null
}
export interface BackendExperienceMapDetail {
placeId?: string | null
placeName?: string | null
mapId?: string | null
mapName?: string | null
coverUrl?: string | null
summary?: string | null
tileBaseUrl?: string | null
tileMetaUrl?: string | null
defaultExperiences?: BackendDefaultExperienceSummary[] | null
}
export interface BackendEntrySessionSummary {
id: string
status: string
@@ -151,6 +231,7 @@ export interface BackendEventPlayResult {
}
currentPresentation?: BackendPresentationSummary | null
currentContentBundle?: BackendContentBundleSummary | null
preview?: BackendPreviewSummary | null
release?: {
id: string
configLabel: string
@@ -188,6 +269,7 @@ export interface BackendLaunchResult {
}
business: {
source: string
isGuest?: boolean
eventId: string
sessionId: string
sessionToken: string
@@ -348,6 +430,17 @@ export function getEventPlay(input: {
})
}
export function getPublicEventPlay(input: {
baseUrl: string
eventId: string
}): Promise<BackendEventPlayResult> {
return requestBackend<BackendEventPlayResult>({
method: 'GET',
baseUrl: input.baseUrl,
path: `/public/events/${encodeURIComponent(input.eventId)}/play`,
})
}
export function getEntryHome(input: {
baseUrl: string
accessToken: string
@@ -391,6 +484,32 @@ export function launchEvent(input: {
})
}
export function launchPublicEvent(input: {
baseUrl: string
eventId: string
releaseId?: string
variantId?: string
clientType: string
deviceKey: string
}): Promise<BackendLaunchResult> {
const body: Record<string, unknown> = {
clientType: input.clientType,
deviceKey: input.deviceKey,
}
if (input.releaseId) {
body.releaseId = input.releaseId
}
if (input.variantId) {
body.variantId = input.variantId
}
return requestBackend<BackendLaunchResult>({
method: 'POST',
baseUrl: input.baseUrl,
path: `/public/events/${encodeURIComponent(input.eventId)}/launch`,
body,
})
}
export function startSession(input: {
baseUrl: string
sessionId: string
@@ -463,3 +582,49 @@ export function postClientLog(input: {
body: input.payload as unknown as Record<string, unknown>,
})
}
export function getExperienceMaps(input: {
baseUrl: string
accessToken: string
}): Promise<BackendExperienceMapSummary[]> {
return requestBackend<BackendExperienceMapSummary[]>({
method: 'GET',
baseUrl: input.baseUrl,
path: '/experience-maps',
authToken: input.accessToken,
})
}
export function getPublicExperienceMaps(input: {
baseUrl: string
}): Promise<BackendExperienceMapSummary[]> {
return requestBackend<BackendExperienceMapSummary[]>({
method: 'GET',
baseUrl: input.baseUrl,
path: '/public/experience-maps',
})
}
export function getExperienceMapDetail(input: {
baseUrl: string
accessToken: string
mapAssetId: string
}): Promise<BackendExperienceMapDetail> {
return requestBackend<BackendExperienceMapDetail>({
method: 'GET',
baseUrl: input.baseUrl,
path: `/experience-maps/${encodeURIComponent(input.mapAssetId)}`,
authToken: input.accessToken,
})
}
export function getPublicExperienceMapDetail(input: {
baseUrl: string
mapAssetId: string
}): Promise<BackendExperienceMapDetail> {
return requestBackend<BackendExperienceMapDetail>({
method: 'GET',
baseUrl: input.baseUrl,
path: `/public/experience-maps/${encodeURIComponent(input.mapAssetId)}`,
})
}

View File

@@ -0,0 +1,482 @@
import { type BackendPreviewSummary } from './backendApi'
import { lonLatToWorldTile, type LonLatPoint } from './projection'
import { isTileWithinBounds, type RemoteMapConfig } from './remoteMapConfig'
import { buildTileUrl } from './tile'
export interface PreparePreviewTile {
url: string
x: number
y: number
leftPx: number
topPx: number
sizePx: number
}
export interface PreparePreviewControl {
kind: 'start' | 'control' | 'finish'
label: string
x: number
y: number
}
export interface PreparePreviewLeg {
fromX: number
fromY: number
toX: number
toY: number
}
export interface PreparePreviewScene {
width: number
height: number
zoom: number
tiles: PreparePreviewTile[]
controls: PreparePreviewControl[]
legs: PreparePreviewLeg[]
overlayAvailable: boolean
}
interface PreviewPointSeed {
kind: 'start' | 'control' | 'finish'
label: string
point: LonLatPoint
}
function resolvePreviewTileTemplate(tileBaseUrl: string): string {
if (tileBaseUrl.indexOf('{z}') >= 0 && tileBaseUrl.indexOf('{x}') >= 0 && tileBaseUrl.indexOf('{y}') >= 0) {
return tileBaseUrl
}
const normalizedBase = tileBaseUrl.replace(/\/+$/, '')
return `${normalizedBase}/{z}/{x}/{y}.png`
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
function collectCoursePoints(config: RemoteMapConfig): LonLatPoint[] {
if (!config.course) {
return []
}
const points: LonLatPoint[] = []
config.course.layers.starts.forEach((item) => {
points.push(item.point)
})
config.course.layers.controls.forEach((item) => {
points.push(item.point)
})
config.course.layers.finishes.forEach((item) => {
points.push(item.point)
})
return points
}
function collectPreviewPointSeeds(items: Array<{
kind?: string | null
label?: string | null
lon?: number | null
lat?: number | null
}>): PreviewPointSeed[] {
const seeds: PreviewPointSeed[] = []
items.forEach((item, index) => {
if (typeof item.lon !== 'number' || typeof item.lat !== 'number') {
return
}
const kind = item.kind === 'start' || item.kind === 'finish' ? item.kind : 'control'
seeds.push({
kind,
label: item.label || String(index + 1),
point: {
lon: item.lon,
lat: item.lat,
},
})
})
return seeds
}
function computePointBounds(points: LonLatPoint[]): { minLon: number; minLat: number; maxLon: number; maxLat: number } | null {
if (!points.length) {
return null
}
let minLon = points[0].lon
let maxLon = points[0].lon
let minLat = points[0].lat
let maxLat = points[0].lat
points.forEach((point) => {
minLon = Math.min(minLon, point.lon)
maxLon = Math.max(maxLon, point.lon)
minLat = Math.min(minLat, point.lat)
maxLat = Math.max(maxLat, point.lat)
})
return {
minLon,
minLat,
maxLon,
maxLat,
}
}
function resolvePreviewZoom(config: RemoteMapConfig, width: number, height: number, points: LonLatPoint[]): number {
const upperZoom = clamp(config.defaultZoom > 0 ? config.defaultZoom : config.maxZoom, config.minZoom, config.maxZoom)
if (!points.length) {
return clamp(upperZoom - 1, config.minZoom, config.maxZoom)
}
const bounds = computePointBounds(points)
if (!bounds) {
return clamp(upperZoom - 1, config.minZoom, config.maxZoom)
}
let fittedZoom = config.minZoom
for (let zoom = upperZoom; zoom >= config.minZoom; zoom -= 1) {
const northWest = lonLatToWorldTile({ lon: bounds.minLon, lat: bounds.maxLat }, zoom)
const southEast = lonLatToWorldTile({ lon: bounds.maxLon, lat: bounds.minLat }, zoom)
const widthPx = Math.abs(southEast.x - northWest.x) * config.tileSize
const heightPx = Math.abs(southEast.y - northWest.y) * config.tileSize
if (widthPx <= width * 0.9 && heightPx <= height * 0.9) {
fittedZoom = zoom
break
}
}
return clamp(fittedZoom, config.minZoom, config.maxZoom)
}
function resolvePreviewCenter(config: RemoteMapConfig, zoom: number, points: LonLatPoint[]): { x: number; y: number } {
const bounds = computePointBounds(points)
if (bounds) {
const center = lonLatToWorldTile(
{
lon: (bounds.minLon + bounds.maxLon) / 2,
lat: (bounds.minLat + bounds.maxLat) / 2,
},
zoom,
)
return {
x: center.x,
y: center.y,
}
}
return {
x: config.initialCenterTileX,
y: config.initialCenterTileY,
}
}
function buildPreviewTiles(
config: RemoteMapConfig,
zoom: number,
width: number,
height: number,
centerWorldX: number,
centerWorldY: number,
): PreparePreviewTile[] {
const halfWidthInTiles = width / 2 / config.tileSize
const halfHeightInTiles = height / 2 / config.tileSize
const minTileX = Math.floor(centerWorldX - halfWidthInTiles) - 1
const maxTileX = Math.ceil(centerWorldX + halfWidthInTiles) + 1
const minTileY = Math.floor(centerWorldY - halfHeightInTiles) - 1
const maxTileY = Math.ceil(centerWorldY + halfHeightInTiles) + 1
const tiles: PreparePreviewTile[] = []
for (let tileY = minTileY; tileY <= maxTileY; tileY += 1) {
for (let tileX = minTileX; tileX <= maxTileX; tileX += 1) {
if (!isTileWithinBounds(config.tileBoundsByZoom, zoom, tileX, tileY)) {
continue
}
tiles.push({
url: buildTileUrl(config.tileSource, zoom, tileX, tileY),
x: tileX,
y: tileY,
leftPx: Math.round(width / 2 + (tileX - centerWorldX) * config.tileSize),
topPx: Math.round(height / 2 + (tileY - centerWorldY) * config.tileSize),
sizePx: config.tileSize,
})
}
}
return tiles
}
function applyFitTransform(
scene: PreparePreviewScene,
paddingRatio: number,
): PreparePreviewScene {
if (!scene.controls.length) {
return scene
}
let minX = scene.controls[0].x
let maxX = scene.controls[0].x
let minY = scene.controls[0].y
let maxY = scene.controls[0].y
scene.controls.forEach((control) => {
minX = Math.min(minX, control.x)
maxX = Math.max(maxX, control.x)
minY = Math.min(minY, control.y)
maxY = Math.max(maxY, control.y)
})
const boundsWidth = Math.max(1, maxX - minX)
const boundsHeight = Math.max(1, maxY - minY)
const targetWidth = scene.width * paddingRatio
const targetHeight = scene.height * paddingRatio
const scale = Math.max(1, Math.min(targetWidth / boundsWidth, targetHeight / boundsHeight))
const centerX = (minX + maxX) / 2
const centerY = (minY + maxY) / 2
const transformX = (value: number) => ((value - centerX) * scale) + scene.width / 2
const transformY = (value: number) => ((value - centerY) * scale) + scene.height / 2
return {
...scene,
tiles: scene.tiles.map((tile) => ({
...tile,
leftPx: transformX(tile.leftPx),
topPx: transformY(tile.topPx),
sizePx: tile.sizePx * scale,
})),
controls: scene.controls.map((control) => ({
...control,
x: transformX(control.x),
y: transformY(control.y),
})),
legs: scene.legs.map((leg) => ({
fromX: transformX(leg.fromX),
fromY: transformY(leg.fromY),
toX: transformX(leg.toX),
toY: transformY(leg.toY),
})),
}
}
export function buildPreparePreviewScene(
config: RemoteMapConfig,
width: number,
height: number,
overlayEnabled: boolean,
): PreparePreviewScene {
const normalizedWidth = Math.max(240, Math.round(width))
const normalizedHeight = Math.max(140, Math.round(height))
const points = collectCoursePoints(config)
const zoom = resolvePreviewZoom(config, normalizedWidth, normalizedHeight, points)
const center = resolvePreviewCenter(config, zoom, points)
const tiles = buildPreviewTiles(config, zoom, normalizedWidth, normalizedHeight, center.x, center.y)
const controls: PreparePreviewControl[] = []
const legs: PreparePreviewLeg[] = []
if (overlayEnabled && config.course) {
const projectPoint = (point: LonLatPoint) => {
const world = lonLatToWorldTile(point, zoom)
return {
x: normalizedWidth / 2 + (world.x - center.x) * config.tileSize,
y: normalizedHeight / 2 + (world.y - center.y) * config.tileSize,
}
}
config.course.layers.legs.forEach((leg) => {
const from = projectPoint(leg.fromPoint)
const to = projectPoint(leg.toPoint)
legs.push({
fromX: from.x,
fromY: from.y,
toX: to.x,
toY: to.y,
})
})
config.course.layers.starts.forEach((item) => {
const point = projectPoint(item.point)
controls.push({
kind: 'start',
label: item.label,
x: point.x,
y: point.y,
})
})
config.course.layers.controls.forEach((item) => {
const point = projectPoint(item.point)
controls.push({
kind: 'control',
label: item.label,
x: point.x,
y: point.y,
})
})
config.course.layers.finishes.forEach((item) => {
const point = projectPoint(item.point)
controls.push({
kind: 'finish',
label: item.label,
x: point.x,
y: point.y,
})
})
}
const baseScene: PreparePreviewScene = {
width: normalizedWidth,
height: normalizedHeight,
zoom,
tiles,
controls,
legs,
overlayAvailable: overlayEnabled && !!config.course,
}
return applyFitTransform(baseScene, 0.88)
}
export function buildPreparePreviewSceneFromVariantControls(
config: RemoteMapConfig,
width: number,
height: number,
controlsInput: Array<{
kind?: string | null
label?: string | null
lon?: number | null
lat?: number | null
}>,
): PreparePreviewScene | null {
const seeds = collectPreviewPointSeeds(controlsInput)
if (!seeds.length) {
return null
}
const normalizedWidth = Math.max(240, Math.round(width))
const normalizedHeight = Math.max(140, Math.round(height))
const points = seeds.map((item) => item.point)
const zoom = resolvePreviewZoom(config, normalizedWidth, normalizedHeight, points)
const center = resolvePreviewCenter(config, zoom, points)
const tiles = buildPreviewTiles(config, zoom, normalizedWidth, normalizedHeight, center.x, center.y)
const controls: PreparePreviewControl[] = seeds.map((item) => {
const world = lonLatToWorldTile(item.point, zoom)
return {
kind: item.kind,
label: item.label,
x: normalizedWidth / 2 + (world.x - center.x) * config.tileSize,
y: normalizedHeight / 2 + (world.y - center.y) * config.tileSize,
}
})
const scene: PreparePreviewScene = {
width: normalizedWidth,
height: normalizedHeight,
zoom,
tiles,
controls,
legs: [],
overlayAvailable: true,
}
return applyFitTransform(scene, 0.88)
}
export function buildPreparePreviewSceneFromBackendPreview(
preview: BackendPreviewSummary,
width: number,
height: number,
variantId?: string | null,
tileUrlTemplateOverride?: string | null,
): PreparePreviewScene | null {
if (!preview.baseTiles || !preview.viewport || !preview.baseTiles.tileBaseUrl || typeof preview.baseTiles.zoom !== 'number') {
return null
}
const viewport = preview.viewport
if (
typeof viewport.minLon !== 'number'
|| typeof viewport.minLat !== 'number'
|| typeof viewport.maxLon !== 'number'
|| typeof viewport.maxLat !== 'number'
) {
return null
}
const normalizedWidth = Math.max(240, Math.round(width))
const normalizedHeight = Math.max(140, Math.round(height))
const zoom = Math.round(preview.baseTiles.zoom)
const tileSize = typeof preview.baseTiles.tileSize === 'number' && preview.baseTiles.tileSize > 0
? preview.baseTiles.tileSize
: 256
const template = resolvePreviewTileTemplate(tileUrlTemplateOverride || preview.baseTiles.tileBaseUrl)
const center = lonLatToWorldTile(
{
lon: (viewport.minLon + viewport.maxLon) / 2,
lat: (viewport.minLat + viewport.maxLat) / 2,
},
zoom,
)
const northWest = lonLatToWorldTile({ lon: viewport.minLon, lat: viewport.maxLat }, zoom)
const southEast = lonLatToWorldTile({ lon: viewport.maxLon, lat: viewport.minLat }, zoom)
const boundsWidthPx = Math.max(1, Math.abs(southEast.x - northWest.x) * tileSize)
const boundsHeightPx = Math.max(1, Math.abs(southEast.y - northWest.y) * tileSize)
const scale = Math.min(normalizedWidth / boundsWidthPx, normalizedHeight / boundsHeightPx)
const minTileX = Math.floor(Math.min(northWest.x, southEast.x)) - 1
const maxTileX = Math.ceil(Math.max(northWest.x, southEast.x)) + 1
const minTileY = Math.floor(Math.min(northWest.y, southEast.y)) - 1
const maxTileY = Math.ceil(Math.max(northWest.y, southEast.y)) + 1
const tiles: PreparePreviewTile[] = []
for (let tileY = minTileY; tileY <= maxTileY; tileY += 1) {
for (let tileX = minTileX; tileX <= maxTileX; tileX += 1) {
const leftPx = ((tileX - center.x) * tileSize * scale) + normalizedWidth / 2
const topPx = ((tileY - center.y) * tileSize * scale) + normalizedHeight / 2
tiles.push({
url: buildTileUrl(template, zoom, tileX, tileY),
x: tileX,
y: tileY,
leftPx,
topPx,
sizePx: tileSize * scale,
})
}
}
const normalizedVariantId = variantId || preview.selectedVariantId || ''
const previewVariant = (preview.variants || []).find((item) => {
const candidateId = item.variantId || item.id || ''
return candidateId === normalizedVariantId
}) || (preview.variants && preview.variants[0] ? preview.variants[0] : null)
const controls: PreparePreviewControl[] = []
if (previewVariant && previewVariant.controls && previewVariant.controls.length) {
previewVariant.controls.forEach((item, index) => {
if (typeof item.lon !== 'number' || typeof item.lat !== 'number') {
return
}
const world = lonLatToWorldTile({ lon: item.lon, lat: item.lat }, zoom)
const x = ((world.x - center.x) * tileSize * scale) + normalizedWidth / 2
const y = ((world.y - center.y) * tileSize * scale) + normalizedHeight / 2
const normalizedKind = item.kind === 'start' || item.kind === 'finish' ? item.kind : 'control'
controls.push({
kind: normalizedKind,
label: item.label || String(index + 1),
x,
y,
})
})
}
return {
width: normalizedWidth,
height: normalizedHeight,
zoom,
tiles,
controls,
legs: [],
overlayAvailable: controls.length > 0,
}
}