完善后端联调链路与模拟器多通道支持

This commit is contained in:
2026-04-01 18:48:59 +08:00
parent 94a1f0ba78
commit a70dc8d5d0
51 changed files with 4037 additions and 197 deletions

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "活动"
}

View 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}`,
})
}
},
})

View 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>

View 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;
}

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "首页"
}

View 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',
})
},
})

View 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>

View 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;
}

View File

@@ -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 })
},
})

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "登录"
}

View 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: '已清空登录态',
})
},
})

View 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>

View 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;
}

View File

@@ -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() {

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "结果"
}

View 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()
},
})

View 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>

View 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;
}