完善多赛道联调与全局产品架构
This commit is contained in:
139
b2f.md
139
b2f.md
@@ -1,6 +1,6 @@
|
|||||||
# b2f
|
# b2f
|
||||||
> 文档版本:v1.0
|
> 文档版本:v1.3
|
||||||
> 最后更新:2026-04-02 09:01:17
|
> 最后更新:2026-04-02 15:25:40
|
||||||
|
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
@@ -54,6 +54,51 @@
|
|||||||
- frontend 是否确认正式流程只消费上述字段,不再自行推断 release URL
|
- frontend 是否确认正式流程只消费上述字段,不再自行推断 release URL
|
||||||
- 是否已解决:否
|
- 是否已解决:否
|
||||||
|
|
||||||
|
### B2F-015
|
||||||
|
|
||||||
|
- 时间:2026-04-02
|
||||||
|
- 谁提的:backend
|
||||||
|
- 当前事实:
|
||||||
|
- backend 已阅读前端多赛道文档:
|
||||||
|
- [多赛道 Variant 五层设计草案](D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md)
|
||||||
|
- [多赛道 Variant 前后端最小契约](D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md)
|
||||||
|
- backend 认可第一阶段先做“最小契约”,不先做完整后台模型
|
||||||
|
- backend 当前建议的第一阶段正式口径为:
|
||||||
|
- `play.assignmentMode`
|
||||||
|
- `play.courseVariants[]`
|
||||||
|
- `id`
|
||||||
|
- `name`
|
||||||
|
- `description`
|
||||||
|
- `routeCode`
|
||||||
|
- `selectable`
|
||||||
|
- `launch.variant.id`
|
||||||
|
- `launch.variant.name`
|
||||||
|
- `launch.variant.routeCode`
|
||||||
|
- `launch.variant.assignmentMode`
|
||||||
|
- `session / ongoing / recent / result` 摘要中补:
|
||||||
|
- `variantId`
|
||||||
|
- `variantName`
|
||||||
|
- `routeCode`
|
||||||
|
- backend 第一阶段实现目标仍然保持保守:
|
||||||
|
- 一个 session 只绑定一个最终 `variantId`
|
||||||
|
- `launch` 返回最终绑定结果
|
||||||
|
- 恢复链不重新分配 variant
|
||||||
|
- 当前兼容性约束:
|
||||||
|
- 如果 `assignmentMode=manual` 且前端暂时未传 `variantId`
|
||||||
|
- backend 当前会先回退到首个可选 variant,避免旧主链直接被打断
|
||||||
|
- backend 当前已完成第一阶段最小实现:
|
||||||
|
- `GET /events/{eventPublicID}/play`
|
||||||
|
- `POST /events/{eventPublicID}/launch`
|
||||||
|
- `GET /me/entry-home`
|
||||||
|
- `GET /sessions/{sessionPublicID}`
|
||||||
|
- `GET /sessions/{sessionPublicID}/result`
|
||||||
|
- `GET /me/results`
|
||||||
|
- `GET /me/sessions`
|
||||||
|
- 上述链路已能携带第一阶段 variant 摘要字段
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- frontend 可按这组字段开始第一阶段联调
|
||||||
|
- 是否已解决:是
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 已确认
|
## 已确认
|
||||||
@@ -121,6 +166,49 @@
|
|||||||
- frontend 继续按当前补报 / 重试逻辑联调
|
- frontend 继续按当前补报 / 重试逻辑联调
|
||||||
- 是否已解决:是
|
- 是否已解决:是
|
||||||
|
|
||||||
|
### B2F-016
|
||||||
|
|
||||||
|
- 时间:2026-04-02
|
||||||
|
- 谁提的:backend
|
||||||
|
- 当前事实:
|
||||||
|
- backend 已确认 `launch` 当前关键字段为前端正式联调契约:
|
||||||
|
- `resolvedRelease.manifestUrl`
|
||||||
|
- `resolvedRelease.releaseId`
|
||||||
|
- `business.sessionId`
|
||||||
|
- `business.sessionToken`
|
||||||
|
- `business.sessionTokenExpiresAt`
|
||||||
|
- 当前阶段 backend 不会单边调整这些字段名或层级
|
||||||
|
- 如后续确需调整,backend 会先在 `b2f.md` 明确通知,再安排联调变更
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- frontend 继续按当前字段接入,不做额外推断
|
||||||
|
- 是否已解决:是
|
||||||
|
|
||||||
|
### B2F-017
|
||||||
|
|
||||||
|
- 时间:2026-04-02
|
||||||
|
- 谁提的:backend
|
||||||
|
- 当前事实:
|
||||||
|
- backend 已完成对 ongoing 口径的代码回归确认
|
||||||
|
- 当前实现中:
|
||||||
|
- 只有 `launched` 和 `running` 会被识别为 ongoing
|
||||||
|
- `cancelled`、`failed`、`finished` 都不会再进入 ongoing
|
||||||
|
- `/me/entry-home` 与 `/events/{eventPublicID}/play` 当前都复用同一 ongoing 判定逻辑
|
||||||
|
- `/me/results` 当前只返回终态 session:
|
||||||
|
- `finished`
|
||||||
|
- `failed`
|
||||||
|
- `cancelled`
|
||||||
|
- 当前首页摘要、play 摘要、result 详情都会复用同一组 session 基础摘要字段:
|
||||||
|
- `id`
|
||||||
|
- `status`
|
||||||
|
- `eventId`
|
||||||
|
- `eventName`
|
||||||
|
- `releaseId`
|
||||||
|
- `configLabel`
|
||||||
|
- `routeCode`
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- frontend 可以按这套 ongoing / result 口径继续回归
|
||||||
|
- 是否已解决:是
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 阻塞
|
## 阻塞
|
||||||
@@ -226,6 +314,52 @@
|
|||||||
- 无
|
- 无
|
||||||
- 是否已解决:是
|
- 是否已解决:是
|
||||||
|
|
||||||
|
### B2F-018
|
||||||
|
|
||||||
|
- 时间:2026-04-02
|
||||||
|
- 谁提的:backend
|
||||||
|
- 当前事实:
|
||||||
|
- backend 已补一条可联调的 `manual` 多赛道 demo 活动:
|
||||||
|
- `eventPublicID = evt_demo_variant_manual_001`
|
||||||
|
- `releaseId = rel_demo_variant_manual_001`
|
||||||
|
- `channelCode = mini-demo`
|
||||||
|
- `channelType = wechat_mini`
|
||||||
|
- 当前 demo 配置为:
|
||||||
|
- `assignmentMode = manual`
|
||||||
|
- `courseVariants = [variant_a, variant_b]`
|
||||||
|
- 当前两条可选赛道:
|
||||||
|
- `variant_a`
|
||||||
|
- `name = A 线`
|
||||||
|
- `routeCode = route-variant-a`
|
||||||
|
- `variant_b`
|
||||||
|
- `name = B 线`
|
||||||
|
- `routeCode = route-variant-b`
|
||||||
|
- 该活动已由 `POST /dev/bootstrap-demo` 自动准备
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- 无
|
||||||
|
- 是否已解决:是
|
||||||
|
|
||||||
|
### B2F-019
|
||||||
|
|
||||||
|
- 时间:2026-04-02
|
||||||
|
- 谁提的:backend
|
||||||
|
- 当前事实:
|
||||||
|
- backend 已完成 `variant_b` 的 service 层回归验证
|
||||||
|
- 已确认从 `launch` 选定的 `variantId` 会稳定回流到:
|
||||||
|
- `GET /me/entry-home`
|
||||||
|
- `GET /sessions/{sessionPublicID}/result`
|
||||||
|
- `GET /me/results`
|
||||||
|
- 实测链路为:
|
||||||
|
- `play.assignmentMode=manual`
|
||||||
|
- `play.courseVariants=2`
|
||||||
|
- `launch.variant.id=variant_b`
|
||||||
|
- `entry-home recent.variantId=variant_b`
|
||||||
|
- `result.session.variantId=variant_b`
|
||||||
|
- `results[0].session.variantId=variant_b`
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- 无
|
||||||
|
- 是否已解决:是
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
@@ -244,6 +378,7 @@
|
|||||||
- frontend 当前优先配合:
|
- frontend 当前优先配合:
|
||||||
- 用当前 demo release 回归 `play -> launch -> map load`
|
- 用当前 demo release 回归 `play -> launch -> map load`
|
||||||
- 回归“继续恢复 / 放弃恢复”两条路径
|
- 回归“继续恢复 / 放弃恢复”两条路径
|
||||||
|
- 如确认进入多赛道第一阶段联调,请先回复 `B2F-015`
|
||||||
- 如发现状态口径不一致,直接在 `f2b.md` 标具体接口和返回值
|
- 如发现状态口径不一致,直接在 `f2b.md` 标具体接口和返回值
|
||||||
- 是否已解决:否
|
- 是否已解决:否
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backend
|
# Backend
|
||||||
> 文档版本:v1.0
|
> 文档版本:v1.1
|
||||||
> 最后更新:2026-04-02 08:28:05
|
> 最后更新:2026-04-02 09:35:44
|
||||||
|
|
||||||
|
|
||||||
这套后端现在已经能支撑一条完整主链:
|
这套后端现在已经能支撑一条完整主链:
|
||||||
@@ -46,5 +46,8 @@ go run .\cmd\api
|
|||||||
- 局生命周期:`start / finish / detail`
|
- 局生命周期:`start / finish / detail`
|
||||||
- 局后结果:`/sessions/{id}/result`、`/me/results`
|
- 局后结果:`/sessions/{id}/result`、`/me/results`
|
||||||
- 开发工作台:`/dev/workbench`
|
- 开发工作台:`/dev/workbench`
|
||||||
|
- 用户主链调试
|
||||||
|
- 资源对象与 Event 组装调试
|
||||||
|
- Build / Publish / Rollback 调试
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backend TodoList
|
# Backend TodoList
|
||||||
> 文档版本:v1.0
|
> 文档版本:v1.2
|
||||||
> 最后更新:2026-04-02 08:28:05
|
> 最后更新:2026-04-02 11:03:02
|
||||||
|
|
||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
@@ -37,6 +37,8 @@
|
|||||||
|
|
||||||
- `evt_demo_001` 的 release manifest 现已可正常加载
|
- `evt_demo_001` 的 release manifest 现已可正常加载
|
||||||
- 小程序已能进入地图
|
- 小程序已能进入地图
|
||||||
|
- `launch` 关键字段在当前阶段不再单边漂移
|
||||||
|
- `cancelled / failed / finished` 已从 ongoing 口径里收稳
|
||||||
- 模拟定位 / 调试日志问题已回到小程序与模拟器侧,不再属于 backend 当前阻塞
|
- 模拟定位 / 调试日志问题已回到小程序与模拟器侧,不再属于 backend 当前阻塞
|
||||||
|
|
||||||
前端当前需要配合的事项:
|
前端当前需要配合的事项:
|
||||||
@@ -160,27 +162,36 @@ backend 现在需要做的是:
|
|||||||
|
|
||||||
## 4. P1 应尽快做
|
## 4. P1 应尽快做
|
||||||
|
|
||||||
## 4.1 给首页 / play / result 的 ongoing 语义再做一次回归确认
|
## 4.1 多赛道 Variant 第一阶段最小契约
|
||||||
|
|
||||||
当前前端已经开始走:
|
当前前端已给出:
|
||||||
|
|
||||||
- 首页聚合
|
- [多赛道 Variant 五层设计草案](D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md)
|
||||||
- `event play`
|
- [多赛道 Variant 前后端最小契约](D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md)
|
||||||
- `launch`
|
|
||||||
- `session start / finish`
|
|
||||||
- 本地故障恢复
|
|
||||||
|
|
||||||
backend 建议再回归确认这几个接口对“进行中 session”的口径一致:
|
backend 当前建议第一阶段只做最小闭环:
|
||||||
|
|
||||||
- `/me/entry-home`
|
- `play.assignmentMode`
|
||||||
- `/events/{eventPublicID}/play`
|
- `play.courseVariants[]`
|
||||||
- `/sessions/{sessionPublicID}/result`
|
- `launch.variant.*`
|
||||||
|
- `session / result / ongoing / recent` 补 `variantId / variantName / routeCode`
|
||||||
|
|
||||||
重点确认:
|
当前目标:
|
||||||
|
|
||||||
1. `cancelled` 后不再继续出现在 ongoing 入口
|
1. 一个 session 最终只绑定一个 `variantId`
|
||||||
2. `failed` 后不再继续出现在 ongoing 入口
|
2. `launch` 返回最终绑定结果
|
||||||
3. `finished` 后结果页与首页摘要字段一致
|
3. 恢复链不重新分配 variant
|
||||||
|
4. 结果页、ongoing、历史结果都能追溯 variant
|
||||||
|
|
||||||
|
备注:
|
||||||
|
|
||||||
|
- 当前只先定最小契约,不先做完整后台 variant 编排模型
|
||||||
|
- 当前第一阶段最小后端链路已补入:
|
||||||
|
- `play.assignmentMode`
|
||||||
|
- `play.courseVariants[]`
|
||||||
|
- `launch.variant.*`
|
||||||
|
- `session / result / ongoing / recent` 的 `variantId / variantName / routeCode`
|
||||||
|
- 下一步应由前端按该契约联调,不再继续扩后台 variant 模型
|
||||||
|
|
||||||
## 4.2 增加用户身体资料读取接口
|
## 4.2 增加用户身体资料读取接口
|
||||||
|
|
||||||
@@ -318,18 +329,18 @@ backend 后面如果要接业务结果页,最好提前定:
|
|||||||
|
|
||||||
## 7. 我建议的最近动作
|
## 7. 我建议的最近动作
|
||||||
|
|
||||||
backend 现在最值得先做的,不是扩接口,而是先确认下面 3 条:
|
backend 现在最值得先做的,不是继续铺更多页面接口,而是先推进下面 3 条:
|
||||||
|
|
||||||
1. `finished / failed / cancelled` 三态语义
|
1. 与前端确认多赛道第一阶段最小契约
|
||||||
2. 放弃恢复是否写 `cancelled`
|
2. 已按最小契约扩完 `play -> launch -> session/result`
|
||||||
3. `start / finish` 是否按幂等处理
|
3. 再补用户身体资料接口和 workbench 恢复场景按钮
|
||||||
|
|
||||||
这 3 条一旦确定,前后端联调会顺很多。
|
这样不会打断当前主链,同时能把下一阶段多赛道联调接上。
|
||||||
|
|
||||||
## 8. 一句话结论
|
## 8. 一句话结论
|
||||||
|
|
||||||
当前 backend 最重要的任务不是“再加更多接口”,而是:
|
当前 backend 最重要的任务不是“再加更多接口”,而是:
|
||||||
|
|
||||||
> 先把 session 运行态语义、放弃恢复语义和 ongoing session 口径定稳,再继续扩后台配置系统。
|
> 在不破坏当前稳定主链的前提下,先把多赛道 Variant 第一阶段最小契约定稳,再继续向配置与后台模型延伸。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 开发说明
|
# 开发说明
|
||||||
> 文档版本:v1.0
|
> 文档版本:v1.1
|
||||||
> 最后更新:2026-04-02 08:28:05
|
> 最后更新:2026-04-02 09:35:44
|
||||||
|
|
||||||
|
|
||||||
## 1. 环境变量
|
## 1. 环境变量
|
||||||
@@ -51,6 +51,11 @@ cd D:\dev\cmr-mini\backend
|
|||||||
|
|
||||||
- [http://127.0.0.1:18090/dev/workbench](http://127.0.0.1:18090/dev/workbench)
|
- [http://127.0.0.1:18090/dev/workbench](http://127.0.0.1:18090/dev/workbench)
|
||||||
|
|
||||||
|
当前 workbench 已覆盖两类调试链:
|
||||||
|
|
||||||
|
- 用户主链:`bootstrap -> auth -> entry/home -> event play/launch -> session -> result`
|
||||||
|
- 后台运营链:`maps/playfields/resource-packs -> admin event source -> build -> publish -> rollback`
|
||||||
|
|
||||||
## 3. 当前开发约定
|
## 3. 当前开发约定
|
||||||
|
|
||||||
### 3.1 开发阶段先不用 Redis
|
### 3.1 开发阶段先不用 Redis
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# API 清单
|
# API 清单
|
||||||
> 文档版本:v1.0
|
> 文档版本:v1.1
|
||||||
> 最后更新:2026-04-02 09:01:17
|
> 最后更新:2026-04-02 11:05:32
|
||||||
|
|
||||||
|
|
||||||
本文档只记录当前 backend 已实现接口,不写未来规划接口。
|
本文档只记录当前 backend 已实现接口,不写未来规划接口。
|
||||||
@@ -121,6 +121,12 @@
|
|||||||
- `ongoingSession`
|
- `ongoingSession`
|
||||||
- `recentSession`
|
- `recentSession`
|
||||||
|
|
||||||
|
`ongoingSession / recentSession` 当前会额外带:
|
||||||
|
|
||||||
|
- `variantId`
|
||||||
|
- `variantName`
|
||||||
|
- `routeCode`
|
||||||
|
|
||||||
## 4. Event
|
## 4. Event
|
||||||
|
|
||||||
### `GET /events/{eventPublicID}`
|
### `GET /events/{eventPublicID}`
|
||||||
@@ -150,6 +156,8 @@
|
|||||||
- `event`
|
- `event`
|
||||||
- `release`
|
- `release`
|
||||||
- `resolvedRelease`
|
- `resolvedRelease`
|
||||||
|
- `play.assignmentMode`
|
||||||
|
- `play.courseVariants`
|
||||||
- `play.canLaunch`
|
- `play.canLaunch`
|
||||||
- `play.primaryAction`
|
- `play.primaryAction`
|
||||||
- `play.launchSource`
|
- `play.launchSource`
|
||||||
@@ -169,13 +177,21 @@
|
|||||||
请求体重点:
|
请求体重点:
|
||||||
|
|
||||||
- `releaseId`
|
- `releaseId`
|
||||||
|
- `variantId`
|
||||||
- `clientType`
|
- `clientType`
|
||||||
- `deviceKey`
|
- `deviceKey`
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- 如果当前 release 声明了 `play.courseVariants[]`
|
||||||
|
- `launch` 会返回最终绑定的 `launch.variant`
|
||||||
|
- 当前为兼容旧调用方,`assignmentMode=manual` 且未传 `variantId` 时,backend 会先回退到首个可选 variant
|
||||||
|
|
||||||
返回重点:
|
返回重点:
|
||||||
|
|
||||||
- `launch.source`
|
- `launch.source`
|
||||||
- `launch.resolvedRelease`
|
- `launch.resolvedRelease`
|
||||||
|
- `launch.variant`
|
||||||
- `launch.config`
|
- `launch.config`
|
||||||
- `launch.business.sessionId`
|
- `launch.business.sessionId`
|
||||||
- `launch.business.sessionToken`
|
- `launch.business.sessionToken`
|
||||||
@@ -228,6 +244,13 @@
|
|||||||
- `event`
|
- `event`
|
||||||
- `resolvedRelease`
|
- `resolvedRelease`
|
||||||
|
|
||||||
|
`session` 当前会额外带:
|
||||||
|
|
||||||
|
- `assignmentMode`
|
||||||
|
- `variantId`
|
||||||
|
- `variantName`
|
||||||
|
- `routeCode`
|
||||||
|
|
||||||
### `POST /sessions/{sessionPublicID}/start`
|
### `POST /sessions/{sessionPublicID}/start`
|
||||||
|
|
||||||
鉴权:
|
鉴权:
|
||||||
@@ -312,6 +335,9 @@
|
|||||||
|
|
||||||
- `releaseId`
|
- `releaseId`
|
||||||
- `configLabel`
|
- `configLabel`
|
||||||
|
- `variantId`
|
||||||
|
- `variantName`
|
||||||
|
- `routeCode`
|
||||||
|
|
||||||
### `GET /me/results`
|
### `GET /me/results`
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 核心流程
|
# 核心流程
|
||||||
> 文档版本:v1.0
|
> 文档版本:v1.1
|
||||||
> 最后更新:2026-04-02 08:28:05
|
> 最后更新:2026-04-02 11:03:02
|
||||||
|
|
||||||
|
|
||||||
## 1. 总流程
|
## 1. 总流程
|
||||||
@@ -100,6 +100,7 @@ APP 当前主链是手机号验证码:
|
|||||||
|
|
||||||
- 当前是否可启动
|
- 当前是否可启动
|
||||||
- 当前会落到哪份 `release`
|
- 当前会落到哪份 `release`
|
||||||
|
- 当前是否存在多赛道 `variant` 编排
|
||||||
- 是否有 ongoing session
|
- 是否有 ongoing session
|
||||||
- 当前推荐动作是什么
|
- 当前推荐动作是什么
|
||||||
|
|
||||||
@@ -112,12 +113,27 @@ APP 当前主链是手机号验证码:
|
|||||||
- `event`
|
- `event`
|
||||||
- `release`
|
- `release`
|
||||||
- `resolvedRelease`
|
- `resolvedRelease`
|
||||||
|
- `play.assignmentMode`
|
||||||
|
- `play.courseVariants[]`
|
||||||
- `play.canLaunch`
|
- `play.canLaunch`
|
||||||
- `play.primaryAction`
|
- `play.primaryAction`
|
||||||
- `play.launchSource`
|
- `play.launchSource`
|
||||||
- `play.ongoingSession`
|
- `play.ongoingSession`
|
||||||
- `play.recentSession`
|
- `play.recentSession`
|
||||||
|
|
||||||
|
当前多赛道第一阶段约束:
|
||||||
|
|
||||||
|
- `play.assignmentMode` 只先支持最小口径:
|
||||||
|
- `manual`
|
||||||
|
- `random`
|
||||||
|
- `server-assigned`
|
||||||
|
- `play.courseVariants[]` 只先返回准备页必需字段:
|
||||||
|
- `id`
|
||||||
|
- `name`
|
||||||
|
- `description`
|
||||||
|
- `routeCode`
|
||||||
|
- `selectable`
|
||||||
|
|
||||||
## 6. Launch 流程
|
## 6. Launch 流程
|
||||||
|
|
||||||
### 6.1 当前原则
|
### 6.1 当前原则
|
||||||
@@ -135,6 +151,7 @@ APP 当前主链是手机号验证码:
|
|||||||
当前请求体支持:
|
当前请求体支持:
|
||||||
|
|
||||||
- `releaseId`
|
- `releaseId`
|
||||||
|
- `variantId`
|
||||||
- `clientType`
|
- `clientType`
|
||||||
- `deviceKey`
|
- `deviceKey`
|
||||||
|
|
||||||
@@ -142,6 +159,7 @@ APP 当前主链是手机号验证码:
|
|||||||
|
|
||||||
- `launch.source`
|
- `launch.source`
|
||||||
- `launch.resolvedRelease`
|
- `launch.resolvedRelease`
|
||||||
|
- `launch.variant`
|
||||||
- `launch.config`
|
- `launch.config`
|
||||||
- `launch.business.sessionId`
|
- `launch.business.sessionId`
|
||||||
- `launch.business.sessionToken`
|
- `launch.business.sessionToken`
|
||||||
@@ -158,6 +176,14 @@ APP 当前主链是手机号验证码:
|
|||||||
- `launch.resolvedRelease.releaseId`
|
- `launch.resolvedRelease.releaseId`
|
||||||
- `launch.resolvedRelease.manifestUrl`
|
- `launch.resolvedRelease.manifestUrl`
|
||||||
- `launch.resolvedRelease.manifestChecksumSha256`
|
- `launch.resolvedRelease.manifestChecksumSha256`
|
||||||
|
- `launch.variant.id`
|
||||||
|
- `launch.variant.assignmentMode`
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- 如果活动声明了多赛道 variant,`launch` 会返回本局最终绑定的 `variant`
|
||||||
|
- 前端可以发起选择,但最终绑定以后端 `launch` 返回为准
|
||||||
|
- 故障恢复不重新分配 variant
|
||||||
|
|
||||||
而不是再拿 `event` 自己去猜。
|
而不是再拿 `event` 自己去猜。
|
||||||
|
|
||||||
@@ -195,6 +221,11 @@ APP 当前主链是手机号验证码:
|
|||||||
- `cancelled` 和 `failed` 都不再作为 ongoing session 返回
|
- `cancelled` 和 `failed` 都不再作为 ongoing session 返回
|
||||||
- “放弃恢复”当前正式收口为 `finish(cancelled)`
|
- “放弃恢复”当前正式收口为 `finish(cancelled)`
|
||||||
- 同一局旧 `sessionToken` 在 `finish(cancelled)` 场景允许继续使用
|
- 同一局旧 `sessionToken` 在 `finish(cancelled)` 场景允许继续使用
|
||||||
|
- 第一阶段若活动声明了多赛道,session 会固化:
|
||||||
|
- `assignmentMode`
|
||||||
|
- `variantId`
|
||||||
|
- `variantName`
|
||||||
|
- `routeCode`
|
||||||
|
|
||||||
### 7.4 幂等要求
|
### 7.4 幂等要求
|
||||||
|
|
||||||
@@ -232,6 +263,7 @@ APP 当前主链是手机号验证码:
|
|||||||
|
|
||||||
- 一个 event 未来可能发布新版本
|
- 一个 event 未来可能发布新版本
|
||||||
- 历史结果必须追溯到当时真实跑过的那份 release
|
- 历史结果必须追溯到当时真实跑过的那份 release
|
||||||
|
- 如果一场活动存在多个 variant,结果与历史摘要也必须能追溯本局 `variantId`
|
||||||
|
|
||||||
## 9. 当前最应该坚持的流程约束
|
## 9. 当前最应该坚持的流程约束
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,8 @@ type EntrySessionSummary struct {
|
|||||||
EventName string `json:"eventName"`
|
EventName string `json:"eventName"`
|
||||||
ReleaseID *string `json:"releaseId,omitempty"`
|
ReleaseID *string `json:"releaseId,omitempty"`
|
||||||
ConfigLabel *string `json:"configLabel,omitempty"`
|
ConfigLabel *string `json:"configLabel,omitempty"`
|
||||||
|
VariantID *string `json:"variantId,omitempty"`
|
||||||
|
VariantName *string `json:"variantName,omitempty"`
|
||||||
RouteCode *string `json:"routeCode,omitempty"`
|
RouteCode *string `json:"routeCode,omitempty"`
|
||||||
LaunchedAt string `json:"launchedAt"`
|
LaunchedAt string `json:"launchedAt"`
|
||||||
StartedAt *string `json:"startedAt,omitempty"`
|
StartedAt *string `json:"startedAt,omitempty"`
|
||||||
@@ -141,6 +143,8 @@ func buildEntrySessionSummary(session *postgres.Session) EntrySessionSummary {
|
|||||||
summary := EntrySessionSummary{
|
summary := EntrySessionSummary{
|
||||||
ID: session.SessionPublicID,
|
ID: session.SessionPublicID,
|
||||||
Status: session.Status,
|
Status: session.Status,
|
||||||
|
VariantID: session.VariantID,
|
||||||
|
VariantName: session.VariantName,
|
||||||
RouteCode: session.RouteCode,
|
RouteCode: session.RouteCode,
|
||||||
LaunchedAt: session.LaunchedAt.Format(timeRFC3339),
|
LaunchedAt: session.LaunchedAt.Format(timeRFC3339),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type EventPlayResult struct {
|
|||||||
} `json:"release,omitempty"`
|
} `json:"release,omitempty"`
|
||||||
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
|
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
|
||||||
Play struct {
|
Play struct {
|
||||||
|
AssignmentMode *string `json:"assignmentMode,omitempty"`
|
||||||
|
CourseVariants []CourseVariantView `json:"courseVariants,omitempty"`
|
||||||
CanLaunch bool `json:"canLaunch"`
|
CanLaunch bool `json:"canLaunch"`
|
||||||
PrimaryAction string `json:"primaryAction"`
|
PrimaryAction string `json:"primaryAction"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
@@ -77,6 +79,11 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu
|
|||||||
result.Event.DisplayName = event.DisplayName
|
result.Event.DisplayName = event.DisplayName
|
||||||
result.Event.Summary = event.Summary
|
result.Event.Summary = event.Summary
|
||||||
result.Event.Status = event.Status
|
result.Event.Status = event.Status
|
||||||
|
variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
|
||||||
|
result.Play.AssignmentMode = variantPlan.AssignmentMode
|
||||||
|
if len(variantPlan.CourseVariants) > 0 {
|
||||||
|
result.Play.CourseVariants = variantPlan.CourseVariants
|
||||||
|
}
|
||||||
if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
|
if event.CurrentReleasePubID != nil && event.ConfigLabel != nil && event.ManifestURL != nil {
|
||||||
result.Release = &struct {
|
result.Release = &struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type LaunchEventInput struct {
|
|||||||
EventPublicID string
|
EventPublicID string
|
||||||
UserID string
|
UserID string
|
||||||
ReleaseID string `json:"releaseId,omitempty"`
|
ReleaseID string `json:"releaseId,omitempty"`
|
||||||
|
VariantID string `json:"variantId,omitempty"`
|
||||||
ClientType string `json:"clientType"`
|
ClientType string `json:"clientType"`
|
||||||
DeviceKey string `json:"deviceKey"`
|
DeviceKey string `json:"deviceKey"`
|
||||||
}
|
}
|
||||||
@@ -49,6 +50,7 @@ type LaunchEventResult struct {
|
|||||||
Launch struct {
|
Launch struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
|
ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"`
|
||||||
|
Variant *VariantBindingView `json:"variant,omitempty"`
|
||||||
Config struct {
|
Config struct {
|
||||||
ConfigURL string `json:"configUrl"`
|
ConfigURL string `json:"configUrl"`
|
||||||
ConfigLabel string `json:"configLabel"`
|
ConfigLabel string `json:"configLabel"`
|
||||||
@@ -115,6 +117,7 @@ func (s *EventService) GetEventDetail(ctx context.Context, eventPublicID string)
|
|||||||
func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput) (*LaunchEventResult, error) {
|
func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput) (*LaunchEventResult, error) {
|
||||||
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
|
input.EventPublicID = strings.TrimSpace(input.EventPublicID)
|
||||||
input.ReleaseID = strings.TrimSpace(input.ReleaseID)
|
input.ReleaseID = strings.TrimSpace(input.ReleaseID)
|
||||||
|
input.VariantID = strings.TrimSpace(input.VariantID)
|
||||||
input.DeviceKey = strings.TrimSpace(input.DeviceKey)
|
input.DeviceKey = strings.TrimSpace(input.DeviceKey)
|
||||||
if err := validateClientType(input.ClientType); err != nil {
|
if err := validateClientType(input.ClientType); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -139,6 +142,24 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
|
|||||||
if input.ReleaseID != "" && input.ReleaseID != *event.CurrentReleasePubID {
|
if input.ReleaseID != "" && input.ReleaseID != *event.CurrentReleasePubID {
|
||||||
return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")
|
return nil, apperr.New(http.StatusConflict, "release_not_launchable", "requested release is not the current published release")
|
||||||
}
|
}
|
||||||
|
variantPlan := resolveVariantPlan(event.ReleasePayloadJSON)
|
||||||
|
variant, err := resolveLaunchVariant(variantPlan, input.VariantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
routeCode := event.RouteCode
|
||||||
|
var assignmentMode *string
|
||||||
|
var variantID *string
|
||||||
|
var variantName *string
|
||||||
|
if variant != nil {
|
||||||
|
resultMode := variant.AssignmentMode
|
||||||
|
assignmentMode = &resultMode
|
||||||
|
variantID = &variant.ID
|
||||||
|
variantName = &variant.Name
|
||||||
|
if variant.RouteCode != nil {
|
||||||
|
routeCode = variant.RouteCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tx, err := s.store.Begin(ctx)
|
tx, err := s.store.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -163,7 +184,10 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
|
|||||||
EventReleaseID: *event.CurrentReleaseID,
|
EventReleaseID: *event.CurrentReleaseID,
|
||||||
DeviceKey: input.DeviceKey,
|
DeviceKey: input.DeviceKey,
|
||||||
ClientType: input.ClientType,
|
ClientType: input.ClientType,
|
||||||
RouteCode: event.RouteCode,
|
AssignmentMode: assignmentMode,
|
||||||
|
VariantID: variantID,
|
||||||
|
VariantName: variantName,
|
||||||
|
RouteCode: routeCode,
|
||||||
SessionTokenHash: security.HashText(sessionToken),
|
SessionTokenHash: security.HashText(sessionToken),
|
||||||
SessionTokenExpiresAt: sessionTokenExpiresAt,
|
SessionTokenExpiresAt: sessionTokenExpiresAt,
|
||||||
})
|
})
|
||||||
@@ -180,16 +204,17 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput)
|
|||||||
result.Event.DisplayName = event.DisplayName
|
result.Event.DisplayName = event.DisplayName
|
||||||
result.Launch.Source = LaunchSourceEventCurrentRelease
|
result.Launch.Source = LaunchSourceEventCurrentRelease
|
||||||
result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
|
result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease)
|
||||||
|
result.Launch.Variant = variant
|
||||||
result.Launch.Config.ConfigURL = *event.ManifestURL
|
result.Launch.Config.ConfigURL = *event.ManifestURL
|
||||||
result.Launch.Config.ConfigLabel = *event.ConfigLabel
|
result.Launch.Config.ConfigLabel = *event.ConfigLabel
|
||||||
result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum
|
result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum
|
||||||
result.Launch.Config.ReleaseID = *event.CurrentReleasePubID
|
result.Launch.Config.ReleaseID = *event.CurrentReleasePubID
|
||||||
result.Launch.Config.RouteCode = event.RouteCode
|
result.Launch.Config.RouteCode = routeCode
|
||||||
result.Launch.Business.Source = "direct-event"
|
result.Launch.Business.Source = "direct-event"
|
||||||
result.Launch.Business.EventID = event.PublicID
|
result.Launch.Business.EventID = event.PublicID
|
||||||
result.Launch.Business.SessionID = session.SessionPublicID
|
result.Launch.Business.SessionID = session.SessionPublicID
|
||||||
result.Launch.Business.SessionToken = sessionToken
|
result.Launch.Business.SessionToken = sessionToken
|
||||||
result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
|
result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
|
||||||
result.Launch.Business.RouteCode = event.RouteCode
|
result.Launch.Business.RouteCode = routeCode
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type SessionResult struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ClientType string `json:"clientType"`
|
ClientType string `json:"clientType"`
|
||||||
DeviceKey string `json:"deviceKey"`
|
DeviceKey string `json:"deviceKey"`
|
||||||
|
AssignmentMode *string `json:"assignmentMode,omitempty"`
|
||||||
|
VariantID *string `json:"variantId,omitempty"`
|
||||||
|
VariantName *string `json:"variantName,omitempty"`
|
||||||
RouteCode *string `json:"routeCode,omitempty"`
|
RouteCode *string `json:"routeCode,omitempty"`
|
||||||
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
|
SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"`
|
||||||
LaunchedAt string `json:"launchedAt"`
|
LaunchedAt string `json:"launchedAt"`
|
||||||
@@ -264,6 +267,9 @@ func buildSessionResult(session *postgres.Session) *SessionResult {
|
|||||||
result.Session.Status = session.Status
|
result.Session.Status = session.Status
|
||||||
result.Session.ClientType = session.ClientType
|
result.Session.ClientType = session.ClientType
|
||||||
result.Session.DeviceKey = session.DeviceKey
|
result.Session.DeviceKey = session.DeviceKey
|
||||||
|
result.Session.AssignmentMode = session.AssignmentMode
|
||||||
|
result.Session.VariantID = session.VariantID
|
||||||
|
result.Session.VariantName = session.VariantName
|
||||||
result.Session.RouteCode = session.RouteCode
|
result.Session.RouteCode = session.RouteCode
|
||||||
result.Session.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
|
result.Session.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339)
|
||||||
result.Session.LaunchedAt = session.LaunchedAt.Format(time.RFC3339)
|
result.Session.LaunchedAt = session.LaunchedAt.Format(time.RFC3339)
|
||||||
|
|||||||
189
backend/internal/service/variant_contract.go
Normal file
189
backend/internal/service/variant_contract.go
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cmr-backend/internal/apperr"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AssignmentModeManual = "manual"
|
||||||
|
AssignmentModeRandom = "random"
|
||||||
|
AssignmentModeServerAssigned = "server-assigned"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CourseVariantView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
RouteCode *string `json:"routeCode,omitempty"`
|
||||||
|
Selectable bool `json:"selectable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VariantBindingView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
RouteCode *string `json:"routeCode,omitempty"`
|
||||||
|
AssignmentMode string `json:"assignmentMode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VariantPlan struct {
|
||||||
|
AssignmentMode *string
|
||||||
|
CourseVariants []CourseVariantView
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveVariantPlan(payloadJSON *string) VariantPlan {
|
||||||
|
if payloadJSON == nil || strings.TrimSpace(*payloadJSON) == "" {
|
||||||
|
return VariantPlan{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(*payloadJSON), &payload); err != nil {
|
||||||
|
return VariantPlan{}
|
||||||
|
}
|
||||||
|
|
||||||
|
play, _ := payload["play"].(map[string]any)
|
||||||
|
if len(play) == 0 {
|
||||||
|
return VariantPlan{}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := VariantPlan{}
|
||||||
|
if rawMode, ok := play["assignmentMode"].(string); ok {
|
||||||
|
if normalized := normalizeAssignmentMode(rawMode); normalized != nil {
|
||||||
|
result.AssignmentMode = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawVariants, _ := play["courseVariants"].([]any)
|
||||||
|
if len(rawVariants) == 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range rawVariants {
|
||||||
|
item, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, _ := item["id"].(string)
|
||||||
|
name, _ := item["name"].(string)
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if id == "" || name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var description *string
|
||||||
|
if value, ok := item["description"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
description = &trimmed
|
||||||
|
}
|
||||||
|
var routeCode *string
|
||||||
|
if value, ok := item["routeCode"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
routeCode = &trimmed
|
||||||
|
}
|
||||||
|
selectable := true
|
||||||
|
if value, ok := item["selectable"].(bool); ok {
|
||||||
|
selectable = value
|
||||||
|
}
|
||||||
|
result.CourseVariants = append(result.CourseVariants, CourseVariantView{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
RouteCode: routeCode,
|
||||||
|
Selectable: selectable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLaunchVariant(plan VariantPlan, requestedVariantID string) (*VariantBindingView, error) {
|
||||||
|
requestedVariantID = strings.TrimSpace(requestedVariantID)
|
||||||
|
if len(plan.CourseVariants) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := AssignmentModeManual
|
||||||
|
if plan.AssignmentMode != nil {
|
||||||
|
mode = *plan.AssignmentMode
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestedVariantID != "" {
|
||||||
|
for _, item := range plan.CourseVariants {
|
||||||
|
if item.ID == requestedVariantID {
|
||||||
|
if !item.Selectable && mode == AssignmentModeManual {
|
||||||
|
return nil, apperr.New(http.StatusBadRequest, "variant_not_selectable", "requested variant is not selectable")
|
||||||
|
}
|
||||||
|
return &VariantBindingView{
|
||||||
|
ID: item.ID,
|
||||||
|
Name: item.Name,
|
||||||
|
RouteCode: item.RouteCode,
|
||||||
|
AssignmentMode: mode,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, apperr.New(http.StatusBadRequest, "variant_not_found", "requested variant does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
selected, err := selectDefaultVariant(plan.CourseVariants, mode)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &VariantBindingView{
|
||||||
|
ID: selected.ID,
|
||||||
|
Name: selected.Name,
|
||||||
|
RouteCode: selected.RouteCode,
|
||||||
|
AssignmentMode: mode,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAssignmentMode(value string) *string {
|
||||||
|
switch strings.TrimSpace(value) {
|
||||||
|
case AssignmentModeManual:
|
||||||
|
mode := AssignmentModeManual
|
||||||
|
return &mode
|
||||||
|
case AssignmentModeRandom:
|
||||||
|
mode := AssignmentModeRandom
|
||||||
|
return &mode
|
||||||
|
case AssignmentModeServerAssigned:
|
||||||
|
mode := AssignmentModeServerAssigned
|
||||||
|
return &mode
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectDefaultVariant(items []CourseVariantView, mode string) (*CourseVariantView, error) {
|
||||||
|
candidates := make([]CourseVariantView, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Selectable {
|
||||||
|
candidates = append(candidates, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
candidates = append(candidates, items...)
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, apperr.New(http.StatusBadRequest, "variant_not_found", "course variants are empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case AssignmentModeRandom:
|
||||||
|
index, err := rand.Int(rand.Reader, big.NewInt(int64(len(candidates))))
|
||||||
|
if err != nil {
|
||||||
|
return nil, apperr.New(http.StatusInternalServerError, "variant_select_failed", fmt.Sprintf("failed to select random variant: %v", err))
|
||||||
|
}
|
||||||
|
selected := candidates[int(index.Int64())]
|
||||||
|
return &selected, nil
|
||||||
|
case AssignmentModeServerAssigned, AssignmentModeManual:
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
selected := candidates[0]
|
||||||
|
return &selected, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ type DemoBootstrapSummary struct {
|
|||||||
SourceID string `json:"sourceId"`
|
SourceID string `json:"sourceId"`
|
||||||
BuildID string `json:"buildId"`
|
BuildID string `json:"buildId"`
|
||||||
CardID string `json:"cardId"`
|
CardID string `json:"cardId"`
|
||||||
|
VariantManualEventID string `json:"variantManualEventId"`
|
||||||
|
VariantManualRelease string `json:"variantManualReleaseId"`
|
||||||
|
VariantManualCardID string `json:"variantManualCardId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, error) {
|
func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, error) {
|
||||||
@@ -88,7 +91,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
|||||||
$1,
|
$1,
|
||||||
1,
|
1,
|
||||||
'Demo Config v1',
|
'Demo Config v1',
|
||||||
'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json',
|
'https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json',
|
||||||
'demo-checksum-001',
|
'demo-checksum-001',
|
||||||
'route-demo-001',
|
'route-demo-001',
|
||||||
'published'
|
'published'
|
||||||
@@ -224,7 +227,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
|||||||
EventReleaseID: releaseRow.ID,
|
EventReleaseID: releaseRow.ID,
|
||||||
AssetType: "manifest",
|
AssetType: "manifest",
|
||||||
AssetKey: "manifest",
|
AssetKey: "manifest",
|
||||||
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json",
|
AssetURL: "https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json",
|
||||||
Checksum: &manifestChecksum,
|
Checksum: &manifestChecksum,
|
||||||
Meta: map[string]any{"source": "release-manifest"},
|
Meta: map[string]any{"source": "release-manifest"},
|
||||||
},
|
},
|
||||||
@@ -308,6 +311,135 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
|||||||
return nil, fmt.Errorf("ensure demo card: %w", err)
|
return nil, fmt.Errorf("ensure demo card: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var manualEventID string
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO events (
|
||||||
|
tenant_id, event_public_id, slug, display_name, summary, status
|
||||||
|
)
|
||||||
|
VALUES ($1, 'evt_demo_variant_manual_001', 'demo-variant-manual-run', 'Demo Variant Manual Run', 'Manual 多赛道联调活动', 'active')
|
||||||
|
ON CONFLICT (event_public_id) DO UPDATE SET
|
||||||
|
tenant_id = EXCLUDED.tenant_id,
|
||||||
|
slug = EXCLUDED.slug,
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
summary = EXCLUDED.summary,
|
||||||
|
status = EXCLUDED.status
|
||||||
|
RETURNING id
|
||||||
|
`, tenantID).Scan(&manualEventID); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure variant manual demo event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var manualReleaseRow struct {
|
||||||
|
ID string
|
||||||
|
PublicID string
|
||||||
|
}
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO event_releases (
|
||||||
|
release_public_id,
|
||||||
|
event_id,
|
||||||
|
release_no,
|
||||||
|
config_label,
|
||||||
|
manifest_url,
|
||||||
|
manifest_checksum_sha256,
|
||||||
|
route_code,
|
||||||
|
status,
|
||||||
|
payload_jsonb
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'rel_demo_variant_manual_001',
|
||||||
|
$1,
|
||||||
|
1,
|
||||||
|
'Demo Variant Manual Config v1',
|
||||||
|
'https://oss-mbh5.colormaprun.com/gotomars/event/releases/evt_demo_001/rel_e7dd953743c5c0d2/manifest.json',
|
||||||
|
'demo-variant-checksum-001',
|
||||||
|
'route-variant-a',
|
||||||
|
'published',
|
||||||
|
$2::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (release_public_id) DO UPDATE SET
|
||||||
|
event_id = EXCLUDED.event_id,
|
||||||
|
config_label = EXCLUDED.config_label,
|
||||||
|
manifest_url = EXCLUDED.manifest_url,
|
||||||
|
manifest_checksum_sha256 = EXCLUDED.manifest_checksum_sha256,
|
||||||
|
route_code = EXCLUDED.route_code,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
payload_jsonb = EXCLUDED.payload_jsonb
|
||||||
|
RETURNING id, release_public_id
|
||||||
|
`, manualEventID, `{
|
||||||
|
"play": {
|
||||||
|
"assignmentMode": "manual",
|
||||||
|
"courseVariants": [
|
||||||
|
{
|
||||||
|
"id": "variant_a",
|
||||||
|
"name": "A 线",
|
||||||
|
"description": "短线体验版",
|
||||||
|
"routeCode": "route-variant-a",
|
||||||
|
"selectable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "variant_b",
|
||||||
|
"name": "B 线",
|
||||||
|
"description": "长线挑战版",
|
||||||
|
"routeCode": "route-variant-b",
|
||||||
|
"selectable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`).Scan(&manualReleaseRow.ID, &manualReleaseRow.PublicID); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure variant manual demo release: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE events
|
||||||
|
SET current_release_id = $2
|
||||||
|
WHERE id = $1
|
||||||
|
`, manualEventID, manualReleaseRow.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("attach variant manual demo release: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var manualCardPublicID string
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO cards (
|
||||||
|
card_public_id,
|
||||||
|
tenant_id,
|
||||||
|
entry_channel_id,
|
||||||
|
card_type,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
cover_url,
|
||||||
|
event_id,
|
||||||
|
display_slot,
|
||||||
|
display_priority,
|
||||||
|
status
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'card_demo_variant_manual_001',
|
||||||
|
$1,
|
||||||
|
$2,
|
||||||
|
'event',
|
||||||
|
'Demo Variant Manual Run',
|
||||||
|
'多赛道手动选择联调',
|
||||||
|
'https://oss-mbh5.colormaprun.com/gotomars/assets/demo-cover.jpg',
|
||||||
|
$3,
|
||||||
|
'home_primary',
|
||||||
|
95,
|
||||||
|
'active'
|
||||||
|
)
|
||||||
|
ON CONFLICT (card_public_id) DO UPDATE SET
|
||||||
|
tenant_id = EXCLUDED.tenant_id,
|
||||||
|
entry_channel_id = EXCLUDED.entry_channel_id,
|
||||||
|
card_type = EXCLUDED.card_type,
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
subtitle = EXCLUDED.subtitle,
|
||||||
|
cover_url = EXCLUDED.cover_url,
|
||||||
|
event_id = EXCLUDED.event_id,
|
||||||
|
display_slot = EXCLUDED.display_slot,
|
||||||
|
display_priority = EXCLUDED.display_priority,
|
||||||
|
status = EXCLUDED.status
|
||||||
|
RETURNING card_public_id
|
||||||
|
`, tenantID, channelID, manualEventID).Scan(&manualCardPublicID); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure variant manual demo card: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -320,5 +452,8 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro
|
|||||||
SourceID: source.ID,
|
SourceID: source.ID,
|
||||||
BuildID: build.ID,
|
BuildID: build.ID,
|
||||||
CardID: cardPublicID,
|
CardID: cardPublicID,
|
||||||
|
VariantManualEventID: "evt_demo_variant_manual_001",
|
||||||
|
VariantManualRelease: manualReleaseRow.PublicID,
|
||||||
|
VariantManualCardID: manualCardPublicID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Event struct {
|
|||||||
ManifestURL *string
|
ManifestURL *string
|
||||||
ManifestChecksum *string
|
ManifestChecksum *string
|
||||||
RouteCode *string
|
RouteCode *string
|
||||||
|
ReleasePayloadJSON *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventRelease struct {
|
type EventRelease struct {
|
||||||
@@ -45,6 +46,9 @@ type CreateGameSessionParams struct {
|
|||||||
EventReleaseID string
|
EventReleaseID string
|
||||||
DeviceKey string
|
DeviceKey string
|
||||||
ClientType string
|
ClientType string
|
||||||
|
AssignmentMode *string
|
||||||
|
VariantID *string
|
||||||
|
VariantName *string
|
||||||
RouteCode *string
|
RouteCode *string
|
||||||
SessionTokenHash string
|
SessionTokenHash string
|
||||||
SessionTokenExpiresAt time.Time
|
SessionTokenExpiresAt time.Time
|
||||||
@@ -58,6 +62,9 @@ type GameSession struct {
|
|||||||
EventReleaseID string
|
EventReleaseID string
|
||||||
DeviceKey string
|
DeviceKey string
|
||||||
ClientType string
|
ClientType string
|
||||||
|
AssignmentMode *string
|
||||||
|
VariantID *string
|
||||||
|
VariantName *string
|
||||||
RouteCode *string
|
RouteCode *string
|
||||||
Status string
|
Status string
|
||||||
SessionTokenExpiresAt time.Time
|
SessionTokenExpiresAt time.Time
|
||||||
@@ -77,7 +84,8 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
|
|||||||
er.config_label,
|
er.config_label,
|
||||||
er.manifest_url,
|
er.manifest_url,
|
||||||
er.manifest_checksum_sha256,
|
er.manifest_checksum_sha256,
|
||||||
er.route_code
|
er.route_code,
|
||||||
|
er.payload_jsonb::text
|
||||||
FROM events e
|
FROM events e
|
||||||
LEFT JOIN event_releases er ON er.id = e.current_release_id
|
LEFT JOIN event_releases er ON er.id = e.current_release_id
|
||||||
WHERE e.event_public_id = $1
|
WHERE e.event_public_id = $1
|
||||||
@@ -98,6 +106,7 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (*
|
|||||||
&event.ManifestURL,
|
&event.ManifestURL,
|
||||||
&event.ManifestChecksum,
|
&event.ManifestChecksum,
|
||||||
&event.RouteCode,
|
&event.RouteCode,
|
||||||
|
&event.ReleasePayloadJSON,
|
||||||
)
|
)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -122,7 +131,8 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
|
|||||||
er.config_label,
|
er.config_label,
|
||||||
er.manifest_url,
|
er.manifest_url,
|
||||||
er.manifest_checksum_sha256,
|
er.manifest_checksum_sha256,
|
||||||
er.route_code
|
er.route_code,
|
||||||
|
er.payload_jsonb::text
|
||||||
FROM events e
|
FROM events e
|
||||||
LEFT JOIN event_releases er ON er.id = e.current_release_id
|
LEFT JOIN event_releases er ON er.id = e.current_release_id
|
||||||
WHERE e.id = $1
|
WHERE e.id = $1
|
||||||
@@ -143,6 +153,7 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error
|
|||||||
&event.ManifestURL,
|
&event.ManifestURL,
|
||||||
&event.ManifestChecksum,
|
&event.ManifestChecksum,
|
||||||
&event.RouteCode,
|
&event.RouteCode,
|
||||||
|
&event.ReleasePayloadJSON,
|
||||||
)
|
)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -235,13 +246,16 @@ func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameS
|
|||||||
event_release_id,
|
event_release_id,
|
||||||
device_key,
|
device_key,
|
||||||
client_type,
|
client_type,
|
||||||
|
assignment_mode,
|
||||||
|
variant_id,
|
||||||
|
variant_name,
|
||||||
route_code,
|
route_code,
|
||||||
session_token_hash,
|
session_token_hash,
|
||||||
session_token_expires_at
|
session_token_expires_at
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
RETURNING id, session_public_id, user_id, event_id, event_release_id, device_key, client_type, route_code, status, session_token_expires_at
|
RETURNING id, session_public_id, user_id, event_id, event_release_id, device_key, client_type, assignment_mode, variant_id, variant_name, route_code, status, session_token_expires_at
|
||||||
`, params.SessionPublicID, params.UserID, params.EventID, params.EventReleaseID, params.DeviceKey, params.ClientType, params.RouteCode, params.SessionTokenHash, params.SessionTokenExpiresAt)
|
`, params.SessionPublicID, params.UserID, params.EventID, params.EventReleaseID, params.DeviceKey, params.ClientType, params.AssignmentMode, params.VariantID, params.VariantName, params.RouteCode, params.SessionTokenHash, params.SessionTokenExpiresAt)
|
||||||
|
|
||||||
var session GameSession
|
var session GameSession
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -252,6 +266,9 @@ func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameS
|
|||||||
&session.EventReleaseID,
|
&session.EventReleaseID,
|
||||||
&session.DeviceKey,
|
&session.DeviceKey,
|
||||||
&session.ClientType,
|
&session.ClientType,
|
||||||
|
&session.AssignmentMode,
|
||||||
|
&session.VariantID,
|
||||||
|
&session.VariantName,
|
||||||
&session.RouteCode,
|
&session.RouteCode,
|
||||||
&session.Status,
|
&session.Status,
|
||||||
&session.SessionTokenExpiresAt,
|
&session.SessionTokenExpiresAt,
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ func (s *Store) GetSessionResultByPublicID(ctx context.Context, sessionPublicID
|
|||||||
er.manifest_checksum_sha256,
|
er.manifest_checksum_sha256,
|
||||||
gs.device_key,
|
gs.device_key,
|
||||||
gs.client_type,
|
gs.client_type,
|
||||||
|
gs.assignment_mode,
|
||||||
|
gs.variant_id,
|
||||||
|
gs.variant_name,
|
||||||
gs.route_code,
|
gs.route_code,
|
||||||
gs.status,
|
gs.status,
|
||||||
gs.session_token_hash,
|
gs.session_token_hash,
|
||||||
@@ -149,6 +152,9 @@ func (s *Store) ListSessionResultsByUserID(ctx context.Context, userID string, l
|
|||||||
er.manifest_checksum_sha256,
|
er.manifest_checksum_sha256,
|
||||||
gs.device_key,
|
gs.device_key,
|
||||||
gs.client_type,
|
gs.client_type,
|
||||||
|
gs.assignment_mode,
|
||||||
|
gs.variant_id,
|
||||||
|
gs.variant_name,
|
||||||
gs.route_code,
|
gs.route_code,
|
||||||
gs.status,
|
gs.status,
|
||||||
gs.session_token_hash,
|
gs.session_token_hash,
|
||||||
@@ -244,6 +250,9 @@ func scanSessionResultRecord(row pgx.Row) (*SessionResultRecord, error) {
|
|||||||
&record.ManifestChecksum,
|
&record.ManifestChecksum,
|
||||||
&record.DeviceKey,
|
&record.DeviceKey,
|
||||||
&record.ClientType,
|
&record.ClientType,
|
||||||
|
&record.AssignmentMode,
|
||||||
|
&record.VariantID,
|
||||||
|
&record.VariantName,
|
||||||
&record.RouteCode,
|
&record.RouteCode,
|
||||||
&record.Status,
|
&record.Status,
|
||||||
&record.SessionTokenHash,
|
&record.SessionTokenHash,
|
||||||
@@ -317,6 +326,9 @@ func scanSessionResultRecordFromRows(rows pgx.Rows) (*SessionResultRecord, error
|
|||||||
&record.ManifestChecksum,
|
&record.ManifestChecksum,
|
||||||
&record.DeviceKey,
|
&record.DeviceKey,
|
||||||
&record.ClientType,
|
&record.ClientType,
|
||||||
|
&record.AssignmentMode,
|
||||||
|
&record.VariantID,
|
||||||
|
&record.VariantName,
|
||||||
&record.RouteCode,
|
&record.RouteCode,
|
||||||
&record.Status,
|
&record.Status,
|
||||||
&record.SessionTokenHash,
|
&record.SessionTokenHash,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ type Session struct {
|
|||||||
ManifestChecksum *string
|
ManifestChecksum *string
|
||||||
DeviceKey string
|
DeviceKey string
|
||||||
ClientType string
|
ClientType string
|
||||||
|
AssignmentMode *string
|
||||||
|
VariantID *string
|
||||||
|
VariantName *string
|
||||||
RouteCode *string
|
RouteCode *string
|
||||||
Status string
|
Status string
|
||||||
SessionTokenHash string
|
SessionTokenHash string
|
||||||
@@ -51,6 +54,9 @@ func (s *Store) GetSessionByPublicID(ctx context.Context, sessionPublicID string
|
|||||||
er.manifest_checksum_sha256,
|
er.manifest_checksum_sha256,
|
||||||
gs.device_key,
|
gs.device_key,
|
||||||
gs.client_type,
|
gs.client_type,
|
||||||
|
gs.assignment_mode,
|
||||||
|
gs.variant_id,
|
||||||
|
gs.variant_name,
|
||||||
gs.route_code,
|
gs.route_code,
|
||||||
gs.status,
|
gs.status,
|
||||||
gs.session_token_hash,
|
gs.session_token_hash,
|
||||||
@@ -83,6 +89,9 @@ func (s *Store) GetSessionByPublicIDForUpdate(ctx context.Context, tx Tx, sessio
|
|||||||
er.manifest_checksum_sha256,
|
er.manifest_checksum_sha256,
|
||||||
gs.device_key,
|
gs.device_key,
|
||||||
gs.client_type,
|
gs.client_type,
|
||||||
|
gs.assignment_mode,
|
||||||
|
gs.variant_id,
|
||||||
|
gs.variant_name,
|
||||||
gs.route_code,
|
gs.route_code,
|
||||||
gs.status,
|
gs.status,
|
||||||
gs.session_token_hash,
|
gs.session_token_hash,
|
||||||
@@ -119,6 +128,9 @@ func (s *Store) ListSessionsByUserID(ctx context.Context, userID string, limit i
|
|||||||
er.manifest_checksum_sha256,
|
er.manifest_checksum_sha256,
|
||||||
gs.device_key,
|
gs.device_key,
|
||||||
gs.client_type,
|
gs.client_type,
|
||||||
|
gs.assignment_mode,
|
||||||
|
gs.variant_id,
|
||||||
|
gs.variant_name,
|
||||||
gs.route_code,
|
gs.route_code,
|
||||||
gs.status,
|
gs.status,
|
||||||
gs.session_token_hash,
|
gs.session_token_hash,
|
||||||
@@ -172,6 +184,9 @@ func (s *Store) ListSessionsByUserAndEvent(ctx context.Context, userID, eventID
|
|||||||
er.manifest_checksum_sha256,
|
er.manifest_checksum_sha256,
|
||||||
gs.device_key,
|
gs.device_key,
|
||||||
gs.client_type,
|
gs.client_type,
|
||||||
|
gs.assignment_mode,
|
||||||
|
gs.variant_id,
|
||||||
|
gs.variant_name,
|
||||||
gs.route_code,
|
gs.route_code,
|
||||||
gs.status,
|
gs.status,
|
||||||
gs.session_token_hash,
|
gs.session_token_hash,
|
||||||
@@ -249,6 +264,9 @@ func scanSession(row pgx.Row) (*Session, error) {
|
|||||||
&session.ManifestChecksum,
|
&session.ManifestChecksum,
|
||||||
&session.DeviceKey,
|
&session.DeviceKey,
|
||||||
&session.ClientType,
|
&session.ClientType,
|
||||||
|
&session.AssignmentMode,
|
||||||
|
&session.VariantID,
|
||||||
|
&session.VariantName,
|
||||||
&session.RouteCode,
|
&session.RouteCode,
|
||||||
&session.Status,
|
&session.Status,
|
||||||
&session.SessionTokenHash,
|
&session.SessionTokenHash,
|
||||||
@@ -282,6 +300,9 @@ func scanSessionFromRows(rows pgx.Rows) (*Session, error) {
|
|||||||
&session.ManifestChecksum,
|
&session.ManifestChecksum,
|
||||||
&session.DeviceKey,
|
&session.DeviceKey,
|
||||||
&session.ClientType,
|
&session.ClientType,
|
||||||
|
&session.AssignmentMode,
|
||||||
|
&session.VariantID,
|
||||||
|
&session.VariantName,
|
||||||
&session.RouteCode,
|
&session.RouteCode,
|
||||||
&session.Status,
|
&session.Status,
|
||||||
&session.SessionTokenHash,
|
&session.SessionTokenHash,
|
||||||
|
|||||||
11
backend/migrations/0007_variant_minimal.sql
Normal file
11
backend/migrations/0007_variant_minimal.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE game_sessions
|
||||||
|
ADD COLUMN assignment_mode TEXT CHECK (assignment_mode IN ('manual', 'random', 'server-assigned')),
|
||||||
|
ADD COLUMN variant_id TEXT,
|
||||||
|
ADD COLUMN variant_name TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX game_sessions_variant_id_idx ON game_sessions(variant_id);
|
||||||
|
CREATE INDEX game_sessions_assignment_mode_idx ON game_sessions(assignment_mode);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -46,4 +46,15 @@ if ($workbenchAddr.StartsWith(":")) {
|
|||||||
Write-Host ("http://" + $workbenchAddr + "/dev/workbench")
|
Write-Host ("http://" + $workbenchAddr + "/dev/workbench")
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
go run .\cmd\api
|
$exePath = Join-Path $backendDir "cmr-backend.exe"
|
||||||
|
|
||||||
|
Write-Host "Build:" -ForegroundColor Yellow
|
||||||
|
Write-Host $exePath
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
go build -o $exePath .\cmd\api
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "go build failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $exePath
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ if (-not (Test-Path $scriptPath)) {
|
|||||||
|
|
||||||
Set-Location $backendDir
|
Set-Location $backendDir
|
||||||
|
|
||||||
powershell -ExecutionPolicy Bypass -File $scriptPath
|
& $scriptPath
|
||||||
|
|||||||
324
doc/gameplay/APP全局产品架构草案.md
Normal file
324
doc/gameplay/APP全局产品架构草案.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# APP全局产品架构草案
|
||||||
|
> 文档版本:v1.0
|
||||||
|
> 最后更新:2026-04-02 18:10:04
|
||||||
|
|
||||||
|
本文档用于整理当前 APP 级产品逻辑的整体设想,作为后续页面架构、后端对象模型、联调边界和默认流程设计的上层基线。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 总体判断
|
||||||
|
|
||||||
|
当前产品不应再简单理解为“地图游戏程序”,更准确的定位应当是:
|
||||||
|
|
||||||
|
**以地图为资源底座、以活动为对外核心、以对局为过程、以用户资产为沉淀的运动游戏系统。**
|
||||||
|
|
||||||
|
这意味着:
|
||||||
|
|
||||||
|
- 地图是场地资产和体验载体
|
||||||
|
- 活动是运营层、展示层、商业层和用户入口
|
||||||
|
- Session 是一次真实对局过程
|
||||||
|
- 历史、成就、奖励、资料等属于用户长期资产
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 一级模块建议
|
||||||
|
|
||||||
|
建议 APP 长期按 5 个一级模块组织。
|
||||||
|
|
||||||
|
### 2.1 首页 / 发现
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 地区浏览
|
||||||
|
- 地图列表
|
||||||
|
- 推荐活动
|
||||||
|
- 默认体验入口
|
||||||
|
- 宣传入口
|
||||||
|
|
||||||
|
适用对象:
|
||||||
|
|
||||||
|
- 游客
|
||||||
|
- 初次进入的新用户
|
||||||
|
- 以地图体验为目标的训练型用户
|
||||||
|
|
||||||
|
### 2.2 活动 / 赛事
|
||||||
|
|
||||||
|
这是系统核心模块。
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 活动卡片列表
|
||||||
|
- 活动详情
|
||||||
|
- 报名
|
||||||
|
- 签到
|
||||||
|
- 开局
|
||||||
|
- 公告
|
||||||
|
- 排行榜
|
||||||
|
- 社交或分享入口
|
||||||
|
- 多地图活动聚合
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 活动页本质上是系统的对外展示壳和运营壳
|
||||||
|
- 不同客户的活动页、排行榜、宣传页都可能是定制化的
|
||||||
|
- 这里适合长期采用“原生模板 + 原生 DSL + H5 增强”的分层方案
|
||||||
|
|
||||||
|
### 2.3 地图体验
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 地图默认体验活动
|
||||||
|
- 地图自由体验
|
||||||
|
- 训练入口
|
||||||
|
- 正式进入地图游戏过程
|
||||||
|
- 游戏过程中的内容、规则、结算
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 地图体验层是运行层,不是对外运营主入口
|
||||||
|
- 游客主要通过这一层进入体验
|
||||||
|
|
||||||
|
### 2.4 我的
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 历史记录
|
||||||
|
- 成绩详情
|
||||||
|
- 报名信息
|
||||||
|
- 全局头像与昵称
|
||||||
|
- 成就
|
||||||
|
- 奖励展示
|
||||||
|
- 收藏与长期资产
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 历史、成就、奖励不应分散在不同页面里,建议统一收口在用户资产中心
|
||||||
|
|
||||||
|
### 2.5 系统
|
||||||
|
|
||||||
|
负责:
|
||||||
|
|
||||||
|
- 设置
|
||||||
|
- 帮助
|
||||||
|
- 使用说明
|
||||||
|
- 反馈
|
||||||
|
- 关于
|
||||||
|
- 调试入口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心对象模型
|
||||||
|
|
||||||
|
建议长期围绕 4 个核心对象组织前后端模型。
|
||||||
|
|
||||||
|
### 3.1 Map
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- 地图资源
|
||||||
|
- 场地资产
|
||||||
|
- 瓦片与底图
|
||||||
|
- 点位、图层、默认体验挂载位置
|
||||||
|
|
||||||
|
### 3.2 Event
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- 活动
|
||||||
|
- 赛事
|
||||||
|
- 运营包装壳
|
||||||
|
- 报名、签到、排行榜、宣传、社交等活动级能力
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 一个 Event 可以挂一个地图,也可以挂多个地图
|
||||||
|
- 一个地图也可以承载多个 Event
|
||||||
|
|
||||||
|
### 3.3 Session
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- 一次具体开局记录
|
||||||
|
- 一次真实游戏过程
|
||||||
|
- 与规则、成绩、恢复、结果页直接相关
|
||||||
|
|
||||||
|
### 3.4 User Asset
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- 历史记录
|
||||||
|
- 成就
|
||||||
|
- 奖励
|
||||||
|
- 头像昵称
|
||||||
|
- 报名资料快照
|
||||||
|
- 收藏与长期沉淀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 用户主流程建议
|
||||||
|
|
||||||
|
### 4.1 游客主流程
|
||||||
|
|
||||||
|
1. 打开程序
|
||||||
|
2. 浏览地区和地图
|
||||||
|
3. 进入地图默认体验活动
|
||||||
|
4. 进行单局游戏
|
||||||
|
5. 结果和历史先本地保存
|
||||||
|
6. 登录后再触发同步或迁移
|
||||||
|
|
||||||
|
设计结论:
|
||||||
|
|
||||||
|
- 游客以地图体验链为主
|
||||||
|
- 游客不应直接进入正式活动能力
|
||||||
|
|
||||||
|
### 4.2 注册用户主流程
|
||||||
|
|
||||||
|
1. 打开程序
|
||||||
|
2. 浏览活动 / 赛事
|
||||||
|
3. 进入活动详情
|
||||||
|
4. 报名 / 签到 / 开局
|
||||||
|
5. 进入正式对局
|
||||||
|
6. 成绩回写历史、排行榜、奖励、成就
|
||||||
|
|
||||||
|
设计结论:
|
||||||
|
|
||||||
|
- 注册用户以活动运营链为主
|
||||||
|
- 活动是主要用户入口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 当前设想的逐项落点
|
||||||
|
|
||||||
|
### 5.1 地图列表
|
||||||
|
|
||||||
|
保留,但定位建议明确为:
|
||||||
|
|
||||||
|
- 自由体验入口
|
||||||
|
- 游客入口
|
||||||
|
- 训练入口
|
||||||
|
|
||||||
|
### 5.2 活动卡片列表
|
||||||
|
|
||||||
|
必须升格为核心模块。
|
||||||
|
|
||||||
|
活动系统长期需要支持:
|
||||||
|
|
||||||
|
- 标准模板活动
|
||||||
|
- 默认体验活动
|
||||||
|
- 客户定制活动
|
||||||
|
|
||||||
|
### 5.3 历史记录
|
||||||
|
|
||||||
|
建议归入“我的”模块统一管理,至少分成:
|
||||||
|
|
||||||
|
- 最近活动
|
||||||
|
- 全部记录
|
||||||
|
- 成绩详情
|
||||||
|
|
||||||
|
### 5.4 游客模式
|
||||||
|
|
||||||
|
建议正式规则为:
|
||||||
|
|
||||||
|
- 游客只可进入地图默认体验活动
|
||||||
|
- 游客数据先本地存储
|
||||||
|
- 登录后触发数据迁移或合并
|
||||||
|
|
||||||
|
### 5.5 全局资料与活动内资料
|
||||||
|
|
||||||
|
建议分两层模型:
|
||||||
|
|
||||||
|
- 全局用户资料
|
||||||
|
- 活动报名资料快照
|
||||||
|
|
||||||
|
因为活动内昵称、头像、队伍信息可能与全局资料不同。
|
||||||
|
|
||||||
|
### 5.6 帮助页面
|
||||||
|
|
||||||
|
建议未来拆成:
|
||||||
|
|
||||||
|
- 新手引导
|
||||||
|
- 使用帮助
|
||||||
|
- 常见问题
|
||||||
|
|
||||||
|
### 5.7 反馈功能
|
||||||
|
|
||||||
|
建议反馈最少自动带上:
|
||||||
|
|
||||||
|
- 当前活动
|
||||||
|
- 当前地图
|
||||||
|
- 当前 session
|
||||||
|
- 设备信息
|
||||||
|
- 程序版本
|
||||||
|
|
||||||
|
### 5.8 成就展示
|
||||||
|
|
||||||
|
当前可以先不做奖励系统,但架构上建议预留:
|
||||||
|
|
||||||
|
- 成就
|
||||||
|
- 奖章
|
||||||
|
- 奖励
|
||||||
|
- 收藏品
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 产品主线建议
|
||||||
|
|
||||||
|
当前最适合正式确定的一条主线是:
|
||||||
|
|
||||||
|
### 游客态
|
||||||
|
|
||||||
|
- 以地图和默认体验为主
|
||||||
|
|
||||||
|
### 登录态
|
||||||
|
|
||||||
|
- 以活动和赛事为主
|
||||||
|
|
||||||
|
这个分流非常重要,因为它会影响:
|
||||||
|
|
||||||
|
- 首页结构
|
||||||
|
- 登录前后导航
|
||||||
|
- 活动和地图的权重关系
|
||||||
|
- 默认入口设计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 与当前工程结构的关系
|
||||||
|
|
||||||
|
对照当前代码与文档体系,可以理解为:
|
||||||
|
|
||||||
|
- `MapEngine`、渲染层、传感器层:地图资源与运行骨架
|
||||||
|
- 规则层、Telemetry、Feedback:Session 过程层
|
||||||
|
- 活动页、准备页、结果页、首页聚合:Event 与 User Asset 的上层壳
|
||||||
|
- 配置系统与文档体系:把 Map / Event / Session 的差异转为可配置运行态
|
||||||
|
|
||||||
|
因此,后续开发不应再只围绕“地图页”扩功能,而应正式开始:
|
||||||
|
|
||||||
|
- 活动系统设计
|
||||||
|
- 结果与历史沉淀设计
|
||||||
|
- 游客链与登录链分流
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 当前阶段建议
|
||||||
|
|
||||||
|
当前建议优先做这三件事:
|
||||||
|
|
||||||
|
1. 正式梳理活动系统页面骨架
|
||||||
|
2. 打磨顺序赛 / 积分赛的默认规则、默认样式、默认流程
|
||||||
|
3. 明确游客模式与登录模式的数据迁移规则
|
||||||
|
|
||||||
|
不建议当前阶段做的事:
|
||||||
|
|
||||||
|
- 过早铺开复杂社交体系
|
||||||
|
- 在奖励系统未定前做大而全的成就结构
|
||||||
|
- 让地图页承担过多对外展示职责
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 一句话结论
|
||||||
|
|
||||||
|
当前 APP 最合理的全局方案是:
|
||||||
|
|
||||||
|
**地图是资源底座,活动是对外核心,Session 是游戏过程,历史与成就是用户资产。**
|
||||||
|
|
||||||
|
后续页面、后端模型、联调策略和配置体系,都应围绕这四个对象继续收口。
|
||||||
383
doc/gameplay/多赛道Variant五层设计草案.md
Normal file
383
doc/gameplay/多赛道Variant五层设计草案.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# 多赛道 Variant 五层设计草案
|
||||||
|
> 文档版本:v0.1
|
||||||
|
> 最后更新:2026-04-02 18:24:00
|
||||||
|
|
||||||
|
本文档用于定义“一个活动对应多版 KML / 多条赛道”时的推荐架构。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 不从页面交互倒推系统结构
|
||||||
|
- 先把多赛道能力按平台层次拆清
|
||||||
|
- 让前端、后端、后台、恢复、结果页都围绕同一套事实工作
|
||||||
|
- 为后续“手动选择 / 随机指定 / 后端指定”留出统一伸缩空间
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本文档是设计草案,不是最终接口契约
|
||||||
|
- 本文档优先定义分层、边界、事实和约束
|
||||||
|
- 具体页面表现、后台表单细节、字段命名可在后续实现阶段微调
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与核心判断
|
||||||
|
|
||||||
|
当前项目里,一场活动后续可能不止一版 KML。
|
||||||
|
|
||||||
|
常见需求包括:
|
||||||
|
|
||||||
|
- 同一活动下有 A / B / C 多条赛道
|
||||||
|
- 准备阶段允许玩家手动选择
|
||||||
|
- 准备阶段由系统随机分配
|
||||||
|
- 后续可能由后台或裁判端直接指定
|
||||||
|
|
||||||
|
这里最关键的判断是:
|
||||||
|
|
||||||
|
**赛道版本不是页面临时状态,而是 session 级事实。**
|
||||||
|
|
||||||
|
也就是说,一旦某局比赛绑定了某个赛道版本,这个事实必须贯穿:
|
||||||
|
|
||||||
|
- 准备阶段
|
||||||
|
- launch
|
||||||
|
- session start / finish
|
||||||
|
- 故障恢复
|
||||||
|
- result
|
||||||
|
- ongoing session
|
||||||
|
- 历史结果
|
||||||
|
|
||||||
|
如果这一点不先定住,后面多端、多页面、多恢复链会很快乱掉。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 总体原则
|
||||||
|
|
||||||
|
多赛道能力建议固定遵守下面 6 条原则:
|
||||||
|
|
||||||
|
1. 一局比赛只绑定一个赛道版本
|
||||||
|
2. 前端可以参与选择,但最终绑定以后端 session 为准
|
||||||
|
3. 客户端不应各自实现不同的随机分配规则
|
||||||
|
4. 恢复链必须记住赛道版本
|
||||||
|
5. 结果页、历史结果和 ongoing 摘要必须可追溯赛道版本
|
||||||
|
6. 扩展新分配模式时,不破坏现有分层
|
||||||
|
|
||||||
|
一句话总结:
|
||||||
|
|
||||||
|
**前端负责交互,后端负责最终绑定,session 负责真实落账。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 五层模型
|
||||||
|
|
||||||
|
多赛道能力建议拆成 5 层。
|
||||||
|
|
||||||
|
### 3.1 资源层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 只定义赛道素材本身
|
||||||
|
- 不讨论谁来选,不讨论 session
|
||||||
|
|
||||||
|
典型内容:
|
||||||
|
|
||||||
|
- KML 文件
|
||||||
|
- 赛道元数据
|
||||||
|
- 可选地图资源
|
||||||
|
- 可选赛道封面、说明图
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 这个赛道版本本身是什么
|
||||||
|
- 它的原始素材和元信息是什么
|
||||||
|
|
||||||
|
建议约束:
|
||||||
|
|
||||||
|
- 每个赛道版本都应有稳定的 `variantId`
|
||||||
|
- 每个赛道版本都应能独立定位到自己的 KML / manifest 入口
|
||||||
|
- 资源层不应混入“本局随机到谁”这种运行时逻辑
|
||||||
|
|
||||||
|
### 3.2 活动编排层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 定义一个活动下有哪些赛道版本可用
|
||||||
|
- 定义这些版本如何被分配
|
||||||
|
|
||||||
|
典型内容:
|
||||||
|
|
||||||
|
- `assignmentMode`
|
||||||
|
- `courseVariants[]`
|
||||||
|
- 权重
|
||||||
|
- 可选分组规则
|
||||||
|
- 可选活动级覆盖
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 当前活动允许哪些赛道版本
|
||||||
|
- 当前活动按什么模式分配赛道
|
||||||
|
|
||||||
|
推荐至少支持 3 种模式:
|
||||||
|
|
||||||
|
1. `manual`
|
||||||
|
- 用户手选
|
||||||
|
2. `random`
|
||||||
|
- 系统随机指定
|
||||||
|
3. `server-assigned`
|
||||||
|
- 后端预先指定,前端只展示
|
||||||
|
|
||||||
|
后续可扩展模式:
|
||||||
|
|
||||||
|
- 按分组分配
|
||||||
|
- 按批次轮换
|
||||||
|
- 团队共用赛道
|
||||||
|
- 避免重复赛道
|
||||||
|
|
||||||
|
### 3.3 会话绑定层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 真正决定“这局比赛到底绑定哪条赛道”
|
||||||
|
- 作为跨端、跨页面、跨恢复的一致事实
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 当前 session 最终绑定的是哪个 `variantId`
|
||||||
|
- 这个绑定是手选、随机还是后端直接指定
|
||||||
|
|
||||||
|
这一层必须落账的最小事实建议包括:
|
||||||
|
|
||||||
|
- `sessionId`
|
||||||
|
- `eventId`
|
||||||
|
- `variantId`
|
||||||
|
- `assignmentMode`
|
||||||
|
- `assignedAt`
|
||||||
|
- 可选的 `assignmentSource`
|
||||||
|
|
||||||
|
核心约束:
|
||||||
|
|
||||||
|
- `variantId` 一旦绑定,不应在本局内漂移
|
||||||
|
- launch、恢复、结果、排行榜都要引用同一 `variantId`
|
||||||
|
- 客户端本地恢复快照必须保存 `variantId`
|
||||||
|
|
||||||
|
### 3.4 客户端呈现层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 负责向玩家展示可选项或绑定结果
|
||||||
|
- 负责发起用户选择
|
||||||
|
- 负责消费最终绑定后的赛道配置
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 准备页要不要展示赛道列表
|
||||||
|
- 是否允许点击手选
|
||||||
|
- 是否显示“本局随机分配结果”
|
||||||
|
- 地图页加载哪一个 manifest
|
||||||
|
|
||||||
|
推荐规则:
|
||||||
|
|
||||||
|
- `manual`:准备页展示赛道列表,允许选择
|
||||||
|
- `random`:准备页展示“随机分配”结果,不允许随意更改
|
||||||
|
- `server-assigned`:准备页只展示最终结果,不提供选择
|
||||||
|
|
||||||
|
强约束:
|
||||||
|
|
||||||
|
- 客户端不能把“页面选择结果”当成最终事实
|
||||||
|
- 客户端必须以后端返回的 `variantId` 为准
|
||||||
|
- 地图页只消费最终绑定后的 manifest / runtime profile
|
||||||
|
|
||||||
|
### 3.5 后台运营层
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 管理赛道版本
|
||||||
|
- 管理活动编排
|
||||||
|
- 管理发布与审计
|
||||||
|
|
||||||
|
这一层回答的是:
|
||||||
|
|
||||||
|
- 活动有哪些赛道版本
|
||||||
|
- 每个版本的素材、说明和发布状态是什么
|
||||||
|
- 当前活动采用哪种分配策略
|
||||||
|
- 某局最终是怎么绑定出来的
|
||||||
|
|
||||||
|
后台层建议逐步承担:
|
||||||
|
|
||||||
|
- 赛道版本管理
|
||||||
|
- 活动绑定多个 variant
|
||||||
|
- 权重配置
|
||||||
|
- 发布检查
|
||||||
|
- 历史审计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 多端协作边界
|
||||||
|
|
||||||
|
多赛道能力一旦牵涉多端,最容易出问题的地方是边界不清。
|
||||||
|
|
||||||
|
建议固定边界如下:
|
||||||
|
|
||||||
|
### 4.1 前端负责
|
||||||
|
|
||||||
|
- 展示赛道选择或展示赛道结果
|
||||||
|
- 发起选择请求或发起随机请求
|
||||||
|
- 消费 launch 返回的赛道绑定结果
|
||||||
|
- 在地图、恢复、结果页中显示当前 `variantId` 或赛道名
|
||||||
|
|
||||||
|
### 4.2 后端负责
|
||||||
|
|
||||||
|
- 最终确认本局绑定哪个 `variantId`
|
||||||
|
- 将 `variantId` 写入 session
|
||||||
|
- 确保 launch 返回的 release / manifest 与 `variantId` 对应
|
||||||
|
- 确保 result / ongoing / recovery 均能反查 `variantId`
|
||||||
|
|
||||||
|
### 4.3 后台负责
|
||||||
|
|
||||||
|
- 管理活动可用的 variants
|
||||||
|
- 管理 assignment mode
|
||||||
|
- 管理发布、上下线和审计
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
- 不允许前端自己定义“随机分配算法”后直接当成最终结果
|
||||||
|
- 不允许某端私自改 `variantId` 但不落 session
|
||||||
|
- 不允许恢复链丢失 `variantId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 配置与发布建议
|
||||||
|
|
||||||
|
多赛道不是只改一个 `kmlUrl`。
|
||||||
|
|
||||||
|
它建议进入活动编排配置,而不是页面临时字段。
|
||||||
|
|
||||||
|
推荐抽象:
|
||||||
|
|
||||||
|
- 活动级:
|
||||||
|
- `assignmentMode`
|
||||||
|
- `courseVariants[]`
|
||||||
|
- variant 级:
|
||||||
|
- `id`
|
||||||
|
- `name`
|
||||||
|
- `kmlUrl`
|
||||||
|
- `weight`
|
||||||
|
- `overrides`
|
||||||
|
|
||||||
|
如果某些版本不仅 KML 不同,连分值、样式、规则也不同,推荐允许 variant 带局部覆盖。
|
||||||
|
|
||||||
|
推荐覆盖顺序:
|
||||||
|
|
||||||
|
`系统默认值 -> 玩法默认值 -> 活动默认值 -> variant 覆盖 -> 单点覆盖`
|
||||||
|
|
||||||
|
这样未来不会因为多赛道把既有继承体系打乱。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 恢复、结果与摘要约束
|
||||||
|
|
||||||
|
### 6.1 故障恢复
|
||||||
|
|
||||||
|
恢复快照中必须加入:
|
||||||
|
|
||||||
|
- `variantId`
|
||||||
|
- 可选 `variantName`
|
||||||
|
|
||||||
|
恢复原则:
|
||||||
|
|
||||||
|
- 恢复的是这局绑定的赛道事实
|
||||||
|
- 不重新随机
|
||||||
|
- 不重新询问选择
|
||||||
|
|
||||||
|
### 6.2 结果页
|
||||||
|
|
||||||
|
结果页建议至少展示:
|
||||||
|
|
||||||
|
- 活动名
|
||||||
|
- 赛道版本名或 `variantId`
|
||||||
|
- 本局结果摘要
|
||||||
|
|
||||||
|
### 6.3 ongoing / recent / 历史成绩
|
||||||
|
|
||||||
|
这些摘要建议都能反映:
|
||||||
|
|
||||||
|
- 本局属于哪个赛道版本
|
||||||
|
|
||||||
|
否则后续:
|
||||||
|
|
||||||
|
- 用户无法判断自己玩的哪版
|
||||||
|
- 运营无法解释同活动下不同赛道差异
|
||||||
|
- 排名、复盘、申诉都困难
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 推荐实施顺序
|
||||||
|
|
||||||
|
这块不建议一上来就做全套复杂功能。
|
||||||
|
|
||||||
|
推荐分三期:
|
||||||
|
|
||||||
|
### 第一期:架构定型
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 定义 `courseVariants`
|
||||||
|
- 定义 `assignmentMode`
|
||||||
|
- 定义 session 绑定 `variantId`
|
||||||
|
- 明确恢复、结果、launch 必须带上 `variantId`
|
||||||
|
|
||||||
|
### 第二期:前后端最小闭环
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 支持 `manual`
|
||||||
|
- 支持 `random`
|
||||||
|
- 准备页可展示赛道选择或随机结果
|
||||||
|
- launch 能消费最终绑定结果
|
||||||
|
|
||||||
|
### 第三期:运营扩展
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 接后台编排
|
||||||
|
- 增加 `server-assigned`
|
||||||
|
- 扩展更多分配模式
|
||||||
|
- 做更完整的审计、排行榜和统计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 六层检查在多赛道能力中的应用
|
||||||
|
|
||||||
|
后续只要多赛道相关配置或契约有变更,建议继续执行当前约定的六层检查:
|
||||||
|
|
||||||
|
1. 文档
|
||||||
|
2. 配置源
|
||||||
|
3. 解析层
|
||||||
|
4. 编译层
|
||||||
|
5. 消费层
|
||||||
|
6. 发布与联调层
|
||||||
|
|
||||||
|
具体到多赛道能力,检查点通常包括:
|
||||||
|
|
||||||
|
- 文档是否同步 `assignmentMode / variants / variantId`
|
||||||
|
- 配置源是否新增或调整 variant 结构
|
||||||
|
- 解析层是否能读取多赛道结构
|
||||||
|
- 编译层是否能生成最终绑定后的 runtime profile
|
||||||
|
- 地图、结果页、恢复链是否都消费了 `variantId`
|
||||||
|
- launch / release / manifest 是否和最终 variant 绑定一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 当前建议结论
|
||||||
|
|
||||||
|
当前阶段,建议把“多 KML / 多赛道”先当成**平台能力设计**,而不是页面功能。
|
||||||
|
|
||||||
|
当前最重要的不是先做某个选择 UI,而是先定住以下 4 个事实:
|
||||||
|
|
||||||
|
1. 一个活动可以有多个 variants
|
||||||
|
2. 一个 session 只能绑定一个 `variantId`
|
||||||
|
3. 最终绑定以后端 session 为准
|
||||||
|
4. 恢复、结果、ongoing、历史结果都必须能追溯该 `variantId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 一句话总结
|
||||||
|
|
||||||
|
**多赛道能力建议固定采用“资源层、活动编排层、会话绑定层、客户端呈现层、后台运营层”五层模型,先把 `variantId` 做成 session 级事实,再去实现准备页手选、随机分配和后台指定等具体交互。**
|
||||||
294
doc/gameplay/多赛道Variant前后端最小契约.md
Normal file
294
doc/gameplay/多赛道Variant前后端最小契约.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# 多赛道 Variant 前后端最小契约
|
||||||
|
> 文档版本:v0.1
|
||||||
|
> 最后更新:2026-04-02 18:33:00
|
||||||
|
|
||||||
|
本文档用于定义“多 KML / 多赛道 variant”第一阶段联调所需的最小前后端契约。
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 先定最小可联调字段,不一开始追求大而全
|
||||||
|
- 保证准备页、launch、地图、恢复、结果页能围绕同一 `variantId` 工作
|
||||||
|
- 避免前端从页面交互反推后端字段
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 本文档只定义最小契约建议
|
||||||
|
- 不等同于最终后台配置模型
|
||||||
|
- 不等同于最终数据库模型
|
||||||
|
- 本文档优先服务前后端第一阶段联调
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 最小联调目标
|
||||||
|
|
||||||
|
第一阶段只解决下面 4 件事:
|
||||||
|
|
||||||
|
1. 一个活动可声明多个 `variant`
|
||||||
|
2. 准备页能知道当前活动是否允许手选 / 随机 / 后端指定
|
||||||
|
3. `launch` 能明确返回本局最终绑定的 `variantId`
|
||||||
|
4. `result / ongoing / recovery` 能持续追溯同一个 `variantId`
|
||||||
|
|
||||||
|
一句话目标:
|
||||||
|
|
||||||
|
**让 `variantId` 成为贯穿一局的稳定事实。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 契约原则
|
||||||
|
|
||||||
|
### 2.1 session 绑定优先
|
||||||
|
|
||||||
|
- 前端可参与选择
|
||||||
|
- 最终绑定以后端 session 为准
|
||||||
|
|
||||||
|
### 2.2 launch 是最终真相
|
||||||
|
|
||||||
|
- 前端准备页即便做了手选或随机请求
|
||||||
|
- 地图页真正消费的仍应是 `launch` 返回的最终绑定结果
|
||||||
|
|
||||||
|
### 2.3 恢复不重新分配
|
||||||
|
|
||||||
|
- 恢复链只恢复既有 `variantId`
|
||||||
|
- 不重新随机
|
||||||
|
- 不重新提示选择
|
||||||
|
|
||||||
|
### 2.4 结果必须可追溯
|
||||||
|
|
||||||
|
- 结果页
|
||||||
|
- ongoing session
|
||||||
|
- 历史成绩
|
||||||
|
|
||||||
|
都建议能反查:
|
||||||
|
|
||||||
|
- `variantId`
|
||||||
|
- 可选 `variantName`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 活动级最小字段建议
|
||||||
|
|
||||||
|
建议活动可玩信息中增加一个最小赛道编排块,例如在 `play` 返回里体现:
|
||||||
|
|
||||||
|
### 3.1 assignmentMode
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- 当前活动的赛道分配模式
|
||||||
|
|
||||||
|
建议最小取值:
|
||||||
|
|
||||||
|
- `manual`
|
||||||
|
- `random`
|
||||||
|
- `server-assigned`
|
||||||
|
|
||||||
|
### 3.2 courseVariants
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- 当前活动可用赛道版本列表
|
||||||
|
|
||||||
|
建议最小字段:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `name`
|
||||||
|
- `description`
|
||||||
|
- `routeCode`
|
||||||
|
- `selectable`
|
||||||
|
|
||||||
|
备注:
|
||||||
|
|
||||||
|
- 第一阶段不一定要在 `play` 里返回所有复杂资源
|
||||||
|
- 只要足够准备页展示选择即可
|
||||||
|
|
||||||
|
推荐最小形态示意:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"play": {
|
||||||
|
"assignmentMode": "manual",
|
||||||
|
"courseVariants": [
|
||||||
|
{
|
||||||
|
"id": "variant_a",
|
||||||
|
"name": "A 线",
|
||||||
|
"description": "适合首次体验",
|
||||||
|
"routeCode": "A",
|
||||||
|
"selectable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "variant_b",
|
||||||
|
"name": "B 线",
|
||||||
|
"description": "稍长路线",
|
||||||
|
"routeCode": "B",
|
||||||
|
"selectable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. launch 最小字段建议
|
||||||
|
|
||||||
|
launch 必须承担“最终绑定本局赛道”的责任。
|
||||||
|
|
||||||
|
建议在现有 `launch` 返回中增加一个明确的 variant 绑定块。
|
||||||
|
|
||||||
|
### 4.1 建议字段
|
||||||
|
|
||||||
|
- `launch.variant.id`
|
||||||
|
- `launch.variant.name`
|
||||||
|
- `launch.variant.routeCode`
|
||||||
|
- `launch.variant.assignmentMode`
|
||||||
|
|
||||||
|
如需保守,也可挂到 `business` 下,但建议语义上单独成块,避免和 release/session 混淆。
|
||||||
|
|
||||||
|
### 4.2 前端输入建议
|
||||||
|
|
||||||
|
如果是手选模式,前端建议向 `launch` 传:
|
||||||
|
|
||||||
|
- `variantId`
|
||||||
|
|
||||||
|
如果是随机模式,前端可以:
|
||||||
|
|
||||||
|
- 不传,由后端分配
|
||||||
|
- 或显式传一个 `assign=random` 请求意图
|
||||||
|
|
||||||
|
### 4.3 输出约束
|
||||||
|
|
||||||
|
无论前端是否传入 `variantId`,launch 返回都必须给出最终绑定结果。
|
||||||
|
|
||||||
|
因为地图页只应消费:
|
||||||
|
|
||||||
|
- 最终 `variantId`
|
||||||
|
- 对应 manifest / config
|
||||||
|
|
||||||
|
不应再依赖准备页上的临时选择状态。
|
||||||
|
|
||||||
|
推荐最小形态示意:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"launch": {
|
||||||
|
"variant": {
|
||||||
|
"id": "variant_b",
|
||||||
|
"name": "B 线",
|
||||||
|
"routeCode": "B",
|
||||||
|
"assignmentMode": "manual"
|
||||||
|
},
|
||||||
|
"resolvedRelease": {
|
||||||
|
"releaseId": "rel_xxx",
|
||||||
|
"manifestUrl": "https://..."
|
||||||
|
},
|
||||||
|
"business": {
|
||||||
|
"sessionId": "ses_xxx",
|
||||||
|
"sessionToken": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. session / result 最小字段建议
|
||||||
|
|
||||||
|
### 5.1 session 摘要
|
||||||
|
|
||||||
|
建议在以下位置都可见:
|
||||||
|
|
||||||
|
- `ongoingSession`
|
||||||
|
- `recentSession`
|
||||||
|
- `session detail`
|
||||||
|
|
||||||
|
最小补充:
|
||||||
|
|
||||||
|
- `variantId`
|
||||||
|
- `variantName`
|
||||||
|
- `routeCode`
|
||||||
|
|
||||||
|
### 5.2 result
|
||||||
|
|
||||||
|
建议 `GET /sessions/{sessionPublicID}/result` 至少返回:
|
||||||
|
|
||||||
|
- `result.session.variantId`
|
||||||
|
- `result.session.variantName`
|
||||||
|
- `result.session.routeCode`
|
||||||
|
|
||||||
|
这样前端单局结果页和历史结果页都能统一展示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 前端第一阶段落点
|
||||||
|
|
||||||
|
前端第一阶段建议只做下面几件事:
|
||||||
|
|
||||||
|
### 6.1 准备页
|
||||||
|
|
||||||
|
- 读取 `assignmentMode`
|
||||||
|
- 读取 `courseVariants[]`
|
||||||
|
- 在 `manual` 下展示可选赛道列表
|
||||||
|
- 在 `random` 下展示“随机分配”
|
||||||
|
- 在 `server-assigned` 下只展示结果
|
||||||
|
|
||||||
|
### 6.2 launch 适配层
|
||||||
|
|
||||||
|
- 将 `launch.variant.*` 写入 `GameLaunchEnvelope`
|
||||||
|
- 将 `variantId` 一起进入地图页和恢复快照
|
||||||
|
|
||||||
|
### 6.3 结果与历史页
|
||||||
|
|
||||||
|
- 显示本局 `variantName / routeCode`
|
||||||
|
|
||||||
|
### 6.4 故障恢复
|
||||||
|
|
||||||
|
- 快照中补 `variantId`
|
||||||
|
- 恢复时继续使用既有 `variantId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 第一阶段后端落点
|
||||||
|
|
||||||
|
后端第一阶段建议只做下面几件事:
|
||||||
|
|
||||||
|
### 7.1 play
|
||||||
|
|
||||||
|
- 返回 `assignmentMode`
|
||||||
|
- 返回 `courseVariants[]`
|
||||||
|
|
||||||
|
### 7.2 launch
|
||||||
|
|
||||||
|
- 接收可选 `variantId`
|
||||||
|
- 返回最终绑定后的 `variant` 信息
|
||||||
|
|
||||||
|
### 7.3 session / result
|
||||||
|
|
||||||
|
- 在 session 摘要和结果里带出 `variantId`
|
||||||
|
|
||||||
|
这样就足够完成第一阶段联调。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 和现有体系的关系
|
||||||
|
|
||||||
|
这份最小契约不替代现有六层检查,后续一旦开始实现,仍建议按六层检查推进:
|
||||||
|
|
||||||
|
1. 文档
|
||||||
|
2. 配置源
|
||||||
|
3. 解析层
|
||||||
|
4. 编译层
|
||||||
|
5. 消费层
|
||||||
|
6. 发布与联调层
|
||||||
|
|
||||||
|
特别是:
|
||||||
|
|
||||||
|
- 配置层后续如果引入 `courseVariants`
|
||||||
|
- 解析层如果开始读取多 variant 结构
|
||||||
|
- 编译层如果开始按 `variantId` 产出 runtime profile
|
||||||
|
|
||||||
|
这三层都不能跳。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 一句话结论
|
||||||
|
|
||||||
|
**多赛道第一阶段联调只需要先定住 `assignmentMode`、`courseVariants[]`、`launch.variant.*`、`session/result.variant*` 这四组最小字段,让 `variantId` 成为贯穿一局的稳定事实。**
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# 游戏规则架构
|
# 游戏规则架构
|
||||||
> 文档版本:v1.0
|
> 文档版本:v1.0
|
||||||
> 最后更新:2026-04-02 08:28:05
|
> 最后更新:2026-04-02 18:33:00
|
||||||
|
|
||||||
|
|
||||||
本文档用于说明当前项目中“游戏规则”在文档、配置文件、样例 JSON、解析代码和运行时规则引擎之间的实际组织方式。
|
本文档用于说明当前项目中“游戏规则”在文档、配置文件、样例 JSON、解析代码和运行时规则引擎之间的实际组织方式。
|
||||||
@@ -68,6 +68,8 @@
|
|||||||
|
|
||||||
- [程序默认规则基线](D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md)
|
- [程序默认规则基线](D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md)
|
||||||
- [运行时编译层总表](D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md)
|
- [运行时编译层总表](D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md)
|
||||||
|
- [多赛道 Variant 五层设计草案](D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md)
|
||||||
|
- [多赛道 Variant 前后端最小契约](D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md)
|
||||||
- [玩法设计文档模板](D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)
|
- [玩法设计文档模板](D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)
|
||||||
- [玩法构想方案](D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md)
|
- [玩法构想方案](D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md)
|
||||||
- `doc/games/<游戏名称>/规则说明文档.md`
|
- `doc/games/<游戏名称>/规则说明文档.md`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 文档索引
|
# 文档索引
|
||||||
> 文档版本:v1.0
|
> 文档版本:v1.0
|
||||||
> 最后更新:2026-04-02 08:28:05
|
> 最后更新:2026-04-02 18:10:04
|
||||||
|
|
||||||
维护约定:
|
维护约定:
|
||||||
|
|
||||||
@@ -41,7 +41,10 @@
|
|||||||
- [玩法构想方案](/D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md)
|
- [玩法构想方案](/D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md)
|
||||||
- [程序默认规则基线](/D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md)
|
- [程序默认规则基线](/D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md)
|
||||||
- [游戏规则架构](/D:/dev/cmr-mini/doc/gameplay/游戏规则架构.md)
|
- [游戏规则架构](/D:/dev/cmr-mini/doc/gameplay/游戏规则架构.md)
|
||||||
|
- [多赛道 Variant 五层设计草案](/D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md)
|
||||||
|
- [多赛道 Variant 前后端最小契约](/D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md)
|
||||||
- [多线程联调协作方式](/D:/dev/cmr-mini/doc/gameplay/多线程联调协作方式.md)
|
- [多线程联调协作方式](/D:/dev/cmr-mini/doc/gameplay/多线程联调协作方式.md)
|
||||||
|
- [APP全局产品架构草案](/D:/dev/cmr-mini/doc/gameplay/APP全局产品架构草案.md)
|
||||||
- [故障恢复机制](/D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md)
|
- [故障恢复机制](/D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md)
|
||||||
- [运行时编译层总表](/D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md)
|
- [运行时编译层总表](/D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md)
|
||||||
- [玩法设计文档模板](/D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)
|
- [玩法设计文档模板](/D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md)
|
||||||
|
|||||||
125
f2b.md
125
f2b.md
@@ -1,6 +1,6 @@
|
|||||||
# F2B 协作清单
|
# F2B 协作清单
|
||||||
> 文档版本:v1.0
|
> 文档版本:v1.3
|
||||||
> 最后更新:2026-04-02 08:28:05
|
> 最后更新:2026-04-02 15:19:37
|
||||||
|
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
@@ -14,34 +14,33 @@
|
|||||||
|
|
||||||
## 待确认
|
## 待确认
|
||||||
|
|
||||||
### F2B-004
|
### F2B-007
|
||||||
|
|
||||||
- 时间:2026-04-01
|
- 时间:2026-04-02
|
||||||
- 提出方:前端
|
- 提出方:前端
|
||||||
- 当前事实:
|
- 当前事实:
|
||||||
- 前端当前依赖以下 launch 字段:
|
- 前端已完成多赛道第一阶段接入:
|
||||||
- `resolvedRelease.manifestUrl`
|
- 活动页、准备页可展示 `assignmentMode / courseVariants`
|
||||||
- `resolvedRelease.releaseId`
|
- 当 `assignmentMode=manual` 时,准备页会让用户选择赛道
|
||||||
- `business.sessionId`
|
- 前端会把选中的 `variantId` 带入 `POST /events/{eventPublicID}/launch`
|
||||||
- `business.sessionToken`
|
|
||||||
- `business.sessionTokenExpiresAt`
|
|
||||||
- 需要对方确认什么:
|
- 需要对方确认什么:
|
||||||
- backend 后续如需调整这些字段名或层级,需先在 `b2f.md` 明确通知
|
- 请 backend 提供一个可联调的 `manual` 多赛道活动或 demo 数据
|
||||||
|
- 该活动需确保 `play.courseVariants[]`、`launch.variant.*` 可稳定返回
|
||||||
- 状态:待确认
|
- 状态:待确认
|
||||||
|
|
||||||
### F2B-005
|
### F2B-008
|
||||||
|
|
||||||
- 时间:2026-04-01
|
- 时间:2026-04-02
|
||||||
- 提出方:前端
|
- 提出方:前端
|
||||||
- 当前事实:
|
- 当前事实:
|
||||||
- ongoing session 目前会影响:
|
- 前端已开始在首页 ongoing/recent、单局结果页、历史结果页展示 `variantName / routeCode`
|
||||||
|
- 当前需要确认从 `launch` 选定的 `variantId` 是否会稳定回流到:
|
||||||
- `/me/entry-home`
|
- `/me/entry-home`
|
||||||
- `/events/{eventPublicID}/play`
|
|
||||||
- `/sessions/{sessionPublicID}/result`
|
- `/sessions/{sessionPublicID}/result`
|
||||||
|
- `/me/results`
|
||||||
- 需要对方确认什么:
|
- 需要对方确认什么:
|
||||||
- `cancelled` 后不再作为 ongoing 返回
|
- 请 backend 确认以上摘要链是否已完成 variant 回写
|
||||||
- `failed` 后不再作为 ongoing 返回
|
- 如还未全部完成,请给出可联调时间点或先可用的接口范围
|
||||||
- `finished` 后结果摘要与首页摘要口径一致
|
|
||||||
- 状态:待确认
|
- 状态:待确认
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -108,6 +107,58 @@
|
|||||||
- 无
|
- 无
|
||||||
- 状态:已确认
|
- 状态:已确认
|
||||||
|
|
||||||
|
### F2B-C006
|
||||||
|
|
||||||
|
- 时间:2026-04-02
|
||||||
|
- 提出方:前端
|
||||||
|
- 当前事实:
|
||||||
|
- backend 已确认多赛道第一阶段最小契约,且相关字段已可从以下接口返回:
|
||||||
|
- `/events/{eventPublicID}/play`
|
||||||
|
- `/events/{eventPublicID}/launch`
|
||||||
|
- `/me/entry-home`
|
||||||
|
- `/sessions/{sessionPublicID}`
|
||||||
|
- `/sessions/{sessionPublicID}/result`
|
||||||
|
- `/me/results`
|
||||||
|
- `/me/sessions`
|
||||||
|
- 正式口径为:
|
||||||
|
- `play.assignmentMode`
|
||||||
|
- `play.courseVariants[]`
|
||||||
|
- `launch.variant.id/name/routeCode/assignmentMode`
|
||||||
|
- `session / ongoing / recent / result` 摘要中带 `variantId/variantName/routeCode`
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- 无
|
||||||
|
- 状态:已确认
|
||||||
|
|
||||||
|
### F2B-C007
|
||||||
|
|
||||||
|
- 时间:2026-04-02
|
||||||
|
- 提出方:前端
|
||||||
|
- 当前事实:
|
||||||
|
- backend 已确认 launch 关键字段为正式契约:
|
||||||
|
- `resolvedRelease.manifestUrl`
|
||||||
|
- `resolvedRelease.releaseId`
|
||||||
|
- `business.sessionId`
|
||||||
|
- `business.sessionToken`
|
||||||
|
- `business.sessionTokenExpiresAt`
|
||||||
|
- 如后续字段名或层级需调整,backend 将先在 `b2f.md` 通知
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- 无
|
||||||
|
- 状态:已确认
|
||||||
|
|
||||||
|
### F2B-C008
|
||||||
|
|
||||||
|
- 时间:2026-04-02
|
||||||
|
- 提出方:前端
|
||||||
|
- 当前事实:
|
||||||
|
- backend 已确认 ongoing / recent / result 摘要口径:
|
||||||
|
- `launched`、`running` 作为 ongoing
|
||||||
|
- `finished`、`failed`、`cancelled` 不再作为 ongoing
|
||||||
|
- `/me/results` 只返回终态对局
|
||||||
|
- 前端后续按这套摘要口径做显示与回归
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- 无
|
||||||
|
- 状态:已确认
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 阻塞
|
## 阻塞
|
||||||
@@ -169,6 +220,20 @@
|
|||||||
- 无
|
- 无
|
||||||
- 状态:已完成
|
- 状态:已完成
|
||||||
|
|
||||||
|
### F2B-D004
|
||||||
|
|
||||||
|
- 时间:2026-04-02
|
||||||
|
- 提出方:前端
|
||||||
|
- 当前事实:
|
||||||
|
- 前端已完成多赛道第一阶段接入:
|
||||||
|
- `backendApi / launchAdapter / GameLaunchEnvelope` 已接入 `variant` 字段
|
||||||
|
- 故障恢复会随 `launchEnvelope` 保留 `variant` 信息
|
||||||
|
- 活动页、准备页、首页、单局结果页、历史结果页开始展示赛道版本信息
|
||||||
|
- `manual` 模式下准备页已支持选择赛道并把 `variantId` 带入 launch
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- 无
|
||||||
|
- 状态:已完成
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
@@ -194,4 +259,28 @@
|
|||||||
- 后续是否提供用户身体数据接口
|
- 后续是否提供用户身体数据接口
|
||||||
- 状态:后续事项
|
- 状态:后续事项
|
||||||
|
|
||||||
|
### F2B-N003
|
||||||
|
|
||||||
|
- 时间:2026-04-02
|
||||||
|
- 提出方:前端
|
||||||
|
- 当前事实:
|
||||||
|
- backend 已确认多赛道第一阶段最小契约
|
||||||
|
- 前端已完成第一阶段基础接入,下一步将转入多赛道专项联调与展示补强
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- 无
|
||||||
|
- 状态:前端执行中
|
||||||
|
|
||||||
|
### F2B-N004
|
||||||
|
|
||||||
|
- 时间:2026-04-02
|
||||||
|
- 提出方:前端
|
||||||
|
- 当前事实:
|
||||||
|
- 多赛道下一步最值钱的是专项联调,而不是继续扩页面
|
||||||
|
- 当前优先链路为:
|
||||||
|
- `manual` 赛道选择 -> `launch.variant`
|
||||||
|
- `launch.variant` -> `ongoing / result / results`
|
||||||
|
- 需要对方确认什么:
|
||||||
|
- 无
|
||||||
|
- 状态:等待 backend 提供联调数据
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"pages/login/login",
|
"pages/login/login",
|
||||||
"pages/home/home",
|
"pages/home/home",
|
||||||
"pages/event/event",
|
"pages/event/event",
|
||||||
|
"pages/event-prepare/event-prepare",
|
||||||
"pages/result/result",
|
"pages/result/result",
|
||||||
|
"pages/results/results",
|
||||||
"pages/map/map",
|
"pages/map/map",
|
||||||
"pages/experience-webview/experience-webview",
|
"pages/experience-webview/experience-webview",
|
||||||
"pages/webview-test/webview-test",
|
"pages/webview-test/webview-test",
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ App<IAppOption>({
|
|||||||
telemetryPlayerProfile: null,
|
telemetryPlayerProfile: null,
|
||||||
backendBaseUrl: null,
|
backendBaseUrl: null,
|
||||||
backendAuthTokens: null,
|
backendAuthTokens: null,
|
||||||
|
pendingResultSnapshot: null,
|
||||||
|
pendingHeartRateAutoConnect: null,
|
||||||
},
|
},
|
||||||
onLaunch() {
|
onLaunch() {
|
||||||
this.globalData.backendBaseUrl = loadBackendBaseUrl()
|
this.globalData.backendBaseUrl = loadBackendBaseUrl()
|
||||||
|
|||||||
575
miniprogram/pages/event-prepare/event-prepare.ts
Normal file
575
miniprogram/pages/event-prepare/event-prepare.ts
Normal file
@@ -0,0 +1,575 @@
|
|||||||
|
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
|
||||||
|
import { getEventPlay, launchEvent, type BackendCourseVariantSummary, type BackendEventPlayResult } from '../../utils/backendApi'
|
||||||
|
import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
|
||||||
|
import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
|
||||||
|
import { HeartRateController } from '../../engine/sensor/heartRateController'
|
||||||
|
|
||||||
|
const PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY = 'cmr.preferredHeartRateDevice'
|
||||||
|
const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
|
||||||
|
const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1'
|
||||||
|
|
||||||
|
type EventPreparePageData = {
|
||||||
|
eventId: string
|
||||||
|
loading: boolean
|
||||||
|
titleText: string
|
||||||
|
summaryText: string
|
||||||
|
releaseText: string
|
||||||
|
actionText: string
|
||||||
|
statusText: string
|
||||||
|
assignmentMode: string
|
||||||
|
variantModeText: string
|
||||||
|
variantSummaryText: string
|
||||||
|
selectedVariantId: string
|
||||||
|
selectedVariantText: string
|
||||||
|
selectableVariants: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
routeCodeText: string
|
||||||
|
descriptionText: string
|
||||||
|
selected: boolean
|
||||||
|
}>
|
||||||
|
locationStatusText: string
|
||||||
|
heartRateStatusText: string
|
||||||
|
heartRateDeviceText: string
|
||||||
|
heartRateScanText: string
|
||||||
|
heartRateConnected: boolean
|
||||||
|
showHeartRateDevicePicker: boolean
|
||||||
|
locationPermissionGranted: boolean
|
||||||
|
locationBackgroundPermissionGranted: boolean
|
||||||
|
heartRateDiscoveredDevices: Array<{
|
||||||
|
deviceId: string
|
||||||
|
name: string
|
||||||
|
rssiText: string
|
||||||
|
preferred: boolean
|
||||||
|
connected: boolean
|
||||||
|
}>
|
||||||
|
mockSourceStatusText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAssignmentMode(mode?: string | null): string {
|
||||||
|
if (mode === 'manual') {
|
||||||
|
return '手动选择'
|
||||||
|
}
|
||||||
|
if (mode === 'random') {
|
||||||
|
return '随机分配'
|
||||||
|
}
|
||||||
|
if (mode === 'server-assigned') {
|
||||||
|
return '后台指定'
|
||||||
|
}
|
||||||
|
return '默认单赛道'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVariantSummary(result: BackendEventPlayResult): string {
|
||||||
|
const variants = result.play.courseVariants || []
|
||||||
|
if (!variants.length) {
|
||||||
|
return '当前未声明额外赛道版本,启动时按默认赛道进入。'
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = variants.map((item) => {
|
||||||
|
const title = item.routeCode || item.name
|
||||||
|
return item.selectable === false ? `${title}(固定)` : title
|
||||||
|
}).join(' / ')
|
||||||
|
|
||||||
|
if (result.play.assignmentMode === 'manual') {
|
||||||
|
return `当前活动支持 ${variants.length} 条赛道。本阶段前端先展示赛道信息,最终绑定以后端 launch 返回为准:${preview}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.play.assignmentMode === 'random') {
|
||||||
|
return `当前活动支持 ${variants.length} 条赛道,进入地图前由后端随机绑定:${preview}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.play.assignmentMode === 'server-assigned') {
|
||||||
|
return `当前活动赛道由后台预先指定:${preview}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return preview
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSelectedVariantId(
|
||||||
|
currentVariantId: string,
|
||||||
|
assignmentMode?: string | null,
|
||||||
|
variants?: BackendCourseVariantSummary[] | null,
|
||||||
|
): string {
|
||||||
|
if (assignmentMode !== 'manual' || !variants || !variants.length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectable = variants.filter((item) => item.selectable !== false)
|
||||||
|
if (!selectable.length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStillExists = selectable.some((item) => item.id === currentVariantId)
|
||||||
|
if (currentVariantId && currentStillExists) {
|
||||||
|
return currentVariantId
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectable[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelectableVariants(
|
||||||
|
selectedVariantId: string,
|
||||||
|
assignmentMode?: string | null,
|
||||||
|
variants?: BackendCourseVariantSummary[] | null,
|
||||||
|
) {
|
||||||
|
if (assignmentMode !== 'manual' || !variants || !variants.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return variants
|
||||||
|
.filter((item) => item.selectable !== false)
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
routeCodeText: item.routeCode || '默认编码',
|
||||||
|
descriptionText: item.description || '暂无赛道说明',
|
||||||
|
selected: item.id === selectedVariantId,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
let prepareHeartRateController: HeartRateController | null = null
|
||||||
|
|
||||||
|
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 loadPreferredHeartRateDeviceName(): string | null {
|
||||||
|
try {
|
||||||
|
const stored = wx.getStorageSync(PREFERRED_HEART_RATE_DEVICE_STORAGE_KEY)
|
||||||
|
if (!stored || typeof stored !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const normalized = stored as { name?: unknown }
|
||||||
|
return typeof normalized.name === 'string' && normalized.name.trim().length > 0
|
||||||
|
? normalized.name.trim()
|
||||||
|
: '心率带'
|
||||||
|
} catch (_error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStoredMockChannelId(): string {
|
||||||
|
try {
|
||||||
|
const stored = wx.getStorageSync(DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY)
|
||||||
|
if (typeof stored === 'string' && stored.trim().length > 0) {
|
||||||
|
return stored.trim()
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMockAutoConnectEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return wx.getStorageSync(DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY) === true
|
||||||
|
} catch (_error) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
eventId: '',
|
||||||
|
loading: false,
|
||||||
|
titleText: '开始前准备',
|
||||||
|
summaryText: '未加载',
|
||||||
|
releaseText: '--',
|
||||||
|
actionText: '--',
|
||||||
|
statusText: '待加载',
|
||||||
|
assignmentMode: '',
|
||||||
|
variantModeText: '--',
|
||||||
|
variantSummaryText: '--',
|
||||||
|
selectedVariantId: '',
|
||||||
|
selectedVariantText: '当前无需手动指定赛道',
|
||||||
|
selectableVariants: [],
|
||||||
|
locationStatusText: '待进入地图后校验定位权限与实时精度',
|
||||||
|
heartRateStatusText: '局前心率带连接入口待接入,本轮先保留骨架',
|
||||||
|
heartRateDeviceText: '--',
|
||||||
|
heartRateScanText: '未扫描',
|
||||||
|
heartRateConnected: false,
|
||||||
|
showHeartRateDevicePicker: false,
|
||||||
|
locationPermissionGranted: false,
|
||||||
|
locationBackgroundPermissionGranted: false,
|
||||||
|
heartRateDiscoveredDevices: [],
|
||||||
|
mockSourceStatusText: '模拟源调试仍在地图页调试面板中使用',
|
||||||
|
} as EventPreparePageData,
|
||||||
|
|
||||||
|
onLoad(query: { eventId?: string }) {
|
||||||
|
const eventId = query && query.eventId ? decodeURIComponent(query.eventId) : ''
|
||||||
|
if (!eventId) {
|
||||||
|
this.setData({
|
||||||
|
statusText: '缺少 eventId',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({ eventId })
|
||||||
|
this.ensurePrepareHeartRateController()
|
||||||
|
this.refreshPreparationDeviceState()
|
||||||
|
this.loadEventPlay(eventId)
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
this.refreshPreparationDeviceState()
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
if (prepareHeartRateController) {
|
||||||
|
prepareHeartRateController.destroy()
|
||||||
|
prepareHeartRateController = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const selectedVariantId = resolveSelectedVariantId(
|
||||||
|
this.data.selectedVariantId,
|
||||||
|
result.play.assignmentMode,
|
||||||
|
result.play.courseVariants,
|
||||||
|
)
|
||||||
|
const selectableVariants = buildSelectableVariants(
|
||||||
|
selectedVariantId,
|
||||||
|
result.play.assignmentMode,
|
||||||
|
result.play.courseVariants,
|
||||||
|
)
|
||||||
|
const selectedVariant = selectableVariants.find((item) => item.id === selectedVariantId) || null
|
||||||
|
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 ? '准备完成,可进入地图' : '当前不可启动',
|
||||||
|
assignmentMode: result.play.assignmentMode || '',
|
||||||
|
variantModeText: formatAssignmentMode(result.play.assignmentMode),
|
||||||
|
variantSummaryText: formatVariantSummary(result),
|
||||||
|
selectedVariantId,
|
||||||
|
selectedVariantText: selectedVariant
|
||||||
|
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
|
||||||
|
: '当前无需手动指定赛道',
|
||||||
|
selectableVariants,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshPreparationDeviceState() {
|
||||||
|
this.refreshLocationPermissionStatus()
|
||||||
|
this.refreshHeartRatePreparationStatus()
|
||||||
|
this.refreshMockSourcePreparationStatus()
|
||||||
|
},
|
||||||
|
|
||||||
|
ensurePrepareHeartRateController() {
|
||||||
|
if (prepareHeartRateController) {
|
||||||
|
return prepareHeartRateController
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareHeartRateController = new HeartRateController({
|
||||||
|
onHeartRate: () => {},
|
||||||
|
onStatus: (message) => {
|
||||||
|
this.setData({
|
||||||
|
heartRateStatusText: message,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
this.setData({
|
||||||
|
heartRateStatusText: message,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onConnectionChange: (connected, deviceName) => {
|
||||||
|
this.setData({
|
||||||
|
heartRateConnected: connected,
|
||||||
|
heartRateDeviceText: connected ? (deviceName || '心率带') : (deviceName || '--'),
|
||||||
|
})
|
||||||
|
this.refreshHeartRatePreparationStatus()
|
||||||
|
},
|
||||||
|
onDeviceListChange: (devices) => {
|
||||||
|
this.setData({
|
||||||
|
heartRateScanText: devices.length ? `已发现 ${devices.length} 个设备` : '未扫描',
|
||||||
|
heartRateDiscoveredDevices: devices.map((device) => ({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
name: device.name,
|
||||||
|
rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --',
|
||||||
|
preferred: !!device.isPreferred,
|
||||||
|
connected: !!prepareHeartRateController
|
||||||
|
&& !!prepareHeartRateController.currentDeviceId
|
||||||
|
&& prepareHeartRateController.currentDeviceId === device.deviceId
|
||||||
|
&& prepareHeartRateController.connected,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return prepareHeartRateController
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshLocationPermissionStatus() {
|
||||||
|
wx.getSetting({
|
||||||
|
success: (result) => {
|
||||||
|
const authSetting = result && result.authSetting
|
||||||
|
? result.authSetting as Record<string, boolean | undefined>
|
||||||
|
: {}
|
||||||
|
const hasForeground = authSetting['scope.userLocation'] === true
|
||||||
|
const hasBackground = authSetting['scope.userLocationBackground'] === true
|
||||||
|
let locationStatusText = '未请求定位权限'
|
||||||
|
if (hasForeground && hasBackground) {
|
||||||
|
locationStatusText = '已授权前后台定位'
|
||||||
|
} else if (hasForeground) {
|
||||||
|
locationStatusText = '已授权前台定位'
|
||||||
|
} else if (authSetting['scope.userLocation'] === false) {
|
||||||
|
locationStatusText = '定位权限被拒绝'
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
locationStatusText,
|
||||||
|
locationPermissionGranted: hasForeground,
|
||||||
|
locationBackgroundPermissionGranted: hasBackground,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.setData({
|
||||||
|
locationStatusText: '无法读取定位权限状态',
|
||||||
|
locationPermissionGranted: false,
|
||||||
|
locationBackgroundPermissionGranted: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleRequestLocationPermission() {
|
||||||
|
wx.authorize({
|
||||||
|
scope: 'scope.userLocation',
|
||||||
|
success: () => {
|
||||||
|
this.refreshLocationPermissionStatus()
|
||||||
|
wx.showToast({
|
||||||
|
title: '前台定位已授权',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.refreshLocationPermissionStatus()
|
||||||
|
wx.showToast({
|
||||||
|
title: '请在设置中开启定位权限',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleOpenLocationSettings() {
|
||||||
|
wx.openSetting({
|
||||||
|
success: () => {
|
||||||
|
this.refreshLocationPermissionStatus()
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
wx.showToast({
|
||||||
|
title: '无法打开设置面板',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshHeartRatePreparationStatus() {
|
||||||
|
const controller = this.ensurePrepareHeartRateController()
|
||||||
|
const preferredDeviceName = loadPreferredHeartRateDeviceName()
|
||||||
|
this.setData({
|
||||||
|
heartRateStatusText: controller.connected
|
||||||
|
? '局前心率带已连接'
|
||||||
|
: preferredDeviceName
|
||||||
|
? `已记住首选设备:${preferredDeviceName}`
|
||||||
|
: '未设置首选设备,可在此连接或进入地图后连接',
|
||||||
|
heartRateDeviceText: controller.currentDeviceName || preferredDeviceName || '--',
|
||||||
|
heartRateScanText: controller.scanning
|
||||||
|
? '扫描中'
|
||||||
|
: (controller.discoveredDevices.length ? `已发现 ${controller.discoveredDevices.length} 个设备` : '未扫描'),
|
||||||
|
heartRateConnected: controller.connected,
|
||||||
|
heartRateDiscoveredDevices: controller.discoveredDevices.map((device) => ({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
name: device.name,
|
||||||
|
rssiText: typeof device.rssi === 'number' ? `${device.rssi} dBm` : 'RSSI --',
|
||||||
|
preferred: !!device.isPreferred,
|
||||||
|
connected: !!controller.currentDeviceId && controller.currentDeviceId === device.deviceId && controller.connected,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshMockSourcePreparationStatus() {
|
||||||
|
const channelId = loadStoredMockChannelId()
|
||||||
|
const autoConnect = loadMockAutoConnectEnabled()
|
||||||
|
this.setData({
|
||||||
|
mockSourceStatusText: autoConnect
|
||||||
|
? `自动连接已开启 / 通道 ${channelId}`
|
||||||
|
: `自动连接未开启 / 通道 ${channelId}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleRefresh() {
|
||||||
|
this.loadEventPlay()
|
||||||
|
},
|
||||||
|
|
||||||
|
handleBack() {
|
||||||
|
wx.navigateBack()
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePrepareHeartRateConnect() {
|
||||||
|
const controller = this.ensurePrepareHeartRateController()
|
||||||
|
controller.startScanAndConnect()
|
||||||
|
this.refreshHeartRatePreparationStatus()
|
||||||
|
},
|
||||||
|
|
||||||
|
handleOpenHeartRateDevicePicker() {
|
||||||
|
const controller = this.ensurePrepareHeartRateController()
|
||||||
|
this.setData({
|
||||||
|
showHeartRateDevicePicker: true,
|
||||||
|
})
|
||||||
|
if (!controller.scanning) {
|
||||||
|
controller.startScanAndConnect()
|
||||||
|
}
|
||||||
|
this.refreshHeartRatePreparationStatus()
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCloseHeartRateDevicePicker() {
|
||||||
|
this.setData({
|
||||||
|
showHeartRateDevicePicker: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePrepareHeartRateDeviceConnect(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) {
|
||||||
|
const deviceId = event.currentTarget.dataset.deviceId
|
||||||
|
if (!deviceId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const controller = this.ensurePrepareHeartRateController()
|
||||||
|
controller.connectToDiscoveredDevice(deviceId)
|
||||||
|
this.setData({
|
||||||
|
showHeartRateDevicePicker: false,
|
||||||
|
})
|
||||||
|
this.refreshHeartRatePreparationStatus()
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePrepareHeartRateDisconnect() {
|
||||||
|
if (!prepareHeartRateController) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prepareHeartRateController.disconnect()
|
||||||
|
this.setData({
|
||||||
|
heartRateConnected: false,
|
||||||
|
})
|
||||||
|
this.refreshHeartRatePreparationStatus()
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePrepareHeartRateClearPreferred() {
|
||||||
|
const controller = this.ensurePrepareHeartRateController()
|
||||||
|
controller.clearPreferredDevice()
|
||||||
|
this.refreshHeartRatePreparationStatus()
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSelectVariant(event: WechatMiniprogram.BaseEvent<{ variantId?: string }>) {
|
||||||
|
const variantId = event.currentTarget.dataset.variantId
|
||||||
|
if (!variantId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectableVariants = this.data.selectableVariants.map((item) => ({
|
||||||
|
...item,
|
||||||
|
selected: item.id === variantId,
|
||||||
|
}))
|
||||||
|
const selectedVariant = selectableVariants.find((item) => item.id === variantId) || null
|
||||||
|
this.setData({
|
||||||
|
selectedVariantId: variantId,
|
||||||
|
selectedVariantText: selectedVariant
|
||||||
|
? `${selectedVariant.name} / ${selectedVariant.routeCodeText}`
|
||||||
|
: '当前无需手动指定赛道',
|
||||||
|
selectableVariants,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleLaunch() {
|
||||||
|
const accessToken = getAccessToken()
|
||||||
|
if (!accessToken) {
|
||||||
|
wx.redirectTo({ url: '/pages/login/login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.data.locationPermissionGranted) {
|
||||||
|
this.setData({
|
||||||
|
statusText: '进入地图前请先完成定位授权',
|
||||||
|
})
|
||||||
|
wx.showToast({
|
||||||
|
title: '请先授权定位',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
statusText: '正在创建 session 并进入地图',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = getApp<IAppOption>()
|
||||||
|
if (app.globalData) {
|
||||||
|
const pendingDeviceName = prepareHeartRateController && prepareHeartRateController.currentDeviceName
|
||||||
|
? prepareHeartRateController.currentDeviceName
|
||||||
|
: loadPreferredHeartRateDeviceName()
|
||||||
|
app.globalData.pendingHeartRateAutoConnect = {
|
||||||
|
enabled: !!pendingDeviceName,
|
||||||
|
deviceName: pendingDeviceName || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prepareHeartRateController) {
|
||||||
|
prepareHeartRateController.destroy()
|
||||||
|
prepareHeartRateController = null
|
||||||
|
}
|
||||||
|
const result = await launchEvent({
|
||||||
|
baseUrl: loadBackendBaseUrl(),
|
||||||
|
eventId: this.data.eventId,
|
||||||
|
accessToken,
|
||||||
|
variantId: this.data.assignmentMode === 'manual' ? this.data.selectedVariantId : undefined,
|
||||||
|
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}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
105
miniprogram/pages/event-prepare/event-prepare.wxml
Normal file
105
miniprogram/pages/event-prepare/event-prepare.wxml
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<scroll-view class="page" scroll-y>
|
||||||
|
<view class="shell">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="hero__eyebrow">Prepare</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="summary">赛道模式:{{variantModeText}}</view>
|
||||||
|
<view class="summary">赛道摘要:{{variantSummaryText}}</view>
|
||||||
|
<view class="summary">当前选择:{{selectedVariantText}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="panel" wx:if="{{assignmentMode === 'manual' && selectableVariants.length}}">
|
||||||
|
<view class="panel__title">赛道选择</view>
|
||||||
|
<view class="summary">当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。</view>
|
||||||
|
<view class="variant-list">
|
||||||
|
<view wx:for="{{selectableVariants}}" wx:key="id" class="variant-card {{item.selected ? 'variant-card--active' : ''}}" data-variant-id="{{item.id}}" bindtap="handleSelectVariant">
|
||||||
|
<view class="variant-card__main">
|
||||||
|
<view class="variant-card__title-row">
|
||||||
|
<text class="variant-card__name">{{item.name}}</text>
|
||||||
|
<text class="variant-card__badge" wx:if="{{item.selected}}">已选中</text>
|
||||||
|
</view>
|
||||||
|
<text class="variant-card__meta">{{item.routeCodeText}}</text>
|
||||||
|
<text class="variant-card__meta">{{item.descriptionText}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="panel">
|
||||||
|
<view class="panel__title">设备准备</view>
|
||||||
|
<view class="summary">这一页现在负责局前设备准备。定位权限先在这里确认,心率带支持先连后进图,地图内仍保留局中快速重连入口。</view>
|
||||||
|
<view class="row">
|
||||||
|
<view class="row__label">定位状态</view>
|
||||||
|
<view class="row__value">{{locationStatusText}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="summary" wx:if="{{locationPermissionGranted && !locationBackgroundPermissionGranted}}">已完成前台定位授权;如果后续需要后台持续定位,请在系统设置中补齐后台权限。</view>
|
||||||
|
<view class="actions">
|
||||||
|
<button class="btn btn--secondary" bindtap="handleRequestLocationPermission">申请定位权限</button>
|
||||||
|
<button class="btn btn--ghost" bindtap="handleOpenLocationSettings">打开系统设置</button>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<view class="row__label">心率带</view>
|
||||||
|
<view class="row__value">{{heartRateStatusText}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<view class="row__label">当前设备</view>
|
||||||
|
<view class="row__value">{{heartRateDeviceText}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<view class="row__label">扫描状态</view>
|
||||||
|
<view class="row__value">{{heartRateScanText}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<view class="row__label">模拟源</view>
|
||||||
|
<view class="row__value">{{mockSourceStatusText}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="actions">
|
||||||
|
<button class="btn btn--secondary" bindtap="handleOpenHeartRateDevicePicker">选择设备</button>
|
||||||
|
<button class="btn btn--ghost" bindtap="handlePrepareHeartRateConnect">重新扫描</button>
|
||||||
|
<button class="btn btn--ghost" bindtap="handlePrepareHeartRateDisconnect">断开连接</button>
|
||||||
|
<button class="btn btn--ghost" bindtap="handlePrepareHeartRateClearPreferred">清除首选</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="panel">
|
||||||
|
<view class="panel__title">开始比赛</view>
|
||||||
|
<view class="summary">这一页先承担局前准备壳子,后面会继续接定位权限、心率带局前连接和设备检查。</view>
|
||||||
|
<view class="actions">
|
||||||
|
<button class="btn btn--secondary" bindtap="handleBack">返回活动页</button>
|
||||||
|
<button class="btn btn--ghost" bindtap="handleRefresh">刷新</button>
|
||||||
|
<button class="btn btn--primary" bindtap="handleLaunch">进入地图</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{showHeartRateDevicePicker}}" class="picker-mask" bindtap="handleCloseHeartRateDevicePicker"></view>
|
||||||
|
<view wx:if="{{showHeartRateDevicePicker}}" class="picker-sheet">
|
||||||
|
<view class="picker-sheet__header">
|
||||||
|
<view class="picker-sheet__title">选择心率带设备</view>
|
||||||
|
<button class="picker-sheet__close" bindtap="handleCloseHeartRateDevicePicker">关闭</button>
|
||||||
|
</view>
|
||||||
|
<view class="summary">扫描状态:{{heartRateScanText}}</view>
|
||||||
|
<view wx:if="{{!heartRateDiscoveredDevices.length}}" class="summary">当前还没有发现设备,可先点“重新扫描”。</view>
|
||||||
|
<view wx:if="{{heartRateDiscoveredDevices.length}}" class="device-list">
|
||||||
|
<view wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId" class="device-card">
|
||||||
|
<view class="device-card__main">
|
||||||
|
<view class="device-card__title-row">
|
||||||
|
<text class="device-card__name">{{item.name}}</text>
|
||||||
|
<text class="device-card__badge" wx:if="{{item.preferred}}">首选</text>
|
||||||
|
<text class="device-card__badge device-card__badge--active" wx:if="{{item.connected}}">已连接</text>
|
||||||
|
</view>
|
||||||
|
<text class="device-card__meta">{{item.rssiText}}</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn {{item.connected ? 'btn--ghost' : 'btn--secondary'}} device-card__action" data-device-id="{{item.deviceId}}" bindtap="handlePrepareHeartRateDeviceConnect">{{item.connected ? '已连接' : '连接'}}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
276
miniprogram/pages/event-prepare/event-prepare.wxss
Normal file
276
miniprogram/pages/event-prepare/event-prepare.wxss
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
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,
|
||||||
|
.row__label,
|
||||||
|
.row__value {
|
||||||
|
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 {
|
||||||
|
max-width: 70%;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #17345a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: #f6f9fc;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-card--active {
|
||||||
|
background: #edf5ff;
|
||||||
|
border-color: #4f86c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-card__main {
|
||||||
|
display: grid;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-card__title-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10rpx;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-card__name {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #17345a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-card__badge {
|
||||||
|
padding: 4rpx 10rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #dff3e8;
|
||||||
|
color: #1f6a45;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-card__meta {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #5c7288;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 22, 38, 0.42);
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sheet {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 31;
|
||||||
|
display: grid;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 24rpx 24rpx 36rpx;
|
||||||
|
border-top-left-radius: 28rpx;
|
||||||
|
border-top-right-radius: 28rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
box-shadow: 0 -14rpx 36rpx rgba(22, 43, 71, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sheet__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sheet__title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #17345a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sheet__close {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 60rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
line-height: 60rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
background: #eef3f8;
|
||||||
|
color: #455a72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sheet__close::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: #f6f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__main {
|
||||||
|
display: grid;
|
||||||
|
gap: 8rpx;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__title-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10rpx;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__name {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #17345a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__badge {
|
||||||
|
padding: 4rpx 10rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #e1ecfa;
|
||||||
|
color: #35567d;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__badge--active {
|
||||||
|
background: #dff3e8;
|
||||||
|
color: #1f6a45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__meta {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #5c7288;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__action {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
|
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
|
||||||
import { getEventPlay, launchEvent, type BackendEventPlayResult } from '../../utils/backendApi'
|
import { getEventPlay, type BackendEventPlayResult } from '../../utils/backendApi'
|
||||||
import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter'
|
|
||||||
import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch'
|
|
||||||
|
|
||||||
type EventPageData = {
|
type EventPageData = {
|
||||||
eventId: string
|
eventId: string
|
||||||
@@ -11,6 +9,33 @@ type EventPageData = {
|
|||||||
releaseText: string
|
releaseText: string
|
||||||
actionText: string
|
actionText: string
|
||||||
statusText: string
|
statusText: string
|
||||||
|
variantModeText: string
|
||||||
|
variantSummaryText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAssignmentMode(mode?: string | null): string {
|
||||||
|
if (mode === 'manual') {
|
||||||
|
return '手动选择'
|
||||||
|
}
|
||||||
|
if (mode === 'random') {
|
||||||
|
return '随机分配'
|
||||||
|
}
|
||||||
|
if (mode === 'server-assigned') {
|
||||||
|
return '后台指定'
|
||||||
|
}
|
||||||
|
return '默认单赛道'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVariantSummary(result: BackendEventPlayResult): string {
|
||||||
|
const variants = result.play.courseVariants || []
|
||||||
|
if (!variants.length) {
|
||||||
|
return '当前未声明额外赛道版本'
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectable = variants.filter((item) => item.selectable !== false)
|
||||||
|
const preview = variants.slice(0, 3).map((item) => item.routeCode || item.name).join(' / ')
|
||||||
|
const suffix = variants.length > 3 ? ' / ...' : ''
|
||||||
|
return `${variants.length} 条赛道,可选 ${selectable.length} 条:${preview}${suffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAccessToken(): string | null {
|
function getAccessToken(): string | null {
|
||||||
@@ -30,6 +55,8 @@ Page({
|
|||||||
releaseText: '--',
|
releaseText: '--',
|
||||||
actionText: '--',
|
actionText: '--',
|
||||||
statusText: '待加载',
|
statusText: '待加载',
|
||||||
|
variantModeText: '--',
|
||||||
|
variantSummaryText: '--',
|
||||||
} as EventPageData,
|
} as EventPageData,
|
||||||
|
|
||||||
onLoad(query: { eventId?: string }) {
|
onLoad(query: { eventId?: string }) {
|
||||||
@@ -83,6 +110,8 @@ Page({
|
|||||||
: '当前无可用 release',
|
: '当前无可用 release',
|
||||||
actionText: `${result.play.primaryAction} / ${result.play.reason}`,
|
actionText: `${result.play.primaryAction} / ${result.play.reason}`,
|
||||||
statusText: result.play.canLaunch ? '可启动' : '当前不可启动',
|
statusText: result.play.canLaunch ? '可启动' : '当前不可启动',
|
||||||
|
variantModeText: formatAssignmentMode(result.play.assignmentMode),
|
||||||
|
variantSummaryText: formatVariantSummary(result),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -91,33 +120,8 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
async handleLaunch() {
|
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({
|
wx.navigateTo({
|
||||||
url: prepareMapPageUrlForLaunch(envelope),
|
url: `/pages/event-prepare/event-prepare?eventId=${encodeURIComponent(this.data.eventId)}`,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
|
||||||
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
|
|
||||||
this.setData({
|
|
||||||
statusText: `launch 失败:${message}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
<view class="summary">Release:{{releaseText}}</view>
|
<view class="summary">Release:{{releaseText}}</view>
|
||||||
<view class="summary">主动作:{{actionText}}</view>
|
<view class="summary">主动作:{{actionText}}</view>
|
||||||
<view class="summary">状态:{{statusText}}</view>
|
<view class="summary">状态:{{statusText}}</view>
|
||||||
|
<view class="summary">赛道模式:{{variantModeText}}</view>
|
||||||
|
<view class="summary">赛道摘要:{{variantSummaryText}}</view>
|
||||||
<view class="actions">
|
<view class="actions">
|
||||||
<button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
|
<button class="btn btn--secondary" bindtap="handleRefresh">刷新</button>
|
||||||
<button class="btn btn--primary" bindtap="handleLaunch">开始比赛</button>
|
<button class="btn btn--primary" bindtap="handleLaunch">前往准备页</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ type HomePageData = {
|
|||||||
cards: BackendCardResult[]
|
cards: BackendCardResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSessionSummary(session?: BackendEntryHomeResult['ongoingSession'] | null): string {
|
||||||
|
if (!session) {
|
||||||
|
return '无'
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = session.eventName || session.eventDisplayName || session.eventId || session.id || session.sessionId
|
||||||
|
const status = session.status || session.sessionStatus || '--'
|
||||||
|
const route = session.routeCode || session.variantName || '默认赛道'
|
||||||
|
return `${title} / ${status} / ${route}`
|
||||||
|
}
|
||||||
|
|
||||||
function requireAuthToken(): string | null {
|
function requireAuthToken(): string | null {
|
||||||
const app = getApp<IAppOption>()
|
const app = getApp<IAppOption>()
|
||||||
const tokens = app.globalData && app.globalData.backendAuthTokens
|
const tokens = app.globalData && app.globalData.backendAuthTokens
|
||||||
@@ -79,12 +90,8 @@ Page({
|
|||||||
userNameText: result.user.nickname || result.user.publicId || result.user.id,
|
userNameText: result.user.nickname || result.user.publicId || result.user.id,
|
||||||
tenantText: `${result.tenant.name} (${result.tenant.code})`,
|
tenantText: `${result.tenant.name} (${result.tenant.code})`,
|
||||||
channelText: `${result.channel.displayName} / ${result.channel.code}`,
|
channelText: `${result.channel.displayName} / ${result.channel.code}`,
|
||||||
ongoingSessionText: result.ongoingSession
|
ongoingSessionText: formatSessionSummary(result.ongoingSession),
|
||||||
? `${result.ongoingSession.eventName || result.ongoingSession.eventDisplayName || result.ongoingSession.eventId || result.ongoingSession.id || result.ongoingSession.sessionId} / ${result.ongoingSession.status || result.ongoingSession.sessionStatus}`
|
recentSessionText: formatSessionSummary(result.recentSession),
|
||||||
: '无',
|
|
||||||
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 || [],
|
cards: result.cards || [],
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -110,7 +117,7 @@ Page({
|
|||||||
|
|
||||||
handleOpenRecentResult() {
|
handleOpenRecentResult() {
|
||||||
wx.navigateTo({
|
wx.navigateTo({
|
||||||
url: '/pages/result/result',
|
url: '/pages/results/results',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
showGameInfoPanel: boolean
|
showGameInfoPanel: boolean
|
||||||
showResultScene: boolean
|
showResultScene: boolean
|
||||||
showSystemSettingsPanel: boolean
|
showSystemSettingsPanel: boolean
|
||||||
|
showHeartRateDevicePicker: boolean
|
||||||
showCenterScaleRuler: boolean
|
showCenterScaleRuler: boolean
|
||||||
showPunchHintBanner: boolean
|
showPunchHintBanner: boolean
|
||||||
punchHintFxClass: string
|
punchHintFxClass: string
|
||||||
@@ -92,6 +93,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
resultSceneHeroLabel: string
|
resultSceneHeroLabel: string
|
||||||
resultSceneHeroValue: string
|
resultSceneHeroValue: string
|
||||||
resultSceneRows: MapEngineGameInfoRow[]
|
resultSceneRows: MapEngineGameInfoRow[]
|
||||||
|
resultSceneCountdownText: string
|
||||||
panelTimerText: string
|
panelTimerText: string
|
||||||
panelTimerMode: 'elapsed' | 'countdown'
|
panelTimerMode: 'elapsed' | 'countdown'
|
||||||
panelMileageText: string
|
panelMileageText: string
|
||||||
@@ -157,6 +159,7 @@ const PUNCH_HINT_AUTO_HIDE_MS = 30000
|
|||||||
const PUNCH_HINT_FX_DURATION_MS = 420
|
const PUNCH_HINT_FX_DURATION_MS = 420
|
||||||
const PUNCH_HINT_HAPTIC_GAP_MS = 2400
|
const PUNCH_HINT_HAPTIC_GAP_MS = 2400
|
||||||
const SESSION_RECOVERY_PERSIST_INTERVAL_MS = 5000
|
const SESSION_RECOVERY_PERSIST_INTERVAL_MS = 5000
|
||||||
|
const RESULT_EXIT_REDIRECT_DELAY_MS = 3000
|
||||||
let currentGameLaunchEnvelope: GameLaunchEnvelope = getDemoGameLaunchEnvelope()
|
let currentGameLaunchEnvelope: GameLaunchEnvelope = getDemoGameLaunchEnvelope()
|
||||||
let mapEngine: MapEngine | null = null
|
let mapEngine: MapEngine | null = null
|
||||||
let stageCanvasAttached = false
|
let stageCanvasAttached = false
|
||||||
@@ -172,6 +175,8 @@ let panelMileageFxTimer = 0
|
|||||||
let panelSpeedFxTimer = 0
|
let panelSpeedFxTimer = 0
|
||||||
let panelHeartRateFxTimer = 0
|
let panelHeartRateFxTimer = 0
|
||||||
let sessionRecoveryPersistTimer = 0
|
let sessionRecoveryPersistTimer = 0
|
||||||
|
let resultExitRedirectTimer = 0
|
||||||
|
let resultExitCountdownTimer = 0
|
||||||
let lastPunchHintHapticAt = 0
|
let lastPunchHintHapticAt = 0
|
||||||
let currentSystemSettingsConfig: SystemSettingsConfig | undefined
|
let currentSystemSettingsConfig: SystemSettingsConfig | undefined
|
||||||
let currentRemoteMapConfig: RemoteMapConfig | undefined
|
let currentRemoteMapConfig: RemoteMapConfig | undefined
|
||||||
@@ -179,6 +184,8 @@ let systemSettingsLockLifetimeActive = false
|
|||||||
let syncedBackendSessionStartId = ''
|
let syncedBackendSessionStartId = ''
|
||||||
let syncedBackendSessionFinishId = ''
|
let syncedBackendSessionFinishId = ''
|
||||||
let shouldAutoRestoreRecoverySnapshot = false
|
let shouldAutoRestoreRecoverySnapshot = false
|
||||||
|
let redirectedToResultPage = false
|
||||||
|
let pendingHeartRateSwitchDeviceName: string | null = null
|
||||||
const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
|
const DEBUG_MOCK_CHANNEL_ID_STORAGE_KEY = 'cmr.debug.mockChannelId.v1'
|
||||||
const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1'
|
const DEBUG_MOCK_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1'
|
||||||
let lastCenterScaleRulerStablePatch: Pick<
|
let lastCenterScaleRulerStablePatch: Pick<
|
||||||
@@ -469,6 +476,34 @@ function clearSessionRecoveryPersistTimer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearResultExitRedirectTimer() {
|
||||||
|
if (resultExitRedirectTimer) {
|
||||||
|
clearTimeout(resultExitRedirectTimer)
|
||||||
|
resultExitRedirectTimer = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResultExitCountdownTimer() {
|
||||||
|
if (resultExitCountdownTimer) {
|
||||||
|
clearInterval(resultExitCountdownTimer)
|
||||||
|
resultExitCountdownTimer = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateAwayFromMapAfterCancel() {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length > 1) {
|
||||||
|
wx.navigateBack({
|
||||||
|
delta: 1,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.redirectTo({
|
||||||
|
url: '/pages/home/home',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function hasExplicitLaunchOptions(options?: MapPageLaunchOptions | null): boolean {
|
function hasExplicitLaunchOptions(options?: MapPageLaunchOptions | null): boolean {
|
||||||
if (!options) {
|
if (!options) {
|
||||||
return false
|
return false
|
||||||
@@ -780,6 +815,7 @@ Page({
|
|||||||
showGameInfoPanel: false,
|
showGameInfoPanel: false,
|
||||||
showResultScene: false,
|
showResultScene: false,
|
||||||
showSystemSettingsPanel: false,
|
showSystemSettingsPanel: false,
|
||||||
|
showHeartRateDevicePicker: false,
|
||||||
showCenterScaleRuler: false,
|
showCenterScaleRuler: false,
|
||||||
statusBarHeight: 0,
|
statusBarHeight: 0,
|
||||||
topInsetHeight: 12,
|
topInsetHeight: 12,
|
||||||
@@ -798,6 +834,7 @@ Page({
|
|||||||
resultSceneHeroLabel: '本局用时',
|
resultSceneHeroLabel: '本局用时',
|
||||||
resultSceneHeroValue: '--',
|
resultSceneHeroValue: '--',
|
||||||
resultSceneRows: buildEmptyResultSceneSnapshot().rows,
|
resultSceneRows: buildEmptyResultSceneSnapshot().rows,
|
||||||
|
resultSceneCountdownText: '',
|
||||||
panelTimerText: '00:00:00',
|
panelTimerText: '00:00:00',
|
||||||
panelTimerMode: 'elapsed',
|
panelTimerMode: 'elapsed',
|
||||||
panelMileageText: '0m',
|
panelMileageText: '0m',
|
||||||
@@ -927,8 +964,11 @@ Page({
|
|||||||
|
|
||||||
onLoad(options: MapPageLaunchOptions) {
|
onLoad(options: MapPageLaunchOptions) {
|
||||||
clearSessionRecoveryPersistTimer()
|
clearSessionRecoveryPersistTimer()
|
||||||
|
clearResultExitRedirectTimer()
|
||||||
|
clearResultExitCountdownTimer()
|
||||||
syncedBackendSessionStartId = ''
|
syncedBackendSessionStartId = ''
|
||||||
syncedBackendSessionFinishId = ''
|
syncedBackendSessionFinishId = ''
|
||||||
|
redirectedToResultPage = false
|
||||||
shouldAutoRestoreRecoverySnapshot = options && options.recoverSession === '1'
|
shouldAutoRestoreRecoverySnapshot = options && options.recoverSession === '1'
|
||||||
currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options)
|
currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options)
|
||||||
if (!hasExplicitLaunchOptions(options)) {
|
if (!hasExplicitLaunchOptions(options)) {
|
||||||
@@ -959,6 +999,7 @@ Page({
|
|||||||
const includeRulerFields = this.data.showCenterScaleRuler
|
const includeRulerFields = this.data.showCenterScaleRuler
|
||||||
let shouldSyncRuntimeSystemSettings = false
|
let shouldSyncRuntimeSystemSettings = false
|
||||||
let nextLockLifetimeActive = isSystemSettingsLockLifetimeActive()
|
let nextLockLifetimeActive = isSystemSettingsLockLifetimeActive()
|
||||||
|
let heartRateSwitchToastText = ''
|
||||||
const nextData: Partial<MapPageData> = filterDebugOnlyPatch({
|
const nextData: Partial<MapPageData> = filterDebugOnlyPatch({
|
||||||
...nextPatch,
|
...nextPatch,
|
||||||
}, includeDebugFields, includeRulerFields)
|
}, includeDebugFields, includeRulerFields)
|
||||||
@@ -1054,6 +1095,8 @@ Page({
|
|||||||
: this.data.animationLevel
|
: this.data.animationLevel
|
||||||
let shouldSyncBackendSessionStart = false
|
let shouldSyncBackendSessionStart = false
|
||||||
let backendSessionFinishStatus: 'finished' | 'failed' | null = null
|
let backendSessionFinishStatus: 'finished' | 'failed' | null = null
|
||||||
|
let shouldOpenResultExitPrompt = false
|
||||||
|
let resultPageSnapshot: MapEngineResultSnapshot | null = null
|
||||||
|
|
||||||
if (nextAnimationLevel === 'lite') {
|
if (nextAnimationLevel === 'lite') {
|
||||||
clearHudFxTimer('timer')
|
clearHudFxTimer('timer')
|
||||||
@@ -1112,13 +1155,24 @@ Page({
|
|||||||
shouldSyncRuntimeSystemSettings = true
|
shouldSyncRuntimeSystemSettings = true
|
||||||
clearSessionRecoverySnapshot()
|
clearSessionRecoverySnapshot()
|
||||||
clearSessionRecoveryPersistTimer()
|
clearSessionRecoveryPersistTimer()
|
||||||
this.syncResultSceneSnapshot()
|
clearResultExitRedirectTimer()
|
||||||
|
clearResultExitCountdownTimer()
|
||||||
|
resultPageSnapshot = mapEngine ? mapEngine.getResultSceneSnapshot() : null
|
||||||
nextData.showResultScene = true
|
nextData.showResultScene = true
|
||||||
nextData.showDebugPanel = false
|
nextData.showDebugPanel = false
|
||||||
nextData.showGameInfoPanel = false
|
nextData.showGameInfoPanel = false
|
||||||
nextData.showSystemSettingsPanel = false
|
nextData.showSystemSettingsPanel = false
|
||||||
clearGameInfoPanelSyncTimer()
|
clearGameInfoPanelSyncTimer()
|
||||||
backendSessionFinishStatus = nextPatch.gameSessionStatus === 'finished' ? 'finished' : 'failed'
|
backendSessionFinishStatus = nextPatch.gameSessionStatus === 'finished' ? 'finished' : 'failed'
|
||||||
|
shouldOpenResultExitPrompt = true
|
||||||
|
if (resultPageSnapshot) {
|
||||||
|
nextData.resultSceneTitle = resultPageSnapshot.title
|
||||||
|
nextData.resultSceneSubtitle = resultPageSnapshot.subtitle
|
||||||
|
nextData.resultSceneHeroLabel = resultPageSnapshot.heroLabel
|
||||||
|
nextData.resultSceneHeroValue = resultPageSnapshot.heroValue
|
||||||
|
nextData.resultSceneRows = resultPageSnapshot.rows
|
||||||
|
}
|
||||||
|
nextData.resultSceneCountdownText = '3 秒后自动进入成绩页'
|
||||||
} else if (
|
} else if (
|
||||||
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
|
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
|
||||||
&& nextPatch.gameSessionStatus === 'idle'
|
&& nextPatch.gameSessionStatus === 'idle'
|
||||||
@@ -1128,6 +1182,8 @@ Page({
|
|||||||
shouldSyncRuntimeSystemSettings = true
|
shouldSyncRuntimeSystemSettings = true
|
||||||
clearSessionRecoverySnapshot()
|
clearSessionRecoverySnapshot()
|
||||||
clearSessionRecoveryPersistTimer()
|
clearSessionRecoveryPersistTimer()
|
||||||
|
clearResultExitRedirectTimer()
|
||||||
|
clearResultExitCountdownTimer()
|
||||||
} else if (
|
} else if (
|
||||||
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
|
nextPatch.gameSessionStatus !== this.data.gameSessionStatus
|
||||||
&& nextPatch.gameSessionStatus === 'running'
|
&& nextPatch.gameSessionStatus === 'running'
|
||||||
@@ -1138,6 +1194,19 @@ Page({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
pendingHeartRateSwitchDeviceName
|
||||||
|
&& nextPatch.heartRateConnected === true
|
||||||
|
&& typeof nextPatch.heartRateDeviceText === 'string'
|
||||||
|
) {
|
||||||
|
const connectedDeviceName = nextPatch.heartRateDeviceText.trim()
|
||||||
|
if (connectedDeviceName && connectedDeviceName === pendingHeartRateSwitchDeviceName) {
|
||||||
|
heartRateSwitchToastText = `已切换到 ${connectedDeviceName}`
|
||||||
|
nextData.statusText = `已切换心率带:${connectedDeviceName}`
|
||||||
|
pendingHeartRateSwitchDeviceName = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(nextData).length || Object.keys(derivedPatch).length) {
|
if (Object.keys(nextData).length || Object.keys(derivedPatch).length) {
|
||||||
this.setData({
|
this.setData({
|
||||||
...nextData,
|
...nextData,
|
||||||
@@ -1151,6 +1220,17 @@ Page({
|
|||||||
}
|
}
|
||||||
if (backendSessionFinishStatus) {
|
if (backendSessionFinishStatus) {
|
||||||
this.syncBackendSessionFinish(backendSessionFinishStatus)
|
this.syncBackendSessionFinish(backendSessionFinishStatus)
|
||||||
|
}
|
||||||
|
if (shouldOpenResultExitPrompt && resultPageSnapshot) {
|
||||||
|
this.stashPendingResultSnapshot(resultPageSnapshot)
|
||||||
|
this.presentResultExitPrompt()
|
||||||
|
}
|
||||||
|
if (heartRateSwitchToastText) {
|
||||||
|
wx.showToast({
|
||||||
|
title: `${heartRateSwitchToastText},并设为首选设备`,
|
||||||
|
icon: 'none',
|
||||||
|
duration: 1800,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (shouldSyncRuntimeSystemSettings) {
|
if (shouldSyncRuntimeSystemSettings) {
|
||||||
this.applyRuntimeSystemSettings(nextLockLifetimeActive)
|
this.applyRuntimeSystemSettings(nextLockLifetimeActive)
|
||||||
@@ -1169,6 +1249,10 @@ Page({
|
|||||||
if (backendSessionFinishStatus) {
|
if (backendSessionFinishStatus) {
|
||||||
this.syncBackendSessionFinish(backendSessionFinishStatus)
|
this.syncBackendSessionFinish(backendSessionFinishStatus)
|
||||||
}
|
}
|
||||||
|
if (shouldOpenResultExitPrompt && resultPageSnapshot) {
|
||||||
|
this.stashPendingResultSnapshot(resultPageSnapshot)
|
||||||
|
this.presentResultExitPrompt()
|
||||||
|
}
|
||||||
if (shouldSyncRuntimeSystemSettings) {
|
if (shouldSyncRuntimeSystemSettings) {
|
||||||
this.applyRuntimeSystemSettings(nextLockLifetimeActive)
|
this.applyRuntimeSystemSettings(nextLockLifetimeActive)
|
||||||
}
|
}
|
||||||
@@ -1209,6 +1293,7 @@ Page({
|
|||||||
...buildResolvedSystemSettingsPatch(systemSettingsState),
|
...buildResolvedSystemSettingsPatch(systemSettingsState),
|
||||||
showDebugPanel: false,
|
showDebugPanel: false,
|
||||||
showGameInfoPanel: false,
|
showGameInfoPanel: false,
|
||||||
|
showResultScene: false,
|
||||||
showSystemSettingsPanel: false,
|
showSystemSettingsPanel: false,
|
||||||
statusBarHeight,
|
statusBarHeight,
|
||||||
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
|
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
|
||||||
@@ -1218,6 +1303,12 @@ Page({
|
|||||||
gameInfoSubtitle: '未开始',
|
gameInfoSubtitle: '未开始',
|
||||||
gameInfoLocalRows: [],
|
gameInfoLocalRows: [],
|
||||||
gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
|
gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
|
||||||
|
resultSceneTitle: '本局结果',
|
||||||
|
resultSceneSubtitle: '未开始',
|
||||||
|
resultSceneHeroLabel: '本局用时',
|
||||||
|
resultSceneHeroValue: '--',
|
||||||
|
resultSceneRows: buildEmptyResultSceneSnapshot().rows,
|
||||||
|
resultSceneCountdownText: '',
|
||||||
panelTimerText: '00:00:00',
|
panelTimerText: '00:00:00',
|
||||||
panelTimerMode: 'elapsed',
|
panelTimerMode: 'elapsed',
|
||||||
panelTimerFxClass: '',
|
panelTimerFxClass: '',
|
||||||
@@ -1349,6 +1440,18 @@ Page({
|
|||||||
stageCanvasAttached = false
|
stageCanvasAttached = false
|
||||||
this.measureStageAndCanvas()
|
this.measureStageAndCanvas()
|
||||||
this.loadGameLaunchEnvelope(currentGameLaunchEnvelope)
|
this.loadGameLaunchEnvelope(currentGameLaunchEnvelope)
|
||||||
|
const app = getApp<IAppOption>()
|
||||||
|
const pendingHeartRateAutoConnect = app.globalData ? app.globalData.pendingHeartRateAutoConnect : null
|
||||||
|
if (pendingHeartRateAutoConnect && pendingHeartRateAutoConnect.enabled && mapEngine) {
|
||||||
|
const pendingDeviceName = pendingHeartRateAutoConnect.deviceName || '心率带'
|
||||||
|
app.globalData.pendingHeartRateAutoConnect = null
|
||||||
|
mapEngine.handleConnectHeartRate()
|
||||||
|
this.setData({
|
||||||
|
statusText: `正在自动连接局前设备:${pendingDeviceName}`,
|
||||||
|
heartRateStatusText: `正在自动连接 ${pendingDeviceName}`,
|
||||||
|
heartRateDeviceText: pendingDeviceName,
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onShow() {
|
onShow() {
|
||||||
@@ -1360,6 +1463,8 @@ Page({
|
|||||||
|
|
||||||
onHide() {
|
onHide() {
|
||||||
this.persistSessionRecoverySnapshot()
|
this.persistSessionRecoverySnapshot()
|
||||||
|
clearResultExitRedirectTimer()
|
||||||
|
clearResultExitCountdownTimer()
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
mapEngine.handleAppHide()
|
mapEngine.handleAppHide()
|
||||||
}
|
}
|
||||||
@@ -1368,6 +1473,8 @@ Page({
|
|||||||
onUnload() {
|
onUnload() {
|
||||||
this.persistSessionRecoverySnapshot()
|
this.persistSessionRecoverySnapshot()
|
||||||
clearSessionRecoveryPersistTimer()
|
clearSessionRecoveryPersistTimer()
|
||||||
|
clearResultExitRedirectTimer()
|
||||||
|
clearResultExitCountdownTimer()
|
||||||
syncedBackendSessionStartId = ''
|
syncedBackendSessionStartId = ''
|
||||||
syncedBackendSessionFinishId = ''
|
syncedBackendSessionFinishId = ''
|
||||||
clearGameInfoPanelSyncTimer()
|
clearGameInfoPanelSyncTimer()
|
||||||
@@ -1388,6 +1495,7 @@ Page({
|
|||||||
systemSettingsLockLifetimeActive = false
|
systemSettingsLockLifetimeActive = false
|
||||||
currentGameLaunchEnvelope = getDemoGameLaunchEnvelope()
|
currentGameLaunchEnvelope = getDemoGameLaunchEnvelope()
|
||||||
shouldAutoRestoreRecoverySnapshot = false
|
shouldAutoRestoreRecoverySnapshot = false
|
||||||
|
redirectedToResultPage = false
|
||||||
stageCanvasAttached = false
|
stageCanvasAttached = false
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1528,6 +1636,57 @@ Page({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
stashPendingResultSnapshot(snapshot: MapEngineResultSnapshot) {
|
||||||
|
const app = getApp<IAppOption>()
|
||||||
|
if (app.globalData) {
|
||||||
|
app.globalData.pendingResultSnapshot = snapshot
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
redirectToResultPage() {
|
||||||
|
if (redirectedToResultPage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearResultExitRedirectTimer()
|
||||||
|
clearResultExitCountdownTimer()
|
||||||
|
redirectedToResultPage = true
|
||||||
|
const sessionContext = getCurrentBackendSessionContext()
|
||||||
|
const resultUrl = sessionContext
|
||||||
|
? `/pages/result/result?sessionId=${encodeURIComponent(sessionContext.sessionId)}`
|
||||||
|
: '/pages/result/result'
|
||||||
|
wx.redirectTo({
|
||||||
|
url: resultUrl,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
presentResultExitPrompt() {
|
||||||
|
clearResultExitRedirectTimer()
|
||||||
|
clearResultExitCountdownTimer()
|
||||||
|
|
||||||
|
let remainingSeconds = Math.ceil(RESULT_EXIT_REDIRECT_DELAY_MS / 1000)
|
||||||
|
this.setData({
|
||||||
|
showResultScene: true,
|
||||||
|
resultSceneCountdownText: `${remainingSeconds} 秒后自动进入成绩页`,
|
||||||
|
})
|
||||||
|
|
||||||
|
resultExitCountdownTimer = setInterval(() => {
|
||||||
|
remainingSeconds -= 1
|
||||||
|
if (remainingSeconds <= 0) {
|
||||||
|
clearResultExitCountdownTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
resultSceneCountdownText: `${remainingSeconds} 秒后自动进入成绩页`,
|
||||||
|
})
|
||||||
|
}, 1000) as unknown as number
|
||||||
|
|
||||||
|
resultExitRedirectTimer = setTimeout(() => {
|
||||||
|
resultExitRedirectTimer = 0
|
||||||
|
this.redirectToResultPage()
|
||||||
|
}, RESULT_EXIT_REDIRECT_DELAY_MS) as unknown as number
|
||||||
|
},
|
||||||
|
|
||||||
restoreRecoverySnapshot(snapshot: SessionRecoverySnapshot) {
|
restoreRecoverySnapshot(snapshot: SessionRecoverySnapshot) {
|
||||||
systemSettingsLockLifetimeActive = true
|
systemSettingsLockLifetimeActive = true
|
||||||
this.applyRuntimeSystemSettings(true)
|
this.applyRuntimeSystemSettings(true)
|
||||||
@@ -2052,12 +2211,36 @@ Page({
|
|||||||
},
|
},
|
||||||
|
|
||||||
handleConnectHeartRate() {
|
handleConnectHeartRate() {
|
||||||
|
if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
mapEngine.handleConnectHeartRate()
|
mapEngine.handleConnectHeartRate()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleOpenHeartRateDevicePicker() {
|
||||||
|
if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setData({
|
||||||
|
showHeartRateDevicePicker: true,
|
||||||
|
})
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleConnectHeartRate()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleCloseHeartRateDevicePicker() {
|
||||||
|
this.setData({
|
||||||
|
showHeartRateDevicePicker: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
handleDisconnectHeartRate() {
|
handleDisconnectHeartRate() {
|
||||||
|
if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
mapEngine.handleDisconnectHeartRate()
|
mapEngine.handleDisconnectHeartRate()
|
||||||
}
|
}
|
||||||
@@ -2065,7 +2248,16 @@ Page({
|
|||||||
|
|
||||||
handleConnectHeartRateDevice(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) {
|
handleConnectHeartRateDevice(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) {
|
||||||
if (mapEngine && event.currentTarget && event.currentTarget.dataset && event.currentTarget.dataset.deviceId) {
|
if (mapEngine && event.currentTarget && event.currentTarget.dataset && event.currentTarget.dataset.deviceId) {
|
||||||
mapEngine.handleConnectHeartRateDevice(event.currentTarget.dataset.deviceId)
|
const targetDeviceId = event.currentTarget.dataset.deviceId
|
||||||
|
const targetDevice = this.data.heartRateDiscoveredDevices.find((item) => item.deviceId === targetDeviceId)
|
||||||
|
pendingHeartRateSwitchDeviceName = targetDevice ? targetDevice.name : null
|
||||||
|
mapEngine.handleConnectHeartRateDevice(targetDeviceId)
|
||||||
|
this.setData({
|
||||||
|
showHeartRateDevicePicker: false,
|
||||||
|
statusText: targetDevice
|
||||||
|
? `正在切换到 ${targetDevice.name}`
|
||||||
|
: '正在切换心率带设备',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2174,9 +2366,21 @@ Page({
|
|||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
success: (result) => {
|
success: (result) => {
|
||||||
if (result.confirm && mapEngine) {
|
if (result.confirm && mapEngine) {
|
||||||
|
clearResultExitRedirectTimer()
|
||||||
|
clearResultExitCountdownTimer()
|
||||||
this.syncBackendSessionFinish('cancelled')
|
this.syncBackendSessionFinish('cancelled')
|
||||||
|
clearSessionRecoverySnapshot()
|
||||||
|
clearSessionRecoveryPersistTimer()
|
||||||
systemSettingsLockLifetimeActive = false
|
systemSettingsLockLifetimeActive = false
|
||||||
mapEngine.handleForceExitGame()
|
mapEngine.handleForceExitGame()
|
||||||
|
wx.showToast({
|
||||||
|
title: '已退出当前对局',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 1000,
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
navigateAwayFromMapAfterCancel()
|
||||||
|
}, 180)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -2312,24 +2516,11 @@ Page({
|
|||||||
handleResultSceneTap() {},
|
handleResultSceneTap() {},
|
||||||
|
|
||||||
handleCloseResultScene() {
|
handleCloseResultScene() {
|
||||||
this.setData({
|
this.redirectToResultPage()
|
||||||
showResultScene: false,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
handleRestartFromResult() {
|
handleRestartFromResult() {
|
||||||
if (!mapEngine) {
|
this.redirectToResultPage()
|
||||||
return
|
|
||||||
}
|
|
||||||
this.setData({
|
|
||||||
showResultScene: false,
|
|
||||||
}, () => {
|
|
||||||
if (mapEngine) {
|
|
||||||
systemSettingsLockLifetimeActive = true
|
|
||||||
this.applyRuntimeSystemSettings(true)
|
|
||||||
mapEngine.handleStartGame()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
handleOpenSystemSettingsPanel() {
|
handleOpenSystemSettingsPanel() {
|
||||||
|
|||||||
@@ -324,7 +324,7 @@
|
|||||||
|
|
||||||
<view class="result-scene-modal" wx:if="{{showResultScene}}" bindtap="handleCloseResultScene">
|
<view class="result-scene-modal" wx:if="{{showResultScene}}" bindtap="handleCloseResultScene">
|
||||||
<view class="result-scene-modal__dialog" catchtap="handleResultSceneTap">
|
<view class="result-scene-modal__dialog" catchtap="handleResultSceneTap">
|
||||||
<view class="result-scene-modal__eyebrow">RESULT</view>
|
<view class="result-scene-modal__eyebrow">FINISH</view>
|
||||||
<view class="result-scene-modal__title">{{resultSceneTitle}}</view>
|
<view class="result-scene-modal__title">{{resultSceneTitle}}</view>
|
||||||
<view class="result-scene-modal__subtitle">{{resultSceneSubtitle}}</view>
|
<view class="result-scene-modal__subtitle">{{resultSceneSubtitle}}</view>
|
||||||
|
|
||||||
@@ -340,9 +340,10 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="result-scene-modal__countdown">{{resultSceneCountdownText}}</view>
|
||||||
|
|
||||||
<view class="result-scene-modal__actions">
|
<view class="result-scene-modal__actions">
|
||||||
<view class="result-scene-modal__action result-scene-modal__action--secondary" bindtap="handleCloseResultScene">返回地图</view>
|
<view class="result-scene-modal__action result-scene-modal__action--primary" bindtap="handleRestartFromResult">查看成绩</view>
|
||||||
<view class="result-scene-modal__action result-scene-modal__action--primary" bindtap="handleRestartFromResult">再来一局</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -726,13 +727,31 @@
|
|||||||
<view class="debug-section__header-row">
|
<view class="debug-section__header-row">
|
||||||
<view class="debug-section__header-main">
|
<view class="debug-section__header-main">
|
||||||
<view class="debug-section__title">16. 心率设备</view>
|
<view class="debug-section__title">16. 心率设备</view>
|
||||||
<view class="debug-section__desc">清除已记住的首选心率带设备,下次重新选择</view>
|
<view class="debug-section__desc">局内正式入口,可快速更换、重连或断开当前心率带</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockHeartRateDevice ? 'debug-section__lock--active' : ''}}">
|
<view class="debug-section__lock {{lockHeartRateDevice ? 'debug-section__lock--active' : ''}}">
|
||||||
<text class="debug-section__lock-text">{{lockHeartRateDevice ? '配置锁定' : '允许调整'}}</text>
|
<text class="debug-section__lock-text">{{lockHeartRateDevice ? '配置锁定' : '允许调整'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">当前状态</text>
|
||||||
|
<text class="info-panel__value">{{heartRateStatusText}}{{heartRateSourceMode !== 'real' ? ' · 当前为模拟模式' : ''}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">当前设备</text>
|
||||||
|
<text class="info-panel__value">{{heartRateDeviceText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row" wx:if="{{heartRateSourceMode === 'real'}}">
|
||||||
|
<text class="info-panel__label">扫描状态</text>
|
||||||
|
<text class="info-panel__value">{{heartRateScanText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="summary" wx:if="{{heartRateSourceMode !== 'real'}}">当前为模拟心率模式,如需连接真实心率带,请先在调试面板切回“真实心率”。</view>
|
||||||
|
<view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
|
||||||
|
<view class="control-chip control-chip--secondary {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleOpenHeartRateDevicePicker">更换心率带</view>
|
||||||
|
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}} {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '重新扫描' : '连接心率带'}}</view>
|
||||||
|
<view class="control-chip control-chip--secondary {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleDisconnectHeartRate">断开心率带</view>
|
||||||
|
</view>
|
||||||
<view class="control-row">
|
<view class="control-row">
|
||||||
<view class="control-chip control-chip--secondary {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleClearPreferredHeartRateDevice">清除首选设备</view>
|
<view class="control-chip control-chip--secondary {{lockHeartRateDevice ? 'control-chip--disabled' : ''}}" bindtap="handleClearPreferredHeartRateDevice">清除首选设备</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -897,25 +916,10 @@
|
|||||||
<text class="info-panel__label">HR Scan</text>
|
<text class="info-panel__label">HR Scan</text>
|
||||||
<text class="info-panel__value">{{heartRateScanText}}</text>
|
<text class="info-panel__value">{{heartRateScanText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-device-list" wx:if="{{heartRateSourceMode === 'real' && heartRateDiscoveredDevices.length}}">
|
|
||||||
<view class="debug-device-card" wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId">
|
|
||||||
<view class="debug-device-card__main">
|
|
||||||
<view class="debug-device-card__title-row">
|
|
||||||
<text class="debug-device-card__name">{{item.name}}</text>
|
|
||||||
<text class="debug-device-card__badge" wx:if="{{item.preferred}}">首选</text>
|
|
||||||
</view>
|
|
||||||
<text class="debug-device-card__meta">{{item.rssiText}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="debug-device-card__action {{item.connected ? 'debug-device-card__action--active' : ''}}" data-device-id="{{item.deviceId}}" bindtap="handleConnectHeartRateDevice">{{item.connected ? '已连接' : '连接'}}</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
|
<view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
|
||||||
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
|
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '重新扫描' : '连接心率带'}}</view>
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
|
|
||||||
</view>
|
|
||||||
<view class="control-row" wx:if="{{heartRateSourceMode === 'real'}}">
|
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
<view class="summary" wx:if="{{heartRateSourceMode === 'real'}}">正式用户入口已放到系统设置;这里仅保留心率源切换与开发调试能力。</view>
|
||||||
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
|
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
|
||||||
<text class="info-panel__label">心率模拟状态</text>
|
<text class="info-panel__label">心率模拟状态</text>
|
||||||
<text class="info-panel__value">{{mockHeartRateBridgeStatusText}}</text>
|
<text class="info-panel__value">{{mockHeartRateBridgeStatusText}}</text>
|
||||||
@@ -1172,6 +1176,29 @@
|
|||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view wx:if="{{showHeartRateDevicePicker}}" class="picker-mask" bindtap="handleCloseHeartRateDevicePicker"></view>
|
||||||
|
<view wx:if="{{showHeartRateDevicePicker}}" class="picker-sheet">
|
||||||
|
<view class="picker-sheet__header">
|
||||||
|
<view class="picker-sheet__title">选择心率带设备</view>
|
||||||
|
<button class="picker-sheet__close" bindtap="handleCloseHeartRateDevicePicker">关闭</button>
|
||||||
|
</view>
|
||||||
|
<view class="summary">扫描状态:{{heartRateScanText}}</view>
|
||||||
|
<view wx:if="{{!heartRateDiscoveredDevices.length}}" class="summary">当前还没有发现设备,可先点“重新扫描”。</view>
|
||||||
|
<view wx:if="{{heartRateDiscoveredDevices.length}}" class="device-list">
|
||||||
|
<view wx:for="{{heartRateDiscoveredDevices}}" wx:key="deviceId" class="device-card">
|
||||||
|
<view class="device-card__main">
|
||||||
|
<view class="device-card__title-row">
|
||||||
|
<text class="device-card__name">{{item.name}}</text>
|
||||||
|
<text class="device-card__badge" wx:if="{{item.preferred}}">首选</text>
|
||||||
|
<text class="device-card__badge device-card__badge--active" wx:if="{{item.connected}}">已连接</text>
|
||||||
|
</view>
|
||||||
|
<text class="device-card__meta">{{item.rssiText}}</text>
|
||||||
|
</view>
|
||||||
|
<button class="btn {{item.connected ? 'btn--ghost' : 'btn--secondary'}} device-card__action" data-device-id="{{item.deviceId}}" bindtap="handleConnectHeartRateDevice">{{item.connected ? '已连接' : '连接'}}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1458,6 +1458,14 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-scene-modal__countdown {
|
||||||
|
margin-top: 18rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #6a826f;
|
||||||
|
}
|
||||||
|
|
||||||
.result-scene-modal__actions {
|
.result-scene-modal__actions {
|
||||||
margin-top: 28rpx;
|
margin-top: 28rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1781,6 +1789,143 @@
|
|||||||
color: #f7fbf2;
|
color: #f7fbf2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picker-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 22, 38, 0.42);
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sheet {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 91;
|
||||||
|
display: grid;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 24rpx 24rpx 36rpx;
|
||||||
|
border-top-left-radius: 28rpx;
|
||||||
|
border-top-right-radius: 28rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
box-shadow: 0 -14rpx 36rpx rgba(22, 43, 71, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sheet__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sheet__title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #17345a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sheet__close {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 60rpx;
|
||||||
|
padding: 0 18rpx;
|
||||||
|
line-height: 60rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
background: #eef3f8;
|
||||||
|
color: #455a72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-sheet__close::after {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #30465f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16rpx;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: #f6f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__main {
|
||||||
|
display: grid;
|
||||||
|
gap: 8rpx;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__title-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10rpx;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__name {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #17345a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__badge {
|
||||||
|
padding: 4rpx 10rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #e1ecfa;
|
||||||
|
color: #35567d;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__badge--active {
|
||||||
|
background: #dff3e8;
|
||||||
|
color: #1f6a45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__meta {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #5c7288;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card__action {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
.control-row {
|
.control-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14rpx;
|
gap: 14rpx;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
|
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
|
||||||
import { getMyResults, getSessionResult, type BackendSessionResultView } from '../../utils/backendApi'
|
import { getSessionResult } from '../../utils/backendApi'
|
||||||
|
import type { MapEngineResultSnapshot } from '../../engine/map/mapEngine'
|
||||||
|
|
||||||
type ResultPageData = {
|
type ResultPageData = {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -7,7 +8,6 @@ type ResultPageData = {
|
|||||||
sessionTitleText: string
|
sessionTitleText: string
|
||||||
sessionSubtitleText: string
|
sessionSubtitleText: string
|
||||||
rows: Array<{ label: string; value: string }>
|
rows: Array<{ label: string; value: string }>
|
||||||
recentResults: BackendSessionResultView[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAccessToken(): string | null {
|
function getAccessToken(): string | null {
|
||||||
@@ -25,6 +25,22 @@ function formatValue(value: unknown): string {
|
|||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRouteSummary(input: {
|
||||||
|
variantName?: string | null
|
||||||
|
routeCode?: string | null
|
||||||
|
}): string {
|
||||||
|
if (input.variantName && input.routeCode) {
|
||||||
|
return `${input.variantName} / ${input.routeCode}`
|
||||||
|
}
|
||||||
|
if (input.variantName) {
|
||||||
|
return input.variantName
|
||||||
|
}
|
||||||
|
if (input.routeCode) {
|
||||||
|
return input.routeCode
|
||||||
|
}
|
||||||
|
return '默认赛道'
|
||||||
|
}
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
@@ -32,17 +48,49 @@ Page({
|
|||||||
sessionTitleText: '结果页',
|
sessionTitleText: '结果页',
|
||||||
sessionSubtitleText: '未加载',
|
sessionSubtitleText: '未加载',
|
||||||
rows: [],
|
rows: [],
|
||||||
recentResults: [],
|
|
||||||
} as ResultPageData,
|
} as ResultPageData,
|
||||||
|
|
||||||
onLoad(query: { sessionId?: string }) {
|
onLoad(query: { sessionId?: string }) {
|
||||||
const sessionId = query && query.sessionId ? decodeURIComponent(query.sessionId) : ''
|
const sessionId = query && query.sessionId ? decodeURIComponent(query.sessionId) : ''
|
||||||
this.setData({ sessionId })
|
this.setData({ sessionId })
|
||||||
|
this.applyPendingResultSnapshot()
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
this.loadSingleResult(sessionId)
|
this.loadSingleResult(sessionId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.loadRecentResults()
|
this.setData({
|
||||||
|
statusText: '未提供单局会话,已跳转历史结果',
|
||||||
|
})
|
||||||
|
wx.redirectTo({
|
||||||
|
url: '/pages/results/results',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
applyPendingResultSnapshot() {
|
||||||
|
const app = getApp<IAppOption>()
|
||||||
|
const snapshot = app.globalData && app.globalData.pendingResultSnapshot
|
||||||
|
? app.globalData.pendingResultSnapshot as MapEngineResultSnapshot
|
||||||
|
: null
|
||||||
|
if (!snapshot) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
statusText: '正在加载结果',
|
||||||
|
sessionTitleText: snapshot.title,
|
||||||
|
sessionSubtitleText: snapshot.subtitle,
|
||||||
|
rows: [
|
||||||
|
{ label: snapshot.heroLabel, value: snapshot.heroValue },
|
||||||
|
...snapshot.rows.map((row) => ({
|
||||||
|
label: row.label,
|
||||||
|
value: row.value,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (app.globalData) {
|
||||||
|
app.globalData.pendingResultSnapshot = null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadSingleResult(sessionId: string) {
|
async loadSingleResult(sessionId: string) {
|
||||||
@@ -65,8 +113,9 @@ Page({
|
|||||||
this.setData({
|
this.setData({
|
||||||
statusText: '单局结果加载完成',
|
statusText: '单局结果加载完成',
|
||||||
sessionTitleText: result.session.eventName || result.session.eventDisplayName || result.session.eventId || result.session.id || result.session.sessionId,
|
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}`,
|
sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status} / ${formatRouteSummary(result.session)}`,
|
||||||
rows: [
|
rows: [
|
||||||
|
{ label: '赛道版本', value: formatRouteSummary(result.session) },
|
||||||
{ label: '最终得分', value: formatValue(result.result.finalScore) },
|
{ label: '最终得分', value: formatValue(result.result.finalScore) },
|
||||||
{ label: '最终用时(秒)', value: formatValue(result.result.finalDurationSec) },
|
{ label: '最终用时(秒)', value: formatValue(result.result.finalDurationSec) },
|
||||||
{ label: '完成点数', value: formatValue(result.result.completedControls) },
|
{ label: '完成点数', value: formatValue(result.result.completedControls) },
|
||||||
@@ -84,51 +133,9 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
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() {
|
handleBackToList() {
|
||||||
this.setData({
|
wx.redirectTo({
|
||||||
sessionId: '',
|
url: '/pages/results/results',
|
||||||
rows: [],
|
|
||||||
})
|
})
|
||||||
this.loadRecentResults()
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<view class="panel">
|
<view class="panel">
|
||||||
<view class="panel__title">当前状态</view>
|
<view class="panel__title">当前状态</view>
|
||||||
<view class="summary">{{statusText}}</view>
|
<view class="summary">{{statusText}}</view>
|
||||||
<button wx:if="{{sessionId}}" class="btn btn--ghost" bindtap="handleBackToList">返回最近结果</button>
|
<button class="btn btn--ghost" bindtap="handleBackToList">查看历史结果</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view wx:if="{{rows.length}}" class="panel">
|
<view wx:if="{{rows.length}}" class="panel">
|
||||||
@@ -19,15 +19,5 @@
|
|||||||
<view class="row__value">{{item.value}}</view>
|
<view class="row__value">{{item.value}}</view>
|
||||||
</view>
|
</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>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|||||||
104
miniprogram/pages/results/results.ts
Normal file
104
miniprogram/pages/results/results.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth'
|
||||||
|
import { getMyResults, type BackendSessionResultView } from '../../utils/backendApi'
|
||||||
|
|
||||||
|
type ResultsPageData = {
|
||||||
|
loading: boolean
|
||||||
|
statusText: string
|
||||||
|
results: Array<{
|
||||||
|
sessionId: string
|
||||||
|
titleText: string
|
||||||
|
statusText: string
|
||||||
|
scoreText: string
|
||||||
|
routeText: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRouteSummary(result: BackendSessionResultView): string {
|
||||||
|
const session = result.session
|
||||||
|
if (session.variantName && session.routeCode) {
|
||||||
|
return `${session.variantName} / ${session.routeCode}`
|
||||||
|
}
|
||||||
|
if (session.variantName) {
|
||||||
|
return session.variantName
|
||||||
|
}
|
||||||
|
if (session.routeCode) {
|
||||||
|
return session.routeCode
|
||||||
|
}
|
||||||
|
return '默认赛道'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultCardView(result: BackendSessionResultView) {
|
||||||
|
return {
|
||||||
|
sessionId: result.session.id,
|
||||||
|
titleText: result.session.eventName || result.session.id,
|
||||||
|
statusText: `${result.result.status} / ${result.session.status}`,
|
||||||
|
scoreText: `得分 ${result.result.finalScore || '--'} / 用时 ${result.result.finalDurationSec || '--'}s`,
|
||||||
|
routeText: `赛道 ${formatRouteSummary(result)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Page({
|
||||||
|
data: {
|
||||||
|
loading: false,
|
||||||
|
statusText: '准备加载历史结果',
|
||||||
|
results: [],
|
||||||
|
} as ResultsPageData,
|
||||||
|
|
||||||
|
onLoad() {
|
||||||
|
this.loadResults()
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
this.loadResults()
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadResults() {
|
||||||
|
const accessToken = getAccessToken()
|
||||||
|
if (!accessToken) {
|
||||||
|
wx.redirectTo({ url: '/pages/login/login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
loading: true,
|
||||||
|
statusText: '正在加载历史结果',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await getMyResults({
|
||||||
|
baseUrl: loadBackendBaseUrl(),
|
||||||
|
accessToken,
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
this.setData({
|
||||||
|
loading: false,
|
||||||
|
statusText: `历史结果加载完成,共 ${results.length} 条`,
|
||||||
|
results: results.map(buildResultCardView),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误'
|
||||||
|
this.setData({
|
||||||
|
loading: false,
|
||||||
|
statusText: `历史结果加载失败:${message}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleOpenResult(event: WechatMiniprogram.TouchEvent) {
|
||||||
|
const sessionId = event.currentTarget.dataset.sessionId as string | undefined
|
||||||
|
if (!sessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wx.navigateTo({
|
||||||
|
url: `/pages/result/result?sessionId=${encodeURIComponent(sessionId)}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
25
miniprogram/pages/results/results.wxml
Normal file
25
miniprogram/pages/results/results.wxml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<scroll-view class="page" scroll-y>
|
||||||
|
<view class="shell">
|
||||||
|
<view class="hero">
|
||||||
|
<view class="hero__eyebrow">Results</view>
|
||||||
|
<view class="hero__title">历史结果</view>
|
||||||
|
<view class="hero__desc">查看最近联调与正式对局结果</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="panel">
|
||||||
|
<view class="panel__title">当前状态</view>
|
||||||
|
<view class="summary">{{statusText}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="panel">
|
||||||
|
<view class="panel__title">结果列表</view>
|
||||||
|
<view wx:if="{{!results.length}}" class="summary">当前没有结果记录</view>
|
||||||
|
<view wx:for="{{results}}" wx:key="sessionId" class="result-card" bindtap="handleOpenResult" data-session-id="{{item.sessionId}}">
|
||||||
|
<view class="result-card__title">{{item.titleText}}</view>
|
||||||
|
<view class="result-card__meta">{{item.statusText}}</view>
|
||||||
|
<view class="result-card__meta">{{item.scoreText}}</view>
|
||||||
|
<view class="result-card__meta">{{item.routeText}}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
76
miniprogram/pages/results/results.wxss
Normal file
76
miniprogram/pages/results/results.wxss
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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,
|
||||||
|
.result-card__meta {
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #30465f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 18rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: #f6f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card__title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #17345a;
|
||||||
|
}
|
||||||
@@ -30,6 +30,21 @@ export interface BackendResolvedRelease {
|
|||||||
routeCode?: string | null
|
routeCode?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackendCourseVariantSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
routeCode?: string | null
|
||||||
|
selectable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendLaunchVariantSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
routeCode?: string | null
|
||||||
|
assignmentMode?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackendEntrySessionSummary {
|
export interface BackendEntrySessionSummary {
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
@@ -38,6 +53,8 @@ export interface BackendEntrySessionSummary {
|
|||||||
releaseId?: string | null
|
releaseId?: string | null
|
||||||
configLabel?: string | null
|
configLabel?: string | null
|
||||||
routeCode?: string | null
|
routeCode?: string | null
|
||||||
|
variantId?: string | null
|
||||||
|
variantName?: string | null
|
||||||
launchedAt?: string | null
|
launchedAt?: string | null
|
||||||
startedAt?: string | null
|
startedAt?: string | null
|
||||||
endedAt?: string | null
|
endedAt?: string | null
|
||||||
@@ -111,6 +128,8 @@ export interface BackendEventPlayResult {
|
|||||||
primaryAction: string
|
primaryAction: string
|
||||||
reason: string
|
reason: string
|
||||||
launchSource?: string
|
launchSource?: string
|
||||||
|
assignmentMode?: string | null
|
||||||
|
courseVariants?: BackendCourseVariantSummary[] | null
|
||||||
ongoingSession?: BackendEntrySessionSummary | null
|
ongoingSession?: BackendEntrySessionSummary | null
|
||||||
recentSession?: BackendEntrySessionSummary | null
|
recentSession?: BackendEntrySessionSummary | null
|
||||||
}
|
}
|
||||||
@@ -139,6 +158,7 @@ export interface BackendLaunchResult {
|
|||||||
sessionTokenExpiresAt: string
|
sessionTokenExpiresAt: string
|
||||||
routeCode?: string | null
|
routeCode?: string | null
|
||||||
}
|
}
|
||||||
|
variant?: BackendLaunchVariantSummary | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +314,7 @@ export function launchEvent(input: {
|
|||||||
eventId: string
|
eventId: string
|
||||||
accessToken: string
|
accessToken: string
|
||||||
releaseId?: string
|
releaseId?: string
|
||||||
|
variantId?: string
|
||||||
clientType: string
|
clientType: string
|
||||||
deviceKey: string
|
deviceKey: string
|
||||||
}): Promise<BackendLaunchResult> {
|
}): Promise<BackendLaunchResult> {
|
||||||
@@ -304,6 +325,9 @@ export function launchEvent(input: {
|
|||||||
if (input.releaseId) {
|
if (input.releaseId) {
|
||||||
body.releaseId = input.releaseId
|
body.releaseId = input.releaseId
|
||||||
}
|
}
|
||||||
|
if (input.variantId) {
|
||||||
|
body.variantId = input.variantId
|
||||||
|
}
|
||||||
return requestBackend<BackendLaunchResult>({
|
return requestBackend<BackendLaunchResult>({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
baseUrl: input.baseUrl,
|
baseUrl: input.baseUrl,
|
||||||
|
|||||||
@@ -17,5 +17,17 @@ export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult):
|
|||||||
sessionToken: result.launch.business.sessionToken,
|
sessionToken: result.launch.business.sessionToken,
|
||||||
sessionTokenExpiresAt: result.launch.business.sessionTokenExpiresAt,
|
sessionTokenExpiresAt: result.launch.business.sessionTokenExpiresAt,
|
||||||
},
|
},
|
||||||
|
variant: result.launch.variant
|
||||||
|
? {
|
||||||
|
variantId: result.launch.variant.id,
|
||||||
|
variantName: result.launch.variant.name,
|
||||||
|
routeCode: result.launch.variant.routeCode || result.launch.config.routeCode || result.launch.business.routeCode || null,
|
||||||
|
assignmentMode: result.launch.variant.assignmentMode || null,
|
||||||
|
}
|
||||||
|
: (result.launch.config.routeCode || result.launch.business.routeCode)
|
||||||
|
? {
|
||||||
|
routeCode: result.launch.config.routeCode || result.launch.business.routeCode || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,17 @@ export interface BusinessLaunchContext {
|
|||||||
realtimeToken?: string | null
|
realtimeToken?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameVariantLaunchContext {
|
||||||
|
variantId?: string | null
|
||||||
|
variantName?: string | null
|
||||||
|
routeCode?: string | null
|
||||||
|
assignmentMode?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameLaunchEnvelope {
|
export interface GameLaunchEnvelope {
|
||||||
config: GameConfigLaunchRequest
|
config: GameConfigLaunchRequest
|
||||||
business: BusinessLaunchContext | null
|
business: BusinessLaunchContext | null
|
||||||
|
variant?: GameVariantLaunchContext | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapPageLaunchOptions {
|
export interface MapPageLaunchOptions {
|
||||||
@@ -46,6 +54,9 @@ export interface MapPageLaunchOptions {
|
|||||||
sessionTokenExpiresAt?: string
|
sessionTokenExpiresAt?: string
|
||||||
realtimeEndpoint?: string
|
realtimeEndpoint?: string
|
||||||
realtimeToken?: string
|
realtimeToken?: string
|
||||||
|
variantId?: string
|
||||||
|
variantName?: string
|
||||||
|
assignmentMode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingGameLaunchStore = Record<string, GameLaunchEnvelope>
|
type PendingGameLaunchStore = Record<string, GameLaunchEnvelope>
|
||||||
@@ -121,6 +132,28 @@ function buildBusinessLaunchContext(options?: MapPageLaunchOptions | null): Busi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildVariantLaunchContext(options?: MapPageLaunchOptions | null): GameVariantLaunchContext | null {
|
||||||
|
if (!options) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantId = normalizeOptionalString(options.variantId)
|
||||||
|
const variantName = normalizeOptionalString(options.variantName)
|
||||||
|
const routeCode = normalizeOptionalString(options.routeCode)
|
||||||
|
const assignmentMode = normalizeOptionalString(options.assignmentMode)
|
||||||
|
|
||||||
|
if (!variantId && !variantName && !routeCode && !assignmentMode) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
variantId,
|
||||||
|
variantName,
|
||||||
|
routeCode,
|
||||||
|
assignmentMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadPendingGameLaunchStore(): PendingGameLaunchStore {
|
function loadPendingGameLaunchStore(): PendingGameLaunchStore {
|
||||||
try {
|
try {
|
||||||
const stored = wx.getStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY)
|
const stored = wx.getStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY)
|
||||||
@@ -146,6 +179,7 @@ export function getDemoGameLaunchEnvelope(preset: DemoGamePreset = 'classic'): G
|
|||||||
business: {
|
business: {
|
||||||
source: 'demo',
|
source: 'demo',
|
||||||
},
|
},
|
||||||
|
variant: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +251,7 @@ export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null)
|
|||||||
routeCode: normalizeOptionalString(options ? options.routeCode : undefined),
|
routeCode: normalizeOptionalString(options ? options.routeCode : undefined),
|
||||||
},
|
},
|
||||||
business: buildBusinessLaunchContext(options),
|
business: buildBusinessLaunchContext(options),
|
||||||
|
variant: buildVariantLaunchContext(options),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
typings/index.d.ts
vendored
5
typings/index.d.ts
vendored
@@ -6,6 +6,11 @@ interface IAppOption {
|
|||||||
telemetryPlayerProfile?: import('../miniprogram/game/telemetry/playerTelemetryProfile').PlayerTelemetryProfile | null,
|
telemetryPlayerProfile?: import('../miniprogram/game/telemetry/playerTelemetryProfile').PlayerTelemetryProfile | null,
|
||||||
backendBaseUrl?: string | null,
|
backendBaseUrl?: string | null,
|
||||||
backendAuthTokens?: import('../miniprogram/utils/backendAuth').BackendAuthTokens | null,
|
backendAuthTokens?: import('../miniprogram/utils/backendAuth').BackendAuthTokens | null,
|
||||||
|
pendingResultSnapshot?: import('../miniprogram/engine/map/mapEngine').MapEngineResultSnapshot | null,
|
||||||
|
pendingHeartRateAutoConnect?: {
|
||||||
|
enabled: boolean,
|
||||||
|
deviceName?: string | null,
|
||||||
|
} | null,
|
||||||
}
|
}
|
||||||
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback,
|
userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user