完善后端联调链路与模拟器多通道支持
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/index/index",
|
||||
"pages/login/login",
|
||||
"pages/home/home",
|
||||
"pages/event/event",
|
||||
"pages/result/result",
|
||||
"pages/map/map",
|
||||
"pages/experience-webview/experience-webview",
|
||||
"pages/webview-test/webview-test",
|
||||
"pages/index/index",
|
||||
"pages/logs/logs"
|
||||
],
|
||||
"window": {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { loadBackendAuthTokens, loadBackendBaseUrl } from './utils/backendAuth'
|
||||
|
||||
// app.ts
|
||||
App<IAppOption>({
|
||||
globalData: {
|
||||
telemetryPlayerProfile: null,
|
||||
backendBaseUrl: null,
|
||||
backendAuthTokens: null,
|
||||
},
|
||||
onLaunch() {
|
||||
this.globalData.backendBaseUrl = loadBackendBaseUrl()
|
||||
this.globalData.backendAuthTokens = loadBackendAuthTokens()
|
||||
|
||||
// 展示本地存储能力
|
||||
const logs = wx.getStorageSync('logs') || []
|
||||
logs.unshift(Date.now())
|
||||
|
||||
@@ -387,6 +387,16 @@ export interface MapEngineGameInfoSnapshot {
|
||||
|
||||
export type MapEngineResultSnapshot = ResultSummarySnapshot
|
||||
|
||||
export interface MapEngineSessionFinishSummary {
|
||||
status: 'finished' | 'failed' | 'cancelled'
|
||||
finalDurationSec?: number
|
||||
finalScore?: number
|
||||
completedControls?: number
|
||||
totalControls?: number
|
||||
distanceMeters?: number
|
||||
averageSpeedKmh?: number
|
||||
}
|
||||
|
||||
const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
||||
'animationLevel',
|
||||
'buildVersion',
|
||||
@@ -1774,6 +1784,41 @@ export class MapEngine {
|
||||
)
|
||||
}
|
||||
|
||||
getSessionFinishSummary(statusOverride?: 'finished' | 'failed' | 'cancelled'): MapEngineSessionFinishSummary | null {
|
||||
const definition = this.gameRuntime.definition
|
||||
const sessionState = this.gameRuntime.state
|
||||
if (!definition || !sessionState) {
|
||||
return null
|
||||
}
|
||||
|
||||
let status: 'finished' | 'failed' | 'cancelled'
|
||||
if (statusOverride) {
|
||||
status = statusOverride
|
||||
} else if (sessionState.endReason === 'timed_out' || sessionState.status === 'failed') {
|
||||
status = 'failed'
|
||||
} else {
|
||||
status = 'finished'
|
||||
}
|
||||
|
||||
const endAt = sessionState.endedAt !== null ? sessionState.endedAt : Date.now()
|
||||
const finalDurationSec = sessionState.startedAt !== null
|
||||
? Math.max(0, Math.floor((endAt - sessionState.startedAt) / 1000))
|
||||
: undefined
|
||||
const totalControls = definition.controls.filter((control) => control.kind === 'control').length
|
||||
|
||||
return {
|
||||
status,
|
||||
finalDurationSec,
|
||||
finalScore: this.getTotalSessionScore(),
|
||||
completedControls: sessionState.completedControlIds.length,
|
||||
totalControls,
|
||||
distanceMeters: this.telemetryRuntime.state.distanceMeters,
|
||||
averageSpeedKmh: this.telemetryRuntime.state.averageSpeedKmh === null
|
||||
? undefined
|
||||
: this.telemetryRuntime.state.averageSpeedKmh,
|
||||
}
|
||||
}
|
||||
|
||||
buildSessionRecoveryRuntimeSnapshot(): RecoveryRuntimeSnapshot | null {
|
||||
const definition = this.gameRuntime.definition
|
||||
const state = this.gameRuntime.state
|
||||
@@ -3577,7 +3622,14 @@ export class MapEngine {
|
||||
}
|
||||
|
||||
handleSetMockLocationMode(): void {
|
||||
const wasListening = this.locationController.listening
|
||||
if (!this.locationController.mockBridge.connected && !this.locationController.mockBridge.connecting) {
|
||||
this.locationController.connectMockBridge()
|
||||
}
|
||||
this.locationController.setSourceMode('mock')
|
||||
if (!wasListening && !this.locationController.listening) {
|
||||
this.locationController.start()
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectMockLocationBridge(): void {
|
||||
@@ -3594,9 +3646,26 @@ export class MapEngine {
|
||||
|
||||
handleSetMockChannelId(channelId: string): void {
|
||||
const normalized = String(channelId || '').trim() || 'default'
|
||||
const shouldReconnectLocation = this.locationController.mockBridge.connected || this.locationController.mockBridge.connecting
|
||||
const locationBridgeUrl = this.locationController.mockBridgeUrl
|
||||
const shouldReconnectHeartRate = this.heartRateController.mockBridge.connected || this.heartRateController.mockBridge.connecting
|
||||
const heartRateBridgeUrl = this.heartRateController.mockBridgeUrl
|
||||
const shouldReconnectDebugLog = this.mockSimulatorDebugLogger.enabled
|
||||
this.locationController.setMockChannelId(normalized)
|
||||
this.heartRateController.setMockChannelId(normalized)
|
||||
this.mockSimulatorDebugLogger.setChannelId(normalized)
|
||||
if (shouldReconnectLocation) {
|
||||
this.locationController.disconnectMockBridge()
|
||||
this.locationController.connectMockBridge(locationBridgeUrl)
|
||||
}
|
||||
if (shouldReconnectHeartRate) {
|
||||
this.heartRateController.disconnectMockBridge()
|
||||
this.heartRateController.connectMockBridge(heartRateBridgeUrl)
|
||||
}
|
||||
if (shouldReconnectDebugLog) {
|
||||
this.mockSimulatorDebugLogger.disconnect()
|
||||
this.mockSimulatorDebugLogger.connect()
|
||||
}
|
||||
this.setState({
|
||||
mockChannelIdText: normalized,
|
||||
})
|
||||
@@ -3663,7 +3732,14 @@ export class MapEngine {
|
||||
}
|
||||
|
||||
handleSetMockHeartRateMode(): void {
|
||||
const wasConnected = this.heartRateController.connected
|
||||
if (!this.heartRateController.mockBridge.connected && !this.heartRateController.mockBridge.connecting) {
|
||||
this.heartRateController.connectMockBridge()
|
||||
}
|
||||
this.heartRateController.setSourceMode('mock')
|
||||
if (!wasConnected && !this.heartRateController.connected) {
|
||||
this.heartRateController.startScanAndConnect()
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectMockHeartRateBridge(): void {
|
||||
|
||||
3
miniprogram/pages/event/event.json
Normal file
3
miniprogram/pages/event/event.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "活动"
|
||||
}
|
||||
123
miniprogram/pages/event/event.ts
Normal file
123
miniprogram/pages/event/event.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
|
||||
import { getEventPlay, launchEvent, type BackendEventPlayResult } from '../../utils/backendApi'
|
||||
import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
|
||||
import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
|
||||
|
||||
type EventPageData = {
|
||||
eventId: string
|
||||
loading: boolean
|
||||
titleText: string
|
||||
summaryText: string
|
||||
releaseText: string
|
||||
actionText: string
|
||||
statusText: string
|
||||
}
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
const app = getApp<IAppOption>()
|
||||
const tokens = app.globalData && app.globalData.backendAuthTokens
|
||||
? app.globalData.backendAuthTokens
|
||||
: loadBackendAuthTokens()
|
||||
return tokens && tokens.accessToken ? tokens.accessToken : null
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
eventId: '',
|
||||
loading: false,
|
||||
titleText: '活动详情',
|
||||
summaryText: '未加载',
|
||||
releaseText: '--',
|
||||
actionText: '--',
|
||||
statusText: '待加载',
|
||||
} as EventPageData,
|
||||
|
||||
onLoad(query: { eventId?: string }) {
|
||||
const eventId = query && query.eventId ? decodeURIComponent(query.eventId) : ''
|
||||
if (!eventId) {
|
||||
this.setData({
|
||||
statusText: '缺少 eventId',
|
||||
})
|
||||
return
|
||||
}
|
||||
this.setData({ eventId })
|
||||
this.loadEventPlay(eventId)
|
||||
},
|
||||
|
||||
async loadEventPlay(eventId?: string) {
|
||||
const targetEventId = eventId || this.data.eventId
|
||||
const accessToken = getAccessToken()
|
||||
if (!accessToken) {
|
||||
wx.redirectTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({
|
||||
loading: true,
|
||||
statusText: '正在加载活动上下文',
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getEventPlay({
|
||||
baseUrl: loadBackendBaseUrl(),
|
||||
eventId: targetEventId,
|
||||
accessToken,
|
||||
})
|
||||
this.applyEventPlay(result)
|
||||
} catch (error) {
|
||||
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
|
||||
this.setData({
|
||||
loading: false,
|
||||
statusText: `活动加载失败:${message}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
applyEventPlay(result: BackendEventPlayResult) {
|
||||
this.setData({
|
||||
loading: false,
|
||||
titleText: result.event.displayName,
|
||||
summaryText: result.event.summary || '暂无活动简介',
|
||||
releaseText: result.resolvedRelease
|
||||
? `${result.resolvedRelease.configLabel} / ${result.resolvedRelease.releaseId}`
|
||||
: '当前无可用 release',
|
||||
actionText: `${result.play.primaryAction} / ${result.play.reason}`,
|
||||
statusText: result.play.canLaunch ? '可启动' : '当前不可启动',
|
||||
})
|
||||
},
|
||||
|
||||
handleRefresh() {
|
||||
this.loadEventPlay()
|
||||
},
|
||||
|
||||
async handleLaunch() {
|
||||
const accessToken = getAccessToken()
|
||||
if (!accessToken) {
|
||||
wx.redirectTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({
|
||||
statusText: '正在创建 session 并进入地图',
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await launchEvent({
|
||||
baseUrl: loadBackendBaseUrl(),
|
||||
eventId: this.data.eventId,
|
||||
accessToken,
|
||||
clientType: 'wechat',
|
||||
deviceKey: 'mini-dev-device-001',
|
||||
})
|
||||
const envelope = adaptBackendLaunchResultToEnvelope(result)
|
||||
wx.navigateTo({
|
||||
url: prepareMapPageUrlForLaunch(envelope),
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
|
||||
this.setData({
|
||||
statusText: `launch 失败:${message}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
20
miniprogram/pages/event/event.wxml
Normal file
20
miniprogram/pages/event/event.wxml
Normal file
@@ -0,0 +1,20 @@
|
||||
<scroll-view class="page" scroll-y>
|
||||
<view class="shell">
|
||||
<view class="hero">
|
||||
<view class="hero__eyebrow">Event Play</view>
|
||||
<view class="hero__title">{{titleText}}</view>
|
||||
<view class="hero__desc">{{summaryText}}</view>
|
||||
</view>
|
||||
|
||||
<view class="panel">
|
||||
<view class="panel__title">开始前准备</view>
|
||||
<view class="summary">Release:{{releaseText}}</view>
|
||||
<view class="summary">主动作:{{actionText}}</view>
|
||||
<view class="summary">状态:{{statusText}}</view>
|
||||
<view class="actions">
|
||||
<button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
|
||||
<button class="btn btn--primary" bindtap="handleLaunch">开始比赛</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
91
miniprogram/pages/event/event.wxss
Normal file
91
miniprogram/pages/event/event.wxss
Normal file
@@ -0,0 +1,91 @@
|
||||
page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #eff4fb 0%, #e8eff7 100%);
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
gap: 24rpx;
|
||||
padding: 28rpx 24rpx 40rpx;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #163a66 0%, #1f5da1 100%);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.hero__eyebrow {
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero__desc {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 14rpx 32rpx rgba(40, 63, 95, 0.08);
|
||||
}
|
||||
|
||||
.panel__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #17345a;
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: #30465f;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin: 0;
|
||||
min-height: 76rpx;
|
||||
padding: 0 24rpx;
|
||||
line-height: 76rpx;
|
||||
border-radius: 18rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.btn::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: #173d73;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: #dfeaf8;
|
||||
color: #173d73;
|
||||
}
|
||||
3
miniprogram/pages/home/home.json
Normal file
3
miniprogram/pages/home/home.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "首页"
|
||||
}
|
||||
127
miniprogram/pages/home/home.ts
Normal file
127
miniprogram/pages/home/home.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { clearBackendAuthTokens, loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
|
||||
import { getEntryHome, type BackendCardResult, type BackendEntryHomeResult } from '../../utils/backendApi'
|
||||
|
||||
const DEFAULT_CHANNEL_CODE = 'mini-demo'
|
||||
const DEFAULT_CHANNEL_TYPE = 'wechat_mini'
|
||||
|
||||
type HomePageData = {
|
||||
loading: boolean
|
||||
statusText: string
|
||||
userNameText: string
|
||||
tenantText: string
|
||||
channelText: string
|
||||
ongoingSessionText: string
|
||||
recentSessionText: string
|
||||
cards: BackendCardResult[]
|
||||
}
|
||||
|
||||
function requireAuthToken(): string | null {
|
||||
const app = getApp<IAppOption>()
|
||||
const tokens = app.globalData && app.globalData.backendAuthTokens
|
||||
? app.globalData.backendAuthTokens
|
||||
: loadBackendAuthTokens()
|
||||
return tokens && tokens.accessToken ? tokens.accessToken : null
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: false,
|
||||
statusText: '准备加载首页',
|
||||
userNameText: '--',
|
||||
tenantText: '--',
|
||||
channelText: '--',
|
||||
ongoingSessionText: '无',
|
||||
recentSessionText: '无',
|
||||
cards: [],
|
||||
} as HomePageData,
|
||||
|
||||
onLoad() {
|
||||
this.loadEntryHome()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.loadEntryHome()
|
||||
},
|
||||
|
||||
async loadEntryHome() {
|
||||
const accessToken = requireAuthToken()
|
||||
if (!accessToken) {
|
||||
wx.redirectTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({
|
||||
loading: true,
|
||||
statusText: '正在加载首页聚合',
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getEntryHome({
|
||||
baseUrl: loadBackendBaseUrl(),
|
||||
accessToken,
|
||||
channelCode: DEFAULT_CHANNEL_CODE,
|
||||
channelType: DEFAULT_CHANNEL_TYPE,
|
||||
})
|
||||
this.applyEntryHomeResult(result)
|
||||
} catch (error) {
|
||||
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
|
||||
this.setData({
|
||||
loading: false,
|
||||
statusText: `首页加载失败:${message}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
applyEntryHomeResult(result: BackendEntryHomeResult) {
|
||||
this.setData({
|
||||
loading: false,
|
||||
statusText: '首页加载完成',
|
||||
userNameText: result.user.nickname || result.user.publicId || result.user.id,
|
||||
tenantText: `${result.tenant.name} (${result.tenant.code})`,
|
||||
channelText: `${result.channel.displayName} / ${result.channel.code}`,
|
||||
ongoingSessionText: result.ongoingSession
|
||||
? `${result.ongoingSession.eventName || result.ongoingSession.eventDisplayName || result.ongoingSession.eventId || result.ongoingSession.id || result.ongoingSession.sessionId} / ${result.ongoingSession.status || result.ongoingSession.sessionStatus}`
|
||||
: '无',
|
||||
recentSessionText: result.recentSession
|
||||
? `${result.recentSession.eventName || result.recentSession.eventDisplayName || result.recentSession.eventId || result.recentSession.id || result.recentSession.sessionId} / ${result.recentSession.status || result.recentSession.sessionStatus}`
|
||||
: '无',
|
||||
cards: result.cards || [],
|
||||
})
|
||||
},
|
||||
|
||||
handleRefresh() {
|
||||
this.loadEntryHome()
|
||||
},
|
||||
|
||||
handleOpenCard(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)}`,
|
||||
})
|
||||
},
|
||||
|
||||
handleOpenRecentResult() {
|
||||
wx.navigateTo({
|
||||
url: '/pages/result/result',
|
||||
})
|
||||
},
|
||||
|
||||
handleLogout() {
|
||||
clearBackendAuthTokens()
|
||||
const app = getApp<IAppOption>()
|
||||
if (app.globalData) {
|
||||
app.globalData.backendAuthTokens = null
|
||||
}
|
||||
wx.redirectTo({
|
||||
url: '/pages/login/login',
|
||||
})
|
||||
},
|
||||
})
|
||||
32
miniprogram/pages/home/home.wxml
Normal file
32
miniprogram/pages/home/home.wxml
Normal file
@@ -0,0 +1,32 @@
|
||||
<scroll-view class="page" scroll-y>
|
||||
<view class="shell">
|
||||
<view class="hero">
|
||||
<view class="hero__eyebrow">Entry Home</view>
|
||||
<view class="hero__title">{{userNameText}}</view>
|
||||
<view class="hero__desc">{{tenantText}}</view>
|
||||
<view class="hero__desc">{{channelText}}</view>
|
||||
</view>
|
||||
|
||||
<view class="panel">
|
||||
<view class="panel__title">当前状态</view>
|
||||
<view class="summary">{{statusText}}</view>
|
||||
<view class="summary">进行中:{{ongoingSessionText}}</view>
|
||||
<view class="summary">最近一局:{{recentSessionText}}</view>
|
||||
<view class="actions">
|
||||
<button class="btn btn--secondary" bindtap="handleRefresh">刷新首页</button>
|
||||
<button class="btn btn--ghost" bindtap="handleOpenRecentResult">查看结果</button>
|
||||
<button class="btn btn--ghost" bindtap="handleLogout">退出登录</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="id" class="card" bindtap="handleOpenCard" data-event-id="{{item.event && item.event.id ? item.event.id : ''}}">
|
||||
<view class="card__title">{{item.title}}</view>
|
||||
<view class="card__subtitle">{{item.subtitle || (item.event && item.event.displayName ? item.event.displayName : '暂无副标题')}}</view>
|
||||
<view class="card__meta">{{item.type}} / {{item.displaySlot}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
111
miniprogram/pages/home/home.wxss
Normal file
111
miniprogram/pages/home/home.wxss
Normal file
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: #ffffff;
|
||||
color: #52657d;
|
||||
border: 2rpx solid #d8e2ec;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: 10rpx;
|
||||
padding: 20rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #f6f9fc;
|
||||
}
|
||||
|
||||
.card__title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #17345a;
|
||||
}
|
||||
|
||||
.card__subtitle,
|
||||
.card__meta {
|
||||
font-size: 22rpx;
|
||||
color: #64748b;
|
||||
}
|
||||
@@ -1,52 +1,11 @@
|
||||
// index.ts
|
||||
// 获取应用实例
|
||||
const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
|
||||
import { loadBackendAuthTokens } from '../../utils/backendAuth'
|
||||
|
||||
Component({
|
||||
data: {
|
||||
motto: 'Hello World',
|
||||
userInfo: {
|
||||
avatarUrl: defaultAvatarUrl,
|
||||
nickName: '',
|
||||
},
|
||||
hasUserInfo: false,
|
||||
canIUseGetUserProfile: wx.canIUse('getUserProfile'),
|
||||
canIUseNicknameComp: wx.canIUse('input.type.nickname'),
|
||||
},
|
||||
methods: {
|
||||
// 事件处理函数
|
||||
bindViewTap() {
|
||||
wx.navigateTo({
|
||||
url: '../logs/logs',
|
||||
})
|
||||
},
|
||||
onChooseAvatar(e: any) {
|
||||
const { avatarUrl } = e.detail
|
||||
const { nickName } = this.data.userInfo
|
||||
this.setData({
|
||||
"userInfo.avatarUrl": avatarUrl,
|
||||
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
|
||||
})
|
||||
},
|
||||
onInputChange(e: any) {
|
||||
const nickName = e.detail.value
|
||||
const { avatarUrl } = this.data.userInfo
|
||||
this.setData({
|
||||
"userInfo.nickName": nickName,
|
||||
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
|
||||
})
|
||||
},
|
||||
getUserProfile() {
|
||||
// 推荐使用wx.getUserProfile获取用户信息,开发者每次通过该接口获取用户个人信息均需用户确认,开发者妥善保管用户快速填写的头像昵称,避免重复弹窗
|
||||
wx.getUserProfile({
|
||||
desc: '展示用户信息', // 声明获取用户个人信息后的用途,后续会展示在弹窗中,请谨慎填写
|
||||
success: (res) => {
|
||||
this.setData({
|
||||
userInfo: res.userInfo,
|
||||
hasUserInfo: true
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
Page({
|
||||
onLoad() {
|
||||
const tokens = loadBackendAuthTokens()
|
||||
const url = tokens && tokens.accessToken
|
||||
? '/pages/home/home'
|
||||
: '/pages/login/login'
|
||||
wx.redirectTo({ url })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,27 +1,3 @@
|
||||
<!--index.wxml-->
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<view class="container">
|
||||
<view class="userinfo">
|
||||
<block wx:if="{{canIUseNicknameComp && !hasUserInfo}}">
|
||||
<button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">
|
||||
<image class="avatar" src="{{userInfo.avatarUrl}}"></image>
|
||||
</button>
|
||||
<view class="nickname-wrapper">
|
||||
<text class="nickname-label">昵称</text>
|
||||
<input type="nickname" class="nickname-input" placeholder="请输入昵称" bind:change="onInputChange" />
|
||||
</view>
|
||||
</block>
|
||||
<block wx:elif="{{!hasUserInfo}}">
|
||||
<button wx:if="{{canIUseGetUserProfile}}" bindtap="getUserProfile"> 获取头像昵称 </button>
|
||||
<view wx:else> 请使用2.10.4及以上版本基础库 </view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
|
||||
<text class="userinfo-nickname">{{userInfo.nickName}}</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="usermotto">
|
||||
<text class="user-motto">{{motto}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="boot-page">
|
||||
<view class="boot-page__text">正在进入业务页...</view>
|
||||
</view>
|
||||
|
||||
@@ -1,62 +1,13 @@
|
||||
/**index.wxss**/
|
||||
page {
|
||||
height: 100vh;
|
||||
.boot-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.scrollarea {
|
||||
flex: 1;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.userinfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #aaa;
|
||||
width: 80%;
|
||||
justify-content: center;
|
||||
background: linear-gradient(180deg, #0f2f5a 0%, #1d5ca8 100%);
|
||||
}
|
||||
|
||||
.userinfo-avatar {
|
||||
overflow: hidden;
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
margin: 20rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.usermotto {
|
||||
margin-top: 200px;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
padding: 0;
|
||||
width: 56px !important;
|
||||
border-radius: 8px;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.nickname-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
border-top: .5px solid rgba(0, 0, 0, 0.1);
|
||||
border-bottom: .5px solid rgba(0, 0, 0, 0.1);
|
||||
color: black;
|
||||
}
|
||||
|
||||
.nickname-label {
|
||||
width: 105px;
|
||||
}
|
||||
|
||||
.nickname-input {
|
||||
flex: 1;
|
||||
.boot-page__text {
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
3
miniprogram/pages/login/login.json
Normal file
3
miniprogram/pages/login/login.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
127
miniprogram/pages/login/login.ts
Normal file
127
miniprogram/pages/login/login.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { clearBackendAuthTokens, saveBackendAuthTokens, saveBackendBaseUrl } from '../../utils/backendAuth'
|
||||
import { loginWechatMini } from '../../utils/backendApi'
|
||||
|
||||
const DEFAULT_BACKEND_BASE_URL = 'https://api.gotomars.xyz'
|
||||
const DEFAULT_DEVICE_KEY = 'mini-dev-device-001'
|
||||
const DEFAULT_DEV_CODE = 'dev-workbench-user'
|
||||
|
||||
type LoginPageData = {
|
||||
backendBaseUrl: string
|
||||
deviceKey: string
|
||||
loginCode: string
|
||||
statusText: string
|
||||
}
|
||||
|
||||
function setAppBackendState(baseUrl: string, accessToken: string, refreshToken: string) {
|
||||
const app = getApp<IAppOption>()
|
||||
if (app.globalData) {
|
||||
app.globalData.backendBaseUrl = baseUrl
|
||||
app.globalData.backendAuthTokens = { accessToken, refreshToken }
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
backendBaseUrl: DEFAULT_BACKEND_BASE_URL,
|
||||
deviceKey: DEFAULT_DEVICE_KEY,
|
||||
loginCode: DEFAULT_DEV_CODE,
|
||||
statusText: '请先登录后端',
|
||||
} as LoginPageData,
|
||||
|
||||
onLoad() {
|
||||
const app = getApp<IAppOption>()
|
||||
this.setData({
|
||||
backendBaseUrl: app.globalData && app.globalData.backendBaseUrl
|
||||
? app.globalData.backendBaseUrl
|
||||
: DEFAULT_BACKEND_BASE_URL,
|
||||
})
|
||||
},
|
||||
handleBaseUrlInput(event: WechatMiniprogram.Input) {
|
||||
this.setData({ backendBaseUrl: event.detail.value })
|
||||
},
|
||||
|
||||
handleDeviceKeyInput(event: WechatMiniprogram.Input) {
|
||||
this.setData({ deviceKey: event.detail.value })
|
||||
},
|
||||
|
||||
handleLoginCodeInput(event: WechatMiniprogram.Input) {
|
||||
this.setData({ loginCode: event.detail.value })
|
||||
},
|
||||
|
||||
persistBaseUrl(): string {
|
||||
const normalized = saveBackendBaseUrl(this.data.backendBaseUrl)
|
||||
const app = getApp<IAppOption>()
|
||||
if (app.globalData) {
|
||||
app.globalData.backendBaseUrl = normalized
|
||||
}
|
||||
if (normalized !== this.data.backendBaseUrl) {
|
||||
this.setData({ backendBaseUrl: normalized })
|
||||
}
|
||||
return normalized
|
||||
},
|
||||
|
||||
async loginWithCode(code: string, sourceLabel: string) {
|
||||
const baseUrl = this.persistBaseUrl()
|
||||
this.setData({
|
||||
statusText: `正在用 ${sourceLabel} 登录后端`,
|
||||
})
|
||||
try {
|
||||
const result = await loginWechatMini({
|
||||
baseUrl,
|
||||
code,
|
||||
deviceKey: this.data.deviceKey || DEFAULT_DEVICE_KEY,
|
||||
clientType: 'wechat',
|
||||
})
|
||||
const tokens = saveBackendAuthTokens(result.tokens)
|
||||
setAppBackendState(baseUrl, tokens.accessToken, tokens.refreshToken)
|
||||
this.setData({
|
||||
statusText: '登录成功,准备进入首页',
|
||||
})
|
||||
wx.redirectTo({
|
||||
url: '/pages/home/home',
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
|
||||
this.setData({
|
||||
statusText: `登录失败:${message}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
handleLoginWithDevCode() {
|
||||
this.loginWithCode((this.data.loginCode || DEFAULT_DEV_CODE).trim(), '开发码')
|
||||
},
|
||||
|
||||
handleLoginWithWechat() {
|
||||
this.setData({
|
||||
statusText: '正在调用 wx.login',
|
||||
})
|
||||
wx.login({
|
||||
success: (result) => {
|
||||
const code = result && result.code ? result.code : ''
|
||||
if (!code) {
|
||||
this.setData({ statusText: 'wx.login 未返回 code' })
|
||||
return
|
||||
}
|
||||
this.setData({ loginCode: code })
|
||||
this.loginWithCode(code, 'wx.login code')
|
||||
},
|
||||
fail: (error) => {
|
||||
this.setData({
|
||||
statusText: `wx.login 失败:${error && error.errMsg ? error.errMsg : '未知错误'}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
handleClearLoginState() {
|
||||
clearBackendAuthTokens()
|
||||
const app = getApp<IAppOption>()
|
||||
if (app.globalData) {
|
||||
app.globalData.backendAuthTokens = null
|
||||
}
|
||||
this.setData({
|
||||
statusText: '已清空登录态',
|
||||
})
|
||||
},
|
||||
})
|
||||
35
miniprogram/pages/login/login.wxml
Normal file
35
miniprogram/pages/login/login.wxml
Normal file
@@ -0,0 +1,35 @@
|
||||
<scroll-view class="page" scroll-y>
|
||||
<view class="shell">
|
||||
<view class="hero">
|
||||
<view class="hero__eyebrow">CMR Backend</view>
|
||||
<view class="hero__title">登录</view>
|
||||
<view class="hero__desc">先把小程序登录态接到 backend,再进入首页和活动页。</view>
|
||||
</view>
|
||||
|
||||
<view class="panel">
|
||||
<view class="panel__title">连接配置</view>
|
||||
<view class="field">
|
||||
<view class="field__label">Backend Base URL</view>
|
||||
<input class="field__input" value="{{backendBaseUrl}}" bindinput="handleBaseUrlInput" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<view class="field__label">Device Key</view>
|
||||
<input class="field__input" value="{{deviceKey}}" bindinput="handleDeviceKeyInput" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<view class="field__label">开发登录 Code</view>
|
||||
<input class="field__input" value="{{loginCode}}" bindinput="handleLoginCodeInput" />
|
||||
</view>
|
||||
<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--ghost" bindtap="handleClearLoginState">清空登录态</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="panel">
|
||||
<view class="panel__title">状态</view>
|
||||
<view class="status">{{statusText}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
116
miniprogram/pages/login/login.wxss
Normal file
116
miniprogram/pages/login/login.wxss
Normal file
@@ -0,0 +1,116 @@
|
||||
page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #eef4fb 0%, #e6eff9 100%);
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
gap: 24rpx;
|
||||
padding: 28rpx 24rpx 40rpx;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: 18rpx;
|
||||
padding: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #123b72 0%, #1d5ca8 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;
|
||||
line-height: 1.6;
|
||||
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;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.field__label {
|
||||
font-size: 22rpx;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.field__input {
|
||||
min-height: 76rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 18rpx;
|
||||
border: 2rpx solid #dce7f3;
|
||||
background: #f8fbff;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.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--primary {
|
||||
background: #173d73;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: #dfeaf8;
|
||||
color: #173d73;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: #ffffff;
|
||||
color: #52657d;
|
||||
border: 2rpx solid #d8e2ec;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: #30465f;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type GameLaunchEnvelope,
|
||||
type MapPageLaunchOptions,
|
||||
} from '../../utils/gameLaunch'
|
||||
import { finishSession, startSession, type BackendSessionFinishSummaryPayload } from '../../utils/backendApi'
|
||||
import { loadBackendBaseUrl } from '../../utils/backendAuth'
|
||||
import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig'
|
||||
import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
|
||||
import { type TrackColorPreset } from '../../game/presentation/trackStyleConfig'
|
||||
@@ -173,6 +175,8 @@ let lastPunchHintHapticAt = 0
|
||||
let currentSystemSettingsConfig: SystemSettingsConfig | undefined
|
||||
let currentRemoteMapConfig: RemoteMapConfig | undefined
|
||||
let systemSettingsLockLifetimeActive = false
|
||||
let syncedBackendSessionStartId = ''
|
||||
let syncedBackendSessionFinishId = ''
|
||||
let lastCenterScaleRulerStablePatch: Pick<
|
||||
MapPageData,
|
||||
| 'centerScaleRulerVisible'
|
||||
@@ -441,6 +445,37 @@ function hasExplicitLaunchOptions(options?: MapPageLaunchOptions | null): boolea
|
||||
)
|
||||
}
|
||||
|
||||
function getCurrentBackendSessionContext(): { sessionId: string; sessionToken: string } | null {
|
||||
const business = currentGameLaunchEnvelope.business
|
||||
if (!business || !business.sessionId || !business.sessionToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: business.sessionId,
|
||||
sessionToken: business.sessionToken,
|
||||
}
|
||||
}
|
||||
|
||||
function getBackendSessionContextFromLaunchEnvelope(envelope: GameLaunchEnvelope | null | undefined): { sessionId: string; sessionToken: string } | null {
|
||||
if (!envelope || !envelope.business || !envelope.business.sessionId || !envelope.business.sessionToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: envelope.business.sessionId,
|
||||
sessionToken: envelope.business.sessionToken,
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentBackendBaseUrl(): string {
|
||||
const app = getApp<IAppOption>()
|
||||
if (app.globalData && app.globalData.backendBaseUrl) {
|
||||
return app.globalData.backendBaseUrl
|
||||
}
|
||||
return loadBackendBaseUrl()
|
||||
}
|
||||
|
||||
function buildSideButtonVisibility(mode: SideButtonMode) {
|
||||
return {
|
||||
sideButtonMode: mode,
|
||||
@@ -871,6 +906,8 @@ Page({
|
||||
|
||||
onLoad(options: MapPageLaunchOptions) {
|
||||
clearSessionRecoveryPersistTimer()
|
||||
syncedBackendSessionStartId = ''
|
||||
syncedBackendSessionFinishId = ''
|
||||
currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options)
|
||||
if (!hasExplicitLaunchOptions(options)) {
|
||||
const recoverySnapshot = loadSessionRecoverySnapshot()
|
||||
@@ -991,6 +1028,8 @@ Page({
|
||||
const nextAnimationLevel = typeof nextPatch.animationLevel === 'string'
|
||||
? nextPatch.animationLevel
|
||||
: this.data.animationLevel
|
||||
let shouldSyncBackendSessionStart = false
|
||||
let backendSessionFinishStatus: 'finished' | 'failed' | null = null
|
||||
|
||||
if (nextAnimationLevel === 'lite') {
|
||||
clearHudFxTimer('timer')
|
||||
@@ -1055,6 +1094,7 @@ Page({
|
||||
nextData.showGameInfoPanel = false
|
||||
nextData.showSystemSettingsPanel = false
|
||||
clearGameInfoPanelSyncTimer()
|
||||
backendSessionFinishStatus = nextPatch.gameSessionStatus === 'finished' ? 'finished' : 'failed'
|
||||
} else if (
|
||||
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
|
||||
&& nextPatch.gameSessionStatus === 'idle'
|
||||
@@ -1064,6 +1104,11 @@ Page({
|
||||
shouldSyncRuntimeSystemSettings = true
|
||||
clearSessionRecoverySnapshot()
|
||||
clearSessionRecoveryPersistTimer()
|
||||
} else if (
|
||||
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
|
||||
&& nextPatch.gameSessionStatus === 'running'
|
||||
) {
|
||||
shouldSyncBackendSessionStart = true
|
||||
} else if (nextPatch.gameSessionStatus === 'running' || nextPatch.gameSessionStatus === 'idle') {
|
||||
nextData.showResultScene = false
|
||||
}
|
||||
@@ -1077,6 +1122,12 @@ Page({
|
||||
if (typeof nextPatch.gameSessionStatus === 'string') {
|
||||
this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus)
|
||||
}
|
||||
if (shouldSyncBackendSessionStart) {
|
||||
this.syncBackendSessionStart()
|
||||
}
|
||||
if (backendSessionFinishStatus) {
|
||||
this.syncBackendSessionFinish(backendSessionFinishStatus)
|
||||
}
|
||||
if (shouldSyncRuntimeSystemSettings) {
|
||||
this.applyRuntimeSystemSettings(nextLockLifetimeActive)
|
||||
}
|
||||
@@ -1088,6 +1139,12 @@ Page({
|
||||
if (typeof nextPatch.gameSessionStatus === 'string') {
|
||||
this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus)
|
||||
}
|
||||
if (shouldSyncBackendSessionStart) {
|
||||
this.syncBackendSessionStart()
|
||||
}
|
||||
if (backendSessionFinishStatus) {
|
||||
this.syncBackendSessionFinish(backendSessionFinishStatus)
|
||||
}
|
||||
if (shouldSyncRuntimeSystemSettings) {
|
||||
this.applyRuntimeSystemSettings(nextLockLifetimeActive)
|
||||
}
|
||||
@@ -1283,6 +1340,8 @@ Page({
|
||||
onUnload() {
|
||||
this.persistSessionRecoverySnapshot()
|
||||
clearSessionRecoveryPersistTimer()
|
||||
syncedBackendSessionStartId = ''
|
||||
syncedBackendSessionFinishId = ''
|
||||
clearGameInfoPanelSyncTimer()
|
||||
clearCenterScaleRulerSyncTimer()
|
||||
clearCenterScaleRulerUpdateTimer()
|
||||
@@ -1332,6 +1391,114 @@ Page({
|
||||
return true
|
||||
},
|
||||
|
||||
syncBackendSessionStart() {
|
||||
const sessionContext = getCurrentBackendSessionContext()
|
||||
if (!sessionContext || syncedBackendSessionStartId === sessionContext.sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
startSession({
|
||||
baseUrl: getCurrentBackendBaseUrl(),
|
||||
sessionId: sessionContext.sessionId,
|
||||
sessionToken: sessionContext.sessionToken,
|
||||
})
|
||||
.then(() => {
|
||||
syncedBackendSessionStartId = sessionContext.sessionId
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error && error.message ? error.message : '未知错误'
|
||||
this.setData({
|
||||
statusText: `session start 上报失败: ${message}`,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
syncBackendSessionFinish(statusOverride?: 'finished' | 'failed' | 'cancelled') {
|
||||
const sessionContext = getCurrentBackendSessionContext()
|
||||
if (!sessionContext || syncedBackendSessionFinishId === sessionContext.sessionId || !mapEngine) {
|
||||
return
|
||||
}
|
||||
|
||||
const finishSummary = mapEngine.getSessionFinishSummary(statusOverride)
|
||||
if (!finishSummary) {
|
||||
return
|
||||
}
|
||||
|
||||
const summaryPayload: BackendSessionFinishSummaryPayload = {}
|
||||
if (typeof finishSummary.finalDurationSec === 'number') {
|
||||
summaryPayload.finalDurationSec = finishSummary.finalDurationSec
|
||||
}
|
||||
if (typeof finishSummary.finalScore === 'number') {
|
||||
summaryPayload.finalScore = finishSummary.finalScore
|
||||
}
|
||||
if (typeof finishSummary.completedControls === 'number') {
|
||||
summaryPayload.completedControls = finishSummary.completedControls
|
||||
}
|
||||
if (typeof finishSummary.totalControls === 'number') {
|
||||
summaryPayload.totalControls = finishSummary.totalControls
|
||||
}
|
||||
if (typeof finishSummary.distanceMeters === 'number') {
|
||||
summaryPayload.distanceMeters = finishSummary.distanceMeters
|
||||
}
|
||||
if (typeof finishSummary.averageSpeedKmh === 'number') {
|
||||
summaryPayload.averageSpeedKmh = finishSummary.averageSpeedKmh
|
||||
}
|
||||
|
||||
finishSession({
|
||||
baseUrl: getCurrentBackendBaseUrl(),
|
||||
sessionId: sessionContext.sessionId,
|
||||
sessionToken: sessionContext.sessionToken,
|
||||
status: finishSummary.status,
|
||||
summary: summaryPayload,
|
||||
})
|
||||
.then(() => {
|
||||
syncedBackendSessionFinishId = sessionContext.sessionId
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error && error.message ? error.message : '未知错误'
|
||||
this.setData({
|
||||
statusText: `session finish 上报失败: ${message}`,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
reportAbandonedRecoverySnapshot(snapshot: SessionRecoverySnapshot) {
|
||||
const sessionContext = getBackendSessionContextFromLaunchEnvelope(snapshot.launchEnvelope)
|
||||
if (!sessionContext) {
|
||||
clearSessionRecoverySnapshot()
|
||||
return
|
||||
}
|
||||
|
||||
finishSession({
|
||||
baseUrl: getCurrentBackendBaseUrl(),
|
||||
sessionId: sessionContext.sessionId,
|
||||
sessionToken: sessionContext.sessionToken,
|
||||
status: 'cancelled',
|
||||
summary: {},
|
||||
})
|
||||
.then(() => {
|
||||
syncedBackendSessionFinishId = sessionContext.sessionId
|
||||
clearSessionRecoverySnapshot()
|
||||
wx.showToast({
|
||||
title: '已放弃上次对局',
|
||||
icon: 'none',
|
||||
duration: 1400,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
clearSessionRecoverySnapshot()
|
||||
const message = error && error.message ? error.message : '未知错误'
|
||||
this.setData({
|
||||
statusText: `放弃恢复已生效,后端取消上报失败: ${message}`,
|
||||
})
|
||||
wx.showToast({
|
||||
title: '已放弃上次对局',
|
||||
icon: 'none',
|
||||
duration: 1400,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
syncSessionRecoveryLifecycle(status: MapPageData['gameSessionStatus']) {
|
||||
if (status === 'running') {
|
||||
this.persistSessionRecoverySnapshot()
|
||||
@@ -1368,7 +1535,7 @@ Page({
|
||||
cancelText: '放弃',
|
||||
success: (result) => {
|
||||
if (!result.confirm) {
|
||||
clearSessionRecoverySnapshot()
|
||||
this.reportAbandonedRecoverySnapshot(snapshot)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1385,15 +1552,19 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({
|
||||
showResultScene: false,
|
||||
showDebugPanel: false,
|
||||
showGameInfoPanel: false,
|
||||
showSystemSettingsPanel: false,
|
||||
})
|
||||
this.syncSessionRecoveryLifecycle('running')
|
||||
},
|
||||
})
|
||||
this.setData({
|
||||
showResultScene: false,
|
||||
showDebugPanel: false,
|
||||
showGameInfoPanel: false,
|
||||
showSystemSettingsPanel: false,
|
||||
})
|
||||
const sessionContext = getCurrentBackendSessionContext()
|
||||
if (sessionContext) {
|
||||
syncedBackendSessionStartId = sessionContext.sessionId
|
||||
}
|
||||
this.syncSessionRecoveryLifecycle('running')
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
compileCurrentRuntimeProfile(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) {
|
||||
@@ -1537,7 +1708,10 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
const errorMessage = error && error.message ? error.message : '未知错误'
|
||||
const rawErrorMessage = error && error.message ? error.message : '未知错误'
|
||||
const errorMessage = rawErrorMessage.indexOf('404') >= 0
|
||||
? `release manifest 不存在或未发布 (${configLabel})`
|
||||
: rawErrorMessage
|
||||
this.setData({
|
||||
configStatusText: `载入失败: ${errorMessage}`,
|
||||
statusText: `远程地图配置载入失败: ${errorMessage} (${INTERNAL_BUILD_VERSION})`,
|
||||
@@ -1939,18 +2113,19 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
wx.showModal({
|
||||
title: '确认退出',
|
||||
content: '确认强制结束当前对局并返回开始前状态?',
|
||||
confirmText: '确认退出',
|
||||
cancelText: '取消',
|
||||
success: (result) => {
|
||||
if (result.confirm && mapEngine) {
|
||||
systemSettingsLockLifetimeActive = false
|
||||
mapEngine.handleForceExitGame()
|
||||
}
|
||||
},
|
||||
})
|
||||
wx.showModal({
|
||||
title: '确认退出',
|
||||
content: '确认强制结束当前对局并返回开始前状态?',
|
||||
confirmText: '确认退出',
|
||||
cancelText: '取消',
|
||||
success: (result) => {
|
||||
if (result.confirm && mapEngine) {
|
||||
this.syncBackendSessionFinish('cancelled')
|
||||
systemSettingsLockLifetimeActive = false
|
||||
mapEngine.handleForceExitGame()
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
handleSkipAction() {
|
||||
|
||||
3
miniprogram/pages/result/result.json
Normal file
3
miniprogram/pages/result/result.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "结果"
|
||||
}
|
||||
134
miniprogram/pages/result/result.ts
Normal file
134
miniprogram/pages/result/result.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
|
||||
import { getMyResults, getSessionResult, type BackendSessionResultView } from '../../utils/backendApi'
|
||||
|
||||
type ResultPageData = {
|
||||
sessionId: string
|
||||
statusText: string
|
||||
sessionTitleText: string
|
||||
sessionSubtitleText: string
|
||||
rows: Array<{ label: string; value: string }>
|
||||
recentResults: BackendSessionResultView[]
|
||||
}
|
||||
|
||||
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 formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '--'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
sessionId: '',
|
||||
statusText: '准备加载结果',
|
||||
sessionTitleText: '结果页',
|
||||
sessionSubtitleText: '未加载',
|
||||
rows: [],
|
||||
recentResults: [],
|
||||
} as ResultPageData,
|
||||
|
||||
onLoad(query: { sessionId?: string }) {
|
||||
const sessionId = query && query.sessionId ? decodeURIComponent(query.sessionId) : ''
|
||||
this.setData({ sessionId })
|
||||
if (sessionId) {
|
||||
this.loadSingleResult(sessionId)
|
||||
return
|
||||
}
|
||||
this.loadRecentResults()
|
||||
},
|
||||
|
||||
async loadSingleResult(sessionId: string) {
|
||||
const accessToken = getAccessToken()
|
||||
if (!accessToken) {
|
||||
wx.redirectTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({
|
||||
statusText: '正在加载单局结果',
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getSessionResult({
|
||||
baseUrl: loadBackendBaseUrl(),
|
||||
accessToken,
|
||||
sessionId,
|
||||
})
|
||||
this.setData({
|
||||
statusText: '单局结果加载完成',
|
||||
sessionTitleText: result.session.eventName || result.session.eventDisplayName || result.session.eventId || result.session.id || result.session.sessionId,
|
||||
sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status}`,
|
||||
rows: [
|
||||
{ label: '最终得分', value: formatValue(result.result.finalScore) },
|
||||
{ label: '最终用时(秒)', value: formatValue(result.result.finalDurationSec) },
|
||||
{ label: '完成点数', value: formatValue(result.result.completedControls) },
|
||||
{ label: '总点数', value: formatValue(result.result.totalControls) },
|
||||
{ label: '累计里程(m)', value: formatValue(result.result.distanceMeters) },
|
||||
{ label: '平均速度(km/h)', value: formatValue(result.result.averageSpeedKmh) },
|
||||
{ label: '最大心率', value: formatValue(result.result.maxHeartRateBpm) },
|
||||
],
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
|
||||
this.setData({
|
||||
statusText: `结果加载失败:${message}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async loadRecentResults() {
|
||||
const accessToken = getAccessToken()
|
||||
if (!accessToken) {
|
||||
wx.redirectTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({
|
||||
statusText: '正在加载最近结果',
|
||||
})
|
||||
|
||||
try {
|
||||
const results = await getMyResults({
|
||||
baseUrl: loadBackendBaseUrl(),
|
||||
accessToken,
|
||||
limit: 20,
|
||||
})
|
||||
this.setData({
|
||||
statusText: '最近结果加载完成',
|
||||
sessionSubtitleText: '最近结果列表',
|
||||
recentResults: results,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
|
||||
this.setData({
|
||||
statusText: `结果加载失败:${message}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
handleOpenResult(event: WechatMiniprogram.TouchEvent) {
|
||||
const sessionId = event.currentTarget.dataset.sessionId as string | undefined
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
wx.redirectTo({
|
||||
url: `/pages/result/result?sessionId=${encodeURIComponent(sessionId)}`,
|
||||
})
|
||||
},
|
||||
|
||||
handleBackToList() {
|
||||
this.setData({
|
||||
sessionId: '',
|
||||
rows: [],
|
||||
})
|
||||
this.loadRecentResults()
|
||||
},
|
||||
})
|
||||
33
miniprogram/pages/result/result.wxml
Normal file
33
miniprogram/pages/result/result.wxml
Normal file
@@ -0,0 +1,33 @@
|
||||
<scroll-view class="page" scroll-y>
|
||||
<view class="shell">
|
||||
<view class="hero">
|
||||
<view class="hero__eyebrow">Result</view>
|
||||
<view class="hero__title">{{sessionTitleText}}</view>
|
||||
<view class="hero__desc">{{sessionSubtitleText}}</view>
|
||||
</view>
|
||||
|
||||
<view class="panel">
|
||||
<view class="panel__title">当前状态</view>
|
||||
<view class="summary">{{statusText}}</view>
|
||||
<button wx:if="{{sessionId}}" class="btn btn--ghost" bindtap="handleBackToList">返回最近结果</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{rows.length}}" class="panel">
|
||||
<view class="panel__title">单局摘要</view>
|
||||
<view wx:for="{{rows}}" wx:key="label" class="row">
|
||||
<view class="row__label">{{item.label}}</view>
|
||||
<view class="row__value">{{item.value}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!sessionId}}" class="panel">
|
||||
<view class="panel__title">最近结果</view>
|
||||
<view wx:if="{{!recentResults.length}}" class="summary">当前没有结果记录</view>
|
||||
<view wx:for="{{recentResults}}" wx:key="session.id" class="result-card" bindtap="handleOpenResult" data-session-id="{{item.session.id}}">
|
||||
<view class="result-card__title">{{item.session.eventName || item.session.id}}</view>
|
||||
<view class="result-card__meta">{{item.result.status}} / {{item.session.status}}</view>
|
||||
<view class="result-card__meta">得分 {{item.result.finalScore || '--'}} / 用时 {{item.result.finalDurationSec || '--'}}s</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
114
miniprogram/pages/result/result.wxss
Normal file
114
miniprogram/pages/result/result.wxss
Normal file
@@ -0,0 +1,114 @@
|
||||
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,
|
||||
.row__label,
|
||||
.row__value,
|
||||
.result-card__meta {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: #30465f;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 10rpx 0;
|
||||
border-bottom: 2rpx solid #edf2f7;
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.row__value {
|
||||
font-weight: 700;
|
||||
color: #17345a;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
display: grid;
|
||||
gap: 8rpx;
|
||||
padding: 18rpx;
|
||||
border-radius: 18rpx;
|
||||
background: #f6f9fc;
|
||||
}
|
||||
|
||||
.result-card__title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #17345a;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin: 0;
|
||||
min-height: 76rpx;
|
||||
padding: 0 24rpx;
|
||||
line-height: 76rpx;
|
||||
border-radius: 18rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.btn::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: #ffffff;
|
||||
color: #52657d;
|
||||
border: 2rpx solid #d8e2ec;
|
||||
}
|
||||
375
miniprogram/utils/backendApi.ts
Normal file
375
miniprogram/utils/backendApi.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { normalizeBackendBaseUrl } from './backendAuth'
|
||||
|
||||
export interface BackendApiError {
|
||||
statusCode: number
|
||||
code: string
|
||||
message: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export interface BackendAuthLoginResult {
|
||||
user?: {
|
||||
id?: string
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
tokens: {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface BackendResolvedRelease {
|
||||
launchMode: string
|
||||
source: string
|
||||
eventId: string
|
||||
releaseId: string
|
||||
configLabel: string
|
||||
manifestUrl: string
|
||||
manifestChecksumSha256?: string | null
|
||||
routeCode?: string | null
|
||||
}
|
||||
|
||||
export interface BackendEntrySessionSummary {
|
||||
id: string
|
||||
status: string
|
||||
eventId?: string
|
||||
eventName?: string
|
||||
releaseId?: string | null
|
||||
configLabel?: string | null
|
||||
routeCode?: string | null
|
||||
launchedAt?: string | null
|
||||
startedAt?: string | null
|
||||
endedAt?: string | null
|
||||
// 兼容前端旧字段名,避免联调过渡期多处判断
|
||||
sessionId?: string
|
||||
sessionStatus?: string
|
||||
eventDisplayName?: string
|
||||
}
|
||||
|
||||
export interface BackendCardResult {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
subtitle?: string | null
|
||||
coverUrl?: string | null
|
||||
displaySlot: string
|
||||
displayPriority: number
|
||||
event?: {
|
||||
id: string
|
||||
displayName: string
|
||||
summary?: string | null
|
||||
} | null
|
||||
htmlUrl?: string | null
|
||||
}
|
||||
|
||||
export interface BackendEntryHomeResult {
|
||||
user: {
|
||||
id: string
|
||||
publicId: string
|
||||
status: string
|
||||
nickname?: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
tenant: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
channel: {
|
||||
id: string
|
||||
code: string
|
||||
type: string
|
||||
platformAppId?: string | null
|
||||
displayName: string
|
||||
status: string
|
||||
isDefault: boolean
|
||||
}
|
||||
cards: BackendCardResult[]
|
||||
ongoingSession?: BackendEntrySessionSummary | null
|
||||
recentSession?: BackendEntrySessionSummary | null
|
||||
}
|
||||
|
||||
export interface BackendEventPlayResult {
|
||||
event: {
|
||||
id: string
|
||||
slug: string
|
||||
displayName: string
|
||||
summary?: string | null
|
||||
status: string
|
||||
}
|
||||
release?: {
|
||||
id: string
|
||||
configLabel: string
|
||||
manifestUrl: string
|
||||
manifestChecksumSha256?: string | null
|
||||
routeCode?: string | null
|
||||
} | null
|
||||
resolvedRelease?: BackendResolvedRelease | null
|
||||
play: {
|
||||
canLaunch: boolean
|
||||
primaryAction: string
|
||||
reason: string
|
||||
launchSource?: string
|
||||
ongoingSession?: BackendEntrySessionSummary | null
|
||||
recentSession?: BackendEntrySessionSummary | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface BackendLaunchResult {
|
||||
event: {
|
||||
id: string
|
||||
displayName: string
|
||||
}
|
||||
launch: {
|
||||
source: string
|
||||
resolvedRelease?: BackendResolvedRelease | null
|
||||
config: {
|
||||
configUrl: string
|
||||
configLabel: string
|
||||
configChecksumSha256?: string | null
|
||||
releaseId: string
|
||||
routeCode?: string | null
|
||||
}
|
||||
business: {
|
||||
source: string
|
||||
eventId: string
|
||||
sessionId: string
|
||||
sessionToken: string
|
||||
sessionTokenExpiresAt: string
|
||||
routeCode?: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface BackendSessionFinishSummaryPayload {
|
||||
finalDurationSec?: number
|
||||
finalScore?: number
|
||||
completedControls?: number
|
||||
totalControls?: number
|
||||
distanceMeters?: number
|
||||
averageSpeedKmh?: number
|
||||
maxHeartRateBpm?: number
|
||||
}
|
||||
|
||||
export interface BackendSessionResult {
|
||||
session: {
|
||||
id: string
|
||||
status: string
|
||||
clientType: string
|
||||
deviceKey: string
|
||||
routeCode?: string | null
|
||||
sessionTokenExpiresAt: string
|
||||
launchedAt: string
|
||||
startedAt?: string | null
|
||||
endedAt?: string | null
|
||||
}
|
||||
event: {
|
||||
id: string
|
||||
displayName: string
|
||||
}
|
||||
resolvedRelease?: BackendResolvedRelease | null
|
||||
}
|
||||
|
||||
export interface BackendSessionResultView {
|
||||
session: BackendEntrySessionSummary
|
||||
result: {
|
||||
status: string
|
||||
finalDurationSec?: number
|
||||
finalScore?: number
|
||||
completedControls?: number
|
||||
totalControls?: number
|
||||
distanceMeters?: number
|
||||
averageSpeedKmh?: number
|
||||
maxHeartRateBpm?: number
|
||||
summary?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
type BackendEnvelope<T> = {
|
||||
data: T
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
method: 'GET' | 'POST'
|
||||
baseUrl: string
|
||||
path: string
|
||||
authToken?: string
|
||||
body?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function requestBackend<T>(options: RequestOptions): Promise<T> {
|
||||
const url = `${normalizeBackendBaseUrl(options.baseUrl)}${options.path}`
|
||||
const header: Record<string, string> = {}
|
||||
if (options.body) {
|
||||
header['Content-Type'] = 'application/json'
|
||||
}
|
||||
if (options.authToken) {
|
||||
header.Authorization = `Bearer ${options.authToken}`
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
wx.request({
|
||||
url,
|
||||
method: options.method,
|
||||
header,
|
||||
data: options.body,
|
||||
success: (response) => {
|
||||
const statusCode = typeof response.statusCode === 'number' ? response.statusCode : 0
|
||||
const data = response.data as BackendEnvelope<T> | { error?: { code?: string; message?: string; details?: unknown } }
|
||||
if (statusCode >= 200 && statusCode < 300 && data && typeof data === 'object' && 'data' in data) {
|
||||
resolve((data as BackendEnvelope<T>).data)
|
||||
return
|
||||
}
|
||||
|
||||
const errorPayload = data && typeof data === 'object' && 'error' in data
|
||||
? (data as { error?: { code?: string; message?: string; details?: unknown } }).error
|
||||
: undefined
|
||||
reject({
|
||||
statusCode,
|
||||
code: errorPayload && errorPayload.code ? errorPayload.code : 'backend_error',
|
||||
message: errorPayload && errorPayload.message ? errorPayload.message : `request failed: ${statusCode}`,
|
||||
details: errorPayload && errorPayload.details ? errorPayload.details : response.data,
|
||||
} as BackendApiError)
|
||||
},
|
||||
fail: (error) => {
|
||||
reject({
|
||||
statusCode: 0,
|
||||
code: 'network_error',
|
||||
message: error && error.errMsg ? error.errMsg : 'network request failed',
|
||||
} as BackendApiError)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function loginWechatMini(input: {
|
||||
baseUrl: string
|
||||
code: string
|
||||
deviceKey: string
|
||||
clientType?: string
|
||||
}): Promise<BackendAuthLoginResult> {
|
||||
return requestBackend<BackendAuthLoginResult>({
|
||||
method: 'POST',
|
||||
baseUrl: input.baseUrl,
|
||||
path: '/auth/login/wechat-mini',
|
||||
body: {
|
||||
code: input.code,
|
||||
clientType: input.clientType || 'wechat',
|
||||
deviceKey: input.deviceKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getEventPlay(input: {
|
||||
baseUrl: string
|
||||
eventId: string
|
||||
accessToken: string
|
||||
}): Promise<BackendEventPlayResult> {
|
||||
return requestBackend<BackendEventPlayResult>({
|
||||
method: 'GET',
|
||||
baseUrl: input.baseUrl,
|
||||
path: `/events/${encodeURIComponent(input.eventId)}/play`,
|
||||
authToken: input.accessToken,
|
||||
})
|
||||
}
|
||||
|
||||
export function getEntryHome(input: {
|
||||
baseUrl: string
|
||||
accessToken: string
|
||||
channelCode: string
|
||||
channelType: string
|
||||
}): Promise<BackendEntryHomeResult> {
|
||||
const query = `channelCode=${encodeURIComponent(input.channelCode)}&channelType=${encodeURIComponent(input.channelType)}`
|
||||
return requestBackend<BackendEntryHomeResult>({
|
||||
method: 'GET',
|
||||
baseUrl: input.baseUrl,
|
||||
path: `/me/entry-home?${query}`,
|
||||
authToken: input.accessToken,
|
||||
})
|
||||
}
|
||||
|
||||
export function launchEvent(input: {
|
||||
baseUrl: string
|
||||
eventId: string
|
||||
accessToken: string
|
||||
releaseId?: 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
|
||||
}
|
||||
return requestBackend<BackendLaunchResult>({
|
||||
method: 'POST',
|
||||
baseUrl: input.baseUrl,
|
||||
path: `/events/${encodeURIComponent(input.eventId)}/launch`,
|
||||
authToken: input.accessToken,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function startSession(input: {
|
||||
baseUrl: string
|
||||
sessionId: string
|
||||
sessionToken: string
|
||||
}): Promise<BackendSessionResult> {
|
||||
return requestBackend<BackendSessionResult>({
|
||||
method: 'POST',
|
||||
baseUrl: input.baseUrl,
|
||||
path: `/sessions/${encodeURIComponent(input.sessionId)}/start`,
|
||||
body: {
|
||||
sessionToken: input.sessionToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function finishSession(input: {
|
||||
baseUrl: string
|
||||
sessionId: string
|
||||
sessionToken: string
|
||||
status: 'finished' | 'failed' | 'cancelled'
|
||||
summary: BackendSessionFinishSummaryPayload
|
||||
}): Promise<BackendSessionResult> {
|
||||
return requestBackend<BackendSessionResult>({
|
||||
method: 'POST',
|
||||
baseUrl: input.baseUrl,
|
||||
path: `/sessions/${encodeURIComponent(input.sessionId)}/finish`,
|
||||
body: {
|
||||
sessionToken: input.sessionToken,
|
||||
status: input.status,
|
||||
summary: input.summary,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function getSessionResult(input: {
|
||||
baseUrl: string
|
||||
accessToken: string
|
||||
sessionId: string
|
||||
}): Promise<BackendSessionResultView> {
|
||||
return requestBackend<BackendSessionResultView>({
|
||||
method: 'GET',
|
||||
baseUrl: input.baseUrl,
|
||||
path: `/sessions/${encodeURIComponent(input.sessionId)}/result`,
|
||||
authToken: input.accessToken,
|
||||
})
|
||||
}
|
||||
|
||||
export function getMyResults(input: {
|
||||
baseUrl: string
|
||||
accessToken: string
|
||||
limit?: number
|
||||
}): Promise<BackendSessionResultView[]> {
|
||||
const limit = typeof input.limit === 'number' ? input.limit : 20
|
||||
return requestBackend<BackendSessionResultView[]>({
|
||||
method: 'GET',
|
||||
baseUrl: input.baseUrl,
|
||||
path: `/me/results?limit=${encodeURIComponent(String(limit))}`,
|
||||
authToken: input.accessToken,
|
||||
})
|
||||
}
|
||||
86
miniprogram/utils/backendAuth.ts
Normal file
86
miniprogram/utils/backendAuth.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export interface BackendAuthTokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
}
|
||||
|
||||
const BACKEND_BASE_URL_STORAGE_KEY = 'cmr.backend.baseUrl.v1'
|
||||
const BACKEND_AUTH_TOKENS_STORAGE_KEY = 'cmr.backend.authTokens.v1'
|
||||
const DEFAULT_BACKEND_BASE_URL = 'https://api.gotomars.xyz'
|
||||
const LEGACY_LOCAL_BACKEND_BASE_URLS = [
|
||||
'http://127.0.0.1:8080',
|
||||
'https://127.0.0.1:8080',
|
||||
'http://localhost:8080',
|
||||
'https://localhost:8080',
|
||||
]
|
||||
|
||||
function normalizeString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
export function normalizeBackendBaseUrl(value: unknown): string {
|
||||
const normalized = normalizeString(value).replace(/\/+$/, '')
|
||||
if (LEGACY_LOCAL_BACKEND_BASE_URLS.indexOf(normalized) >= 0) {
|
||||
return DEFAULT_BACKEND_BASE_URL
|
||||
}
|
||||
return normalized || DEFAULT_BACKEND_BASE_URL
|
||||
}
|
||||
|
||||
export function loadBackendBaseUrl(): string {
|
||||
try {
|
||||
const stored = wx.getStorageSync(BACKEND_BASE_URL_STORAGE_KEY)
|
||||
const normalized = normalizeBackendBaseUrl(stored)
|
||||
if (normalized !== stored && normalized === DEFAULT_BACKEND_BASE_URL) {
|
||||
wx.setStorageSync(BACKEND_BASE_URL_STORAGE_KEY, normalized)
|
||||
}
|
||||
return normalized
|
||||
} catch {
|
||||
return DEFAULT_BACKEND_BASE_URL
|
||||
}
|
||||
}
|
||||
|
||||
export function saveBackendBaseUrl(baseUrl: string): string {
|
||||
const normalized = normalizeBackendBaseUrl(baseUrl)
|
||||
try {
|
||||
wx.setStorageSync(BACKEND_BASE_URL_STORAGE_KEY, normalized)
|
||||
} catch {}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function loadBackendAuthTokens(): BackendAuthTokens | null {
|
||||
try {
|
||||
const stored = wx.getStorageSync(BACKEND_AUTH_TOKENS_STORAGE_KEY)
|
||||
if (!stored || typeof stored !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const accessToken = normalizeString((stored as Record<string, unknown>).accessToken)
|
||||
const refreshToken = normalizeString((stored as Record<string, unknown>).refreshToken)
|
||||
if (!accessToken || !refreshToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveBackendAuthTokens(tokens: BackendAuthTokens): BackendAuthTokens {
|
||||
const normalized = {
|
||||
accessToken: normalizeString(tokens.accessToken),
|
||||
refreshToken: normalizeString(tokens.refreshToken),
|
||||
}
|
||||
try {
|
||||
wx.setStorageSync(BACKEND_AUTH_TOKENS_STORAGE_KEY, normalized)
|
||||
} catch {}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function clearBackendAuthTokens() {
|
||||
try {
|
||||
wx.removeStorageSync(BACKEND_AUTH_TOKENS_STORAGE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
21
miniprogram/utils/backendLaunchAdapter.ts
Normal file
21
miniprogram/utils/backendLaunchAdapter.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { type GameLaunchEnvelope } from './gameLaunch'
|
||||
import { type BackendLaunchResult } from './backendApi'
|
||||
|
||||
export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult): GameLaunchEnvelope {
|
||||
return {
|
||||
config: {
|
||||
configUrl: result.launch.config.configUrl,
|
||||
configLabel: result.launch.config.configLabel,
|
||||
configChecksumSha256: result.launch.config.configChecksumSha256 || null,
|
||||
releaseId: result.launch.config.releaseId,
|
||||
routeCode: result.launch.config.routeCode || null,
|
||||
},
|
||||
business: {
|
||||
source: result.launch.business.source === 'direct-event' ? 'direct-event' : 'custom',
|
||||
eventId: result.launch.business.eventId,
|
||||
sessionId: result.launch.business.sessionId,
|
||||
sessionToken: result.launch.business.sessionToken,
|
||||
sessionTokenExpiresAt: result.launch.business.sessionTokenExpiresAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user