推进活动系统最小成品闭环与游客体验
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
195
miniprogram/pages/experience-map/experience-map.ts
Normal file
195
miniprogram/pages/experience-map/experience-map.ts
Normal 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)}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
42
miniprogram/pages/experience-map/experience-map.wxml
Normal file
42
miniprogram/pages/experience-map/experience-map.wxml
Normal 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>
|
||||
153
miniprogram/pages/experience-map/experience-map.wxss
Normal file
153
miniprogram/pages/experience-map/experience-map.wxss
Normal 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;
|
||||
}
|
||||
131
miniprogram/pages/experience-maps/experience-maps.ts
Normal file
131
miniprogram/pages/experience-maps/experience-maps.ts
Normal 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)}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
29
miniprogram/pages/experience-maps/experience-maps.wxml
Normal file
29
miniprogram/pages/experience-maps/experience-maps.wxml
Normal 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>
|
||||
129
miniprogram/pages/experience-maps/experience-maps.wxss
Normal file
129
miniprogram/pages/experience-maps/experience-maps.wxss
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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})`
|
||||
|
||||
@@ -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)}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}`,
|
||||
})
|
||||
}
|
||||
|
||||
482
miniprogram/utils/prepareMapPreview.ts
Normal file
482
miniprogram/utils/prepareMapPreview.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user