diff --git a/b2f.md b/b2f.md index f71dcf9..4bb3881 100644 --- a/b2f.md +++ b/b2f.md @@ -1,6 +1,6 @@ # b2f -> 文档版本:v1.0 -> 最后更新:2026-04-02 09:01:17 +> 文档版本:v1.3 +> 最后更新:2026-04-02 15:25:40 说明: @@ -54,6 +54,51 @@ - 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 继续按当前补报 / 重试逻辑联调 - 是否已解决:是 +### 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 当前优先配合: - 用当前 demo release 回归 `play -> launch -> map load` - 回归“继续恢复 / 放弃恢复”两条路径 + - 如确认进入多赛道第一阶段联调,请先回复 `B2F-015` - 如发现状态口径不一致,直接在 `f2b.md` 标具体接口和返回值 - 是否已解决:否 diff --git a/backend/README.md b/backend/README.md index e4d4900..5a2a133 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Backend -> 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 文档版本:v1.1 +> 最后更新:2026-04-02 09:35:44 这套后端现在已经能支撑一条完整主链: @@ -46,5 +46,8 @@ go run .\cmd\api - 局生命周期:`start / finish / detail` - 局后结果:`/sessions/{id}/result`、`/me/results` - 开发工作台:`/dev/workbench` + - 用户主链调试 + - 资源对象与 Event 组装调试 + - Build / Publish / Rollback 调试 diff --git a/backend/docs/todolist.md b/backend/docs/todolist.md index 4703462..c56b56d 100644 --- a/backend/docs/todolist.md +++ b/backend/docs/todolist.md @@ -1,6 +1,6 @@ # Backend TodoList -> 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 文档版本:v1.2 +> 最后更新:2026-04-02 11:03:02 ## 1. 目标 @@ -37,6 +37,8 @@ - `evt_demo_001` 的 release manifest 现已可正常加载 - 小程序已能进入地图 +- `launch` 关键字段在当前阶段不再单边漂移 +- `cancelled / failed / finished` 已从 ongoing 口径里收稳 - 模拟定位 / 调试日志问题已回到小程序与模拟器侧,不再属于 backend 当前阻塞 前端当前需要配合的事项: @@ -160,27 +162,36 @@ backend 现在需要做的是: ## 4. P1 应尽快做 -## 4.1 给首页 / play / result 的 ongoing 语义再做一次回归确认 +## 4.1 多赛道 Variant 第一阶段最小契约 -当前前端已经开始走: +当前前端已给出: -- 首页聚合 -- `event play` -- `launch` -- `session start / finish` -- 本地故障恢复 +- [多赛道 Variant 五层设计草案](D:/dev/cmr-mini/doc/gameplay/多赛道Variant五层设计草案.md) +- [多赛道 Variant 前后端最小契约](D:/dev/cmr-mini/doc/gameplay/多赛道Variant前后端最小契约.md) -backend 建议再回归确认这几个接口对“进行中 session”的口径一致: +backend 当前建议第一阶段只做最小闭环: -- `/me/entry-home` -- `/events/{eventPublicID}/play` -- `/sessions/{sessionPublicID}/result` +- `play.assignmentMode` +- `play.courseVariants[]` +- `launch.variant.*` +- `session / result / ongoing / recent` 补 `variantId / variantName / routeCode` -重点确认: +当前目标: -1. `cancelled` 后不再继续出现在 ongoing 入口 -2. `failed` 后不再继续出现在 ongoing 入口 -3. `finished` 后结果页与首页摘要字段一致 +1. 一个 session 最终只绑定一个 `variantId` +2. `launch` 返回最终绑定结果 +3. 恢复链不重新分配 variant +4. 结果页、ongoing、历史结果都能追溯 variant + +备注: + +- 当前只先定最小契约,不先做完整后台 variant 编排模型 +- 当前第一阶段最小后端链路已补入: + - `play.assignmentMode` + - `play.courseVariants[]` + - `launch.variant.*` + - `session / result / ongoing / recent` 的 `variantId / variantName / routeCode` +- 下一步应由前端按该契约联调,不再继续扩后台 variant 模型 ## 4.2 增加用户身体资料读取接口 @@ -318,18 +329,18 @@ backend 后面如果要接业务结果页,最好提前定: ## 7. 我建议的最近动作 -backend 现在最值得先做的,不是扩接口,而是先确认下面 3 条: +backend 现在最值得先做的,不是继续铺更多页面接口,而是先推进下面 3 条: -1. `finished / failed / cancelled` 三态语义 -2. 放弃恢复是否写 `cancelled` -3. `start / finish` 是否按幂等处理 +1. 与前端确认多赛道第一阶段最小契约 +2. 已按最小契约扩完 `play -> launch -> session/result` +3. 再补用户身体资料接口和 workbench 恢复场景按钮 -这 3 条一旦确定,前后端联调会顺很多。 +这样不会打断当前主链,同时能把下一阶段多赛道联调接上。 ## 8. 一句话结论 当前 backend 最重要的任务不是“再加更多接口”,而是: -> 先把 session 运行态语义、放弃恢复语义和 ongoing session 口径定稳,再继续扩后台配置系统。 +> 在不破坏当前稳定主链的前提下,先把多赛道 Variant 第一阶段最小契约定稳,再继续向配置与后台模型延伸。 diff --git a/backend/docs/开发说明.md b/backend/docs/开发说明.md index 571d590..65f7352 100644 --- a/backend/docs/开发说明.md +++ b/backend/docs/开发说明.md @@ -1,6 +1,6 @@ # 开发说明 -> 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 文档版本:v1.1 +> 最后更新:2026-04-02 09:35:44 ## 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) +当前 workbench 已覆盖两类调试链: + +- 用户主链:`bootstrap -> auth -> entry/home -> event play/launch -> session -> result` +- 后台运营链:`maps/playfields/resource-packs -> admin event source -> build -> publish -> rollback` + ## 3. 当前开发约定 ### 3.1 开发阶段先不用 Redis diff --git a/backend/docs/接口清单.md b/backend/docs/接口清单.md index 5615cf3..2af383f 100644 --- a/backend/docs/接口清单.md +++ b/backend/docs/接口清单.md @@ -1,6 +1,6 @@ # API 清单 -> 文档版本:v1.0 -> 最后更新:2026-04-02 09:01:17 +> 文档版本:v1.1 +> 最后更新:2026-04-02 11:05:32 本文档只记录当前 backend 已实现接口,不写未来规划接口。 @@ -121,6 +121,12 @@ - `ongoingSession` - `recentSession` +`ongoingSession / recentSession` 当前会额外带: + +- `variantId` +- `variantName` +- `routeCode` + ## 4. Event ### `GET /events/{eventPublicID}` @@ -150,6 +156,8 @@ - `event` - `release` - `resolvedRelease` +- `play.assignmentMode` +- `play.courseVariants` - `play.canLaunch` - `play.primaryAction` - `play.launchSource` @@ -169,13 +177,21 @@ 请求体重点: - `releaseId` +- `variantId` - `clientType` - `deviceKey` +补充说明: + +- 如果当前 release 声明了 `play.courseVariants[]` +- `launch` 会返回最终绑定的 `launch.variant` +- 当前为兼容旧调用方,`assignmentMode=manual` 且未传 `variantId` 时,backend 会先回退到首个可选 variant + 返回重点: - `launch.source` - `launch.resolvedRelease` +- `launch.variant` - `launch.config` - `launch.business.sessionId` - `launch.business.sessionToken` @@ -228,6 +244,13 @@ - `event` - `resolvedRelease` +`session` 当前会额外带: + +- `assignmentMode` +- `variantId` +- `variantName` +- `routeCode` + ### `POST /sessions/{sessionPublicID}/start` 鉴权: @@ -312,6 +335,9 @@ - `releaseId` - `configLabel` +- `variantId` +- `variantName` +- `routeCode` ### `GET /me/results` diff --git a/backend/docs/核心流程.md b/backend/docs/核心流程.md index c90fdfc..77f5383 100644 --- a/backend/docs/核心流程.md +++ b/backend/docs/核心流程.md @@ -1,6 +1,6 @@ # 核心流程 -> 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 文档版本:v1.1 +> 最后更新:2026-04-02 11:03:02 ## 1. 总流程 @@ -100,6 +100,7 @@ APP 当前主链是手机号验证码: - 当前是否可启动 - 当前会落到哪份 `release` +- 当前是否存在多赛道 `variant` 编排 - 是否有 ongoing session - 当前推荐动作是什么 @@ -112,12 +113,27 @@ APP 当前主链是手机号验证码: - `event` - `release` - `resolvedRelease` +- `play.assignmentMode` +- `play.courseVariants[]` - `play.canLaunch` - `play.primaryAction` - `play.launchSource` - `play.ongoingSession` - `play.recentSession` +当前多赛道第一阶段约束: + +- `play.assignmentMode` 只先支持最小口径: + - `manual` + - `random` + - `server-assigned` +- `play.courseVariants[]` 只先返回准备页必需字段: + - `id` + - `name` + - `description` + - `routeCode` + - `selectable` + ## 6. Launch 流程 ### 6.1 当前原则 @@ -135,6 +151,7 @@ APP 当前主链是手机号验证码: 当前请求体支持: - `releaseId` +- `variantId` - `clientType` - `deviceKey` @@ -142,6 +159,7 @@ APP 当前主链是手机号验证码: - `launch.source` - `launch.resolvedRelease` +- `launch.variant` - `launch.config` - `launch.business.sessionId` - `launch.business.sessionToken` @@ -158,6 +176,14 @@ APP 当前主链是手机号验证码: - `launch.resolvedRelease.releaseId` - `launch.resolvedRelease.manifestUrl` - `launch.resolvedRelease.manifestChecksumSha256` +- `launch.variant.id` +- `launch.variant.assignmentMode` + +补充说明: + +- 如果活动声明了多赛道 variant,`launch` 会返回本局最终绑定的 `variant` +- 前端可以发起选择,但最终绑定以后端 `launch` 返回为准 +- 故障恢复不重新分配 variant 而不是再拿 `event` 自己去猜。 @@ -195,6 +221,11 @@ APP 当前主链是手机号验证码: - `cancelled` 和 `failed` 都不再作为 ongoing session 返回 - “放弃恢复”当前正式收口为 `finish(cancelled)` - 同一局旧 `sessionToken` 在 `finish(cancelled)` 场景允许继续使用 +- 第一阶段若活动声明了多赛道,session 会固化: + - `assignmentMode` + - `variantId` + - `variantName` + - `routeCode` ### 7.4 幂等要求 @@ -232,6 +263,7 @@ APP 当前主链是手机号验证码: - 一个 event 未来可能发布新版本 - 历史结果必须追溯到当时真实跑过的那份 release +- 如果一场活动存在多个 variant,结果与历史摘要也必须能追溯本局 `variantId` ## 9. 当前最应该坚持的流程约束 diff --git a/backend/internal/httpapi/handlers/dev_handler.go b/backend/internal/httpapi/handlers/dev_handler.go index 0a9a52d..d82666c 100644 --- a/backend/internal/httpapi/handlers/dev_handler.go +++ b/backend/internal/httpapi/handlers/dev_handler.go @@ -92,6 +92,101 @@ const devWorkbenchHTML = ` color: var(--muted); line-height: 1.6; } + .layout { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: 20px; + align-items: start; + } + .sidebar { + position: sticky; + top: 18px; + display: grid; + gap: 16px; + } + .workspace { + display: grid; + gap: 0; + min-width: 0; + } + .side-card { + background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)), var(--panel); + border: 1px solid var(--line); + border-radius: 18px; + padding: 16px; + display: grid; + gap: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); + } + .side-card h2 { + margin: 0; + font-size: 16px; + } + .side-card p { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.6; + } + .mode-list, + .side-links { + display: grid; + gap: 8px; + } + .mode-btn, + .side-link { + display: inline-flex; + align-items: center; + justify-content: flex-start; + min-height: 40px; + padding: 0 14px; + border-radius: 12px; + border: 1px solid var(--line); + background: rgba(255,255,255,0.04); + color: var(--text); + font-size: 13px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + } + .mode-btn.active { + background: rgba(79, 209, 165, 0.16); + border-color: rgba(79, 209, 165, 0.55); + color: var(--accent); + } + .guide-list { + display: grid; + gap: 8px; + margin: 0; + padding-left: 18px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; + } + .category-head { + display: grid; + gap: 6px; + margin: 28px 0 14px; + } + .category-kicker { + color: var(--accent); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + } + .category-head h2 { + margin: 0; + font-size: 24px; + line-height: 1.2; + } + .category-head p { + margin: 0; + color: var(--muted); + line-height: 1.6; + font-size: 13px; + max-width: 960px; + } .grid { display: grid; gap: 16px; @@ -244,6 +339,24 @@ const devWorkbenchHTML = ` display: grid; gap: 12px; } + .api-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 4px 0 2px; + } + .api-chip { + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255,255,255,0.04); + color: var(--muted); + font-size: 12px; + font-weight: 600; + } .api-item { display: grid; gap: 8px; @@ -255,6 +368,9 @@ const devWorkbenchHTML = ` .api-item.hidden { display: none; } + .mode-hidden { + display: none !important; + } .api-head { display: flex; flex-wrap: wrap; @@ -299,6 +415,8 @@ const devWorkbenchHTML = ` color: var(--danger); } @media (max-width: 900px) { + .layout { grid-template-columns: 1fr; } + .sidebar { position: static; } .row.two { grid-template-columns: 1fr; } .shell { padding: 20px 16px 32px; } } @@ -312,20 +430,64 @@ const devWorkbenchHTML = `

把入口、登录、首页、活动详情、launch、session、profile 串成一条完整调试链。这个页面只在非 production 环境开放,适合后续继续扩展成你想要的 API 测试面板。

+
+ + +
+ +
-
-

1. Bootstrap

+
+

准备 Demo 数据

初始化 demo tenant / channel / event / card。

+
默认入口 tenant_demo / mini-demo / evt_demo_001
+
多赛道入口 tenant_demo / mini-demo / evt_demo_variant_manual_001
-
-

2. Config Pipeline

+
+

本地配置导入与发布

从本地 event 目录导入 source config,生成 preview build,并可直接发布成当前 release。

-
-

3. Session State

+
+

当前上下文

当前调试上下文,所有按钮共享这一组状态。

Access Token -
@@ -370,8 +532,8 @@ const devWorkbenchHTML = `
-
-

4. SMS Auth

+
+

短信登录 / 绑定

-
-

7. Event

+
+

活动与启动

@@ -465,8 +629,8 @@ const devWorkbenchHTML = `
-
-

8. Session

+
+

局内状态

-
-

9. Results

+
+

结果查询

-
-

10. Profile

+
+

当前用户

@@ -540,9 +704,14 @@ const devWorkbenchHTML = `
-
-
-

11. Quick Flows

+ +
+
+

一键流程

把常用接口串成一键工作流,减少重复点击。

@@ -553,8 +722,8 @@ const devWorkbenchHTML = `
这些流程会复用当前表单里的手机号、设备、event、channel 等输入。
-
-

12. Request Export

+
+

请求导出

最后一次请求会生成一条可复制的 curl,后面做问题复现会方便很多。

@@ -567,9 +736,262 @@ const devWorkbenchHTML = `
+ +
+
+

资源对象管理

+

管理地图、赛场和资源包对象,先建对象,再建版本,后面 Event source 直接引用这些对象。

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ +
+

Event Source 组装

+

创建 Event 并把 map version、playfield version、resource pack version 组装成 source config。

+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ + + + + +
+
+
+ +
-
-

13. Scenarios

+
+

Build / Publish / Rollback

+

围绕当前 Event 查询 source/build/release 流水线,并执行 build、publish、rollback。

+
+ + +
+
+ + +
+
+ + + + + +
+
+ +
+

场景模板

保存当前表单状态为可复用场景,也支持导入导出 JSON,适合后续切换不同俱乐部、入口和 event。

-
-

14. Response Log

+
+

响应日志

最后一次请求的结果会记录在这里,便于后续做请求回放和用例保存。

ready
@@ -603,21 +1025,27 @@ const devWorkbenchHTML = `
-
-

15. Request History

+
+

请求历史

最近 12 次请求会保留在浏览器本地,刷新页面不会丢。

-
-
-

16. API 列表

+ +
+
+

API 目录

把当前已实现接口按分组放进 workbench,直接看中文说明、鉴权要求和关键参数,不用来回翻文档。

-
共 24 个接口,支持按关键词筛选。
+
共 0 个接口,支持按关键词筛选。
+
GET/healthz
@@ -709,18 +1137,18 @@ const devWorkbenchHTML = `
鉴权:无需鉴权
-
+
GET/events/{eventPublicID}/play
-
活动详情页 / 开始前准备页聚合接口,判断是否可启动、继续还是查看上次结果。
+
活动详情页 / 开始前准备页聚合接口,判断是否可启动、继续还是查看上次结果;第一阶段也会返回多赛道 assignmentMode 和 courseVariants。
鉴权:Bearer token
-
+
POST/events/{eventPublicID}/launch
-
基于当前 event 的已发布 release 创建一局 session,并返回 config URL、releaseId、sessionToken。
+
基于当前 event 的已发布 release 创建一局 session,并返回 config URL、releaseId、sessionToken;多赛道第一阶段支持可选 variantId,并返回最终绑定的 launch.variant。
鉴权:Bearer token
-
关键参数:releaseIdclientTypedeviceKey
+
关键参数:releaseIdvariantIdclientTypedeviceKey
@@ -834,15 +1262,180 @@ const devWorkbenchHTML = `
关键参数:buildId
+ +
+
GET/admin/maps
+
后台地图对象列表接口。
+
鉴权:Bearer token
+
+ +
+
POST/admin/maps
+
创建地图对象,后续再为它追加版本。
+
+
鉴权:Bearer token
+
关键参数:codenamestatus
+
+
+ +
+
GET/admin/maps/{mapPublicID}
+
查看单个地图对象和它的版本列表。
+
鉴权:Bearer token
+
+ +
+
POST/admin/maps/{mapPublicID}/versions
+
为地图对象创建一个版本,挂接 mapmeta 和 tiles 根路径。
+
+
鉴权:Bearer token
+
关键参数:versionCodemapmetaUrltilesRootUrlsetAsCurrent
+
+
+ +
+
GET/admin/playfields
+
后台赛场对象列表接口。
+
鉴权:Bearer token
+
+ +
+
POST/admin/playfields
+
创建赛场对象,适合管理 KML / GeoJSON 这类可复用场地资源。
+
+
鉴权:Bearer token
+
关键参数:codenamekindstatus
+
+
+ +
+
GET/admin/playfields/{playfieldPublicID}
+
查看单个赛场对象和它的版本列表。
+
鉴权:Bearer token
+
+ +
+
POST/admin/playfields/{playfieldPublicID}/versions
+
为赛场对象创建一个版本,挂接 KML 等源文件地址和控制点摘要。
+
+
鉴权:Bearer token
+
关键参数:versionCodesourceTypesourceUrlcontrolCountsetAsCurrent
+
+
+ +
+
GET/admin/resource-packs
+
后台资源包对象列表接口。
+
鉴权:Bearer token
+
+ +
+
POST/admin/resource-packs
+
创建资源包对象,用来管理内容页、音频和主题资源。
+
+
鉴权:Bearer token
+
关键参数:codenamestatus
+
+
+ +
+
GET/admin/resource-packs/{resourcePackPublicID}
+
查看单个资源包对象和它的版本列表。
+
鉴权:Bearer token
+
+ +
+
POST/admin/resource-packs/{resourcePackPublicID}/versions
+
为资源包对象创建版本,配置内容入口、音频根路径和主题代码。
+
+
鉴权:Bearer token
+
关键参数:versionCodecontentEntryUrlaudioRootUrlthemeProfileCodesetAsCurrent
+
+
+ +
+
GET/admin/events
+
后台 event 列表接口。
+
鉴权:Bearer token
+
+ +
+
POST/admin/events
+
创建 event 基础信息。
+
+
鉴权:Bearer token
+
关键参数:tenantCodeslugdisplayNamestatus
+
+
+ +
+
GET/admin/events/{eventPublicID}
+
查看 event 明细、最新 source 和当前 source 摘要。
+
鉴权:Bearer token
+
+ +
+
PUT/admin/events/{eventPublicID}
+
更新 event 基础信息。
+
+
鉴权:Bearer token
+
关键参数:tenantCodeslugdisplayNamestatus
+
+
+ +
+
POST/admin/events/{eventPublicID}/source
+
把 map/playfield/resource pack 版本和 gameModeCode 组装成 source config。
+
+
鉴权:Bearer token
+
关键参数:map.mapIdmap.versionIdplayfield.playfieldIdplayfield.versionIdgameModeCodeoverrides
+
+
+ +
+
GET/admin/events/{eventPublicID}/pipeline
+
查看 event 下的 source、build、release 流水线概览。
+
鉴权:Bearer token
+
+ +
+
POST/admin/sources/{sourceID}/build
+
基于 source 生成一条 build 记录和 preview manifest。
+
鉴权:Bearer token
+
+ +
+
GET/admin/builds/{buildID}
+
查看后台 build 明细。
+
鉴权:Bearer token
+
+ +
+
POST/admin/builds/{buildID}/publish
+
把后台 build 发布为正式 release,并切换为 event 当前发布版本。
+
鉴权:Bearer token
+
+ +
+
POST/admin/events/{eventPublicID}/rollback
+
将 event 当前发布版本回滚到指定 releaseId。
+
+
鉴权:Bearer token
+
关键参数:releaseId
+
+
+
+
diff --git a/backend/internal/service/entry_home_service.go b/backend/internal/service/entry_home_service.go index 2acad6b..ae72d2a 100644 --- a/backend/internal/service/entry_home_service.go +++ b/backend/internal/service/entry_home_service.go @@ -55,6 +55,8 @@ type EntrySessionSummary struct { EventName string `json:"eventName"` ReleaseID *string `json:"releaseId,omitempty"` ConfigLabel *string `json:"configLabel,omitempty"` + VariantID *string `json:"variantId,omitempty"` + VariantName *string `json:"variantName,omitempty"` RouteCode *string `json:"routeCode,omitempty"` LaunchedAt string `json:"launchedAt"` StartedAt *string `json:"startedAt,omitempty"` @@ -139,10 +141,12 @@ func (s *EntryHomeService) GetEntryHome(ctx context.Context, input EntryHomeInpu func buildEntrySessionSummary(session *postgres.Session) EntrySessionSummary { summary := EntrySessionSummary{ - ID: session.SessionPublicID, - Status: session.Status, - RouteCode: session.RouteCode, - LaunchedAt: session.LaunchedAt.Format(timeRFC3339), + ID: session.SessionPublicID, + Status: session.Status, + VariantID: session.VariantID, + VariantName: session.VariantName, + RouteCode: session.RouteCode, + LaunchedAt: session.LaunchedAt.Format(timeRFC3339), } if session.EventPublicID != nil { summary.EventID = *session.EventPublicID diff --git a/backend/internal/service/event_play_service.go b/backend/internal/service/event_play_service.go index eda1b52..3bd5229 100644 --- a/backend/internal/service/event_play_service.go +++ b/backend/internal/service/event_play_service.go @@ -35,6 +35,8 @@ type EventPlayResult struct { } `json:"release,omitempty"` ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` Play struct { + AssignmentMode *string `json:"assignmentMode,omitempty"` + CourseVariants []CourseVariantView `json:"courseVariants,omitempty"` CanLaunch bool `json:"canLaunch"` PrimaryAction string `json:"primaryAction"` Reason string `json:"reason"` @@ -77,6 +79,11 @@ func (s *EventPlayService) GetEventPlay(ctx context.Context, input EventPlayInpu result.Event.DisplayName = event.DisplayName result.Event.Summary = event.Summary 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 { result.Release = &struct { ID string `json:"id"` diff --git a/backend/internal/service/event_service.go b/backend/internal/service/event_service.go index 833bbbf..5ff477c 100644 --- a/backend/internal/service/event_service.go +++ b/backend/internal/service/event_service.go @@ -37,6 +37,7 @@ type LaunchEventInput struct { EventPublicID string UserID string ReleaseID string `json:"releaseId,omitempty"` + VariantID string `json:"variantId,omitempty"` ClientType string `json:"clientType"` DeviceKey string `json:"deviceKey"` } @@ -49,6 +50,7 @@ type LaunchEventResult struct { Launch struct { Source string `json:"source"` ResolvedRelease *ResolvedReleaseView `json:"resolvedRelease,omitempty"` + Variant *VariantBindingView `json:"variant,omitempty"` Config struct { ConfigURL string `json:"configUrl"` 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) { input.EventPublicID = strings.TrimSpace(input.EventPublicID) input.ReleaseID = strings.TrimSpace(input.ReleaseID) + input.VariantID = strings.TrimSpace(input.VariantID) input.DeviceKey = strings.TrimSpace(input.DeviceKey) if err := validateClientType(input.ClientType); err != nil { return nil, err @@ -139,6 +142,24 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput) if input.ReleaseID != "" && input.ReleaseID != *event.CurrentReleasePubID { 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) if err != nil { @@ -163,7 +184,10 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput) EventReleaseID: *event.CurrentReleaseID, DeviceKey: input.DeviceKey, ClientType: input.ClientType, - RouteCode: event.RouteCode, + AssignmentMode: assignmentMode, + VariantID: variantID, + VariantName: variantName, + RouteCode: routeCode, SessionTokenHash: security.HashText(sessionToken), SessionTokenExpiresAt: sessionTokenExpiresAt, }) @@ -180,16 +204,17 @@ func (s *EventService) LaunchEvent(ctx context.Context, input LaunchEventInput) result.Event.DisplayName = event.DisplayName result.Launch.Source = LaunchSourceEventCurrentRelease result.Launch.ResolvedRelease = buildResolvedReleaseFromEvent(event, LaunchSourceEventCurrentRelease) + result.Launch.Variant = variant result.Launch.Config.ConfigURL = *event.ManifestURL result.Launch.Config.ConfigLabel = *event.ConfigLabel result.Launch.Config.ConfigChecksumSha256 = event.ManifestChecksum 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.EventID = event.PublicID result.Launch.Business.SessionID = session.SessionPublicID result.Launch.Business.SessionToken = sessionToken result.Launch.Business.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339) - result.Launch.Business.RouteCode = event.RouteCode + result.Launch.Business.RouteCode = routeCode return result, nil } diff --git a/backend/internal/service/session_service.go b/backend/internal/service/session_service.go index c809629..a3dfee1 100644 --- a/backend/internal/service/session_service.go +++ b/backend/internal/service/session_service.go @@ -26,6 +26,9 @@ type SessionResult struct { Status string `json:"status"` ClientType string `json:"clientType"` DeviceKey string `json:"deviceKey"` + AssignmentMode *string `json:"assignmentMode,omitempty"` + VariantID *string `json:"variantId,omitempty"` + VariantName *string `json:"variantName,omitempty"` RouteCode *string `json:"routeCode,omitempty"` SessionTokenExpiresAt string `json:"sessionTokenExpiresAt"` LaunchedAt string `json:"launchedAt"` @@ -264,6 +267,9 @@ func buildSessionResult(session *postgres.Session) *SessionResult { result.Session.Status = session.Status result.Session.ClientType = session.ClientType 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.SessionTokenExpiresAt = session.SessionTokenExpiresAt.Format(time.RFC3339) result.Session.LaunchedAt = session.LaunchedAt.Format(time.RFC3339) diff --git a/backend/internal/service/variant_contract.go b/backend/internal/service/variant_contract.go new file mode 100644 index 0000000..52585b5 --- /dev/null +++ b/backend/internal/service/variant_contract.go @@ -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 + } +} diff --git a/backend/internal/store/postgres/dev_store.go b/backend/internal/store/postgres/dev_store.go index e0d853b..8518207 100644 --- a/backend/internal/store/postgres/dev_store.go +++ b/backend/internal/store/postgres/dev_store.go @@ -6,13 +6,16 @@ import ( ) type DemoBootstrapSummary struct { - TenantCode string `json:"tenantCode"` - ChannelCode string `json:"channelCode"` - EventID string `json:"eventId"` - ReleaseID string `json:"releaseId"` - SourceID string `json:"sourceId"` - BuildID string `json:"buildId"` - CardID string `json:"cardId"` + TenantCode string `json:"tenantCode"` + ChannelCode string `json:"channelCode"` + EventID string `json:"eventId"` + ReleaseID string `json:"releaseId"` + SourceID string `json:"sourceId"` + BuildID string `json:"buildId"` + 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) { @@ -88,7 +91,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro $1, 1, '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', 'route-demo-001', 'published' @@ -224,7 +227,7 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro EventReleaseID: releaseRow.ID, AssetType: "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, Meta: map[string]any{"source": "release-manifest"}, }, @@ -308,17 +311,149 @@ func (s *Store) EnsureDemoData(ctx context.Context) (*DemoBootstrapSummary, erro 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 { return nil, err } return &DemoBootstrapSummary{ - TenantCode: "tenant_demo", - ChannelCode: "mini-demo", - EventID: "evt_demo_001", - ReleaseID: releaseRow.PublicID, - SourceID: source.ID, - BuildID: build.ID, - CardID: cardPublicID, + TenantCode: "tenant_demo", + ChannelCode: "mini-demo", + EventID: "evt_demo_001", + ReleaseID: releaseRow.PublicID, + SourceID: source.ID, + BuildID: build.ID, + CardID: cardPublicID, + VariantManualEventID: "evt_demo_variant_manual_001", + VariantManualRelease: manualReleaseRow.PublicID, + VariantManualCardID: manualCardPublicID, }, nil } diff --git a/backend/internal/store/postgres/event_store.go b/backend/internal/store/postgres/event_store.go index e1aac57..16a46a3 100644 --- a/backend/internal/store/postgres/event_store.go +++ b/backend/internal/store/postgres/event_store.go @@ -22,6 +22,7 @@ type Event struct { ManifestURL *string ManifestChecksum *string RouteCode *string + ReleasePayloadJSON *string } type EventRelease struct { @@ -45,6 +46,9 @@ type CreateGameSessionParams struct { EventReleaseID string DeviceKey string ClientType string + AssignmentMode *string + VariantID *string + VariantName *string RouteCode *string SessionTokenHash string SessionTokenExpiresAt time.Time @@ -58,6 +62,9 @@ type GameSession struct { EventReleaseID string DeviceKey string ClientType string + AssignmentMode *string + VariantID *string + VariantName *string RouteCode *string Status string SessionTokenExpiresAt time.Time @@ -77,7 +84,8 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (* er.config_label, er.manifest_url, er.manifest_checksum_sha256, - er.route_code + er.route_code, + er.payload_jsonb::text FROM events e LEFT JOIN event_releases er ON er.id = e.current_release_id WHERE e.event_public_id = $1 @@ -98,6 +106,7 @@ func (s *Store) GetEventByPublicID(ctx context.Context, eventPublicID string) (* &event.ManifestURL, &event.ManifestChecksum, &event.RouteCode, + &event.ReleasePayloadJSON, ) if errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -122,7 +131,8 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error er.config_label, er.manifest_url, er.manifest_checksum_sha256, - er.route_code + er.route_code, + er.payload_jsonb::text FROM events e LEFT JOIN event_releases er ON er.id = e.current_release_id WHERE e.id = $1 @@ -143,6 +153,7 @@ func (s *Store) GetEventByID(ctx context.Context, eventID string) (*Event, error &event.ManifestURL, &event.ManifestChecksum, &event.RouteCode, + &event.ReleasePayloadJSON, ) if errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -235,13 +246,16 @@ func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameS event_release_id, device_key, client_type, + assignment_mode, + variant_id, + variant_name, route_code, session_token_hash, session_token_expires_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id, session_public_id, user_id, event_id, event_release_id, device_key, client_type, 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) + 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, 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.AssignmentMode, params.VariantID, params.VariantName, params.RouteCode, params.SessionTokenHash, params.SessionTokenExpiresAt) var session GameSession err := row.Scan( @@ -252,6 +266,9 @@ func (s *Store) CreateGameSession(ctx context.Context, tx Tx, params CreateGameS &session.EventReleaseID, &session.DeviceKey, &session.ClientType, + &session.AssignmentMode, + &session.VariantID, + &session.VariantName, &session.RouteCode, &session.Status, &session.SessionTokenExpiresAt, diff --git a/backend/internal/store/postgres/result_store.go b/backend/internal/store/postgres/result_store.go index 1655242..a168a50 100644 --- a/backend/internal/store/postgres/result_store.go +++ b/backend/internal/store/postgres/result_store.go @@ -101,6 +101,9 @@ func (s *Store) GetSessionResultByPublicID(ctx context.Context, sessionPublicID er.manifest_checksum_sha256, gs.device_key, gs.client_type, + gs.assignment_mode, + gs.variant_id, + gs.variant_name, gs.route_code, gs.status, gs.session_token_hash, @@ -149,6 +152,9 @@ func (s *Store) ListSessionResultsByUserID(ctx context.Context, userID string, l er.manifest_checksum_sha256, gs.device_key, gs.client_type, + gs.assignment_mode, + gs.variant_id, + gs.variant_name, gs.route_code, gs.status, gs.session_token_hash, @@ -244,6 +250,9 @@ func scanSessionResultRecord(row pgx.Row) (*SessionResultRecord, error) { &record.ManifestChecksum, &record.DeviceKey, &record.ClientType, + &record.AssignmentMode, + &record.VariantID, + &record.VariantName, &record.RouteCode, &record.Status, &record.SessionTokenHash, @@ -317,6 +326,9 @@ func scanSessionResultRecordFromRows(rows pgx.Rows) (*SessionResultRecord, error &record.ManifestChecksum, &record.DeviceKey, &record.ClientType, + &record.AssignmentMode, + &record.VariantID, + &record.VariantName, &record.RouteCode, &record.Status, &record.SessionTokenHash, diff --git a/backend/internal/store/postgres/session_store.go b/backend/internal/store/postgres/session_store.go index f54f6fa..d9538e7 100644 --- a/backend/internal/store/postgres/session_store.go +++ b/backend/internal/store/postgres/session_store.go @@ -21,6 +21,9 @@ type Session struct { ManifestChecksum *string DeviceKey string ClientType string + AssignmentMode *string + VariantID *string + VariantName *string RouteCode *string Status string SessionTokenHash string @@ -51,6 +54,9 @@ func (s *Store) GetSessionByPublicID(ctx context.Context, sessionPublicID string er.manifest_checksum_sha256, gs.device_key, gs.client_type, + gs.assignment_mode, + gs.variant_id, + gs.variant_name, gs.route_code, gs.status, gs.session_token_hash, @@ -83,6 +89,9 @@ func (s *Store) GetSessionByPublicIDForUpdate(ctx context.Context, tx Tx, sessio er.manifest_checksum_sha256, gs.device_key, gs.client_type, + gs.assignment_mode, + gs.variant_id, + gs.variant_name, gs.route_code, gs.status, gs.session_token_hash, @@ -119,6 +128,9 @@ func (s *Store) ListSessionsByUserID(ctx context.Context, userID string, limit i er.manifest_checksum_sha256, gs.device_key, gs.client_type, + gs.assignment_mode, + gs.variant_id, + gs.variant_name, gs.route_code, gs.status, gs.session_token_hash, @@ -172,6 +184,9 @@ func (s *Store) ListSessionsByUserAndEvent(ctx context.Context, userID, eventID er.manifest_checksum_sha256, gs.device_key, gs.client_type, + gs.assignment_mode, + gs.variant_id, + gs.variant_name, gs.route_code, gs.status, gs.session_token_hash, @@ -249,6 +264,9 @@ func scanSession(row pgx.Row) (*Session, error) { &session.ManifestChecksum, &session.DeviceKey, &session.ClientType, + &session.AssignmentMode, + &session.VariantID, + &session.VariantName, &session.RouteCode, &session.Status, &session.SessionTokenHash, @@ -282,6 +300,9 @@ func scanSessionFromRows(rows pgx.Rows) (*Session, error) { &session.ManifestChecksum, &session.DeviceKey, &session.ClientType, + &session.AssignmentMode, + &session.VariantID, + &session.VariantName, &session.RouteCode, &session.Status, &session.SessionTokenHash, diff --git a/backend/migrations/0007_variant_minimal.sql b/backend/migrations/0007_variant_minimal.sql new file mode 100644 index 0000000..2018ac0 --- /dev/null +++ b/backend/migrations/0007_variant_minimal.sql @@ -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; diff --git a/backend/scripts/start-dev.ps1 b/backend/scripts/start-dev.ps1 index a2643ff..05888d5 100644 --- a/backend/scripts/start-dev.ps1 +++ b/backend/scripts/start-dev.ps1 @@ -46,4 +46,15 @@ if ($workbenchAddr.StartsWith(":")) { Write-Host ("http://" + $workbenchAddr + "/dev/workbench") 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 diff --git a/backend/start-backend.ps1 b/backend/start-backend.ps1 index 4a2fd36..1ad2f4a 100644 --- a/backend/start-backend.ps1 +++ b/backend/start-backend.ps1 @@ -10,4 +10,4 @@ if (-not (Test-Path $scriptPath)) { Set-Location $backendDir -powershell -ExecutionPolicy Bypass -File $scriptPath +& $scriptPath diff --git a/doc/gameplay/APP全局产品架构草案.md b/doc/gameplay/APP全局产品架构草案.md new file mode 100644 index 0000000..f564d1e --- /dev/null +++ b/doc/gameplay/APP全局产品架构草案.md @@ -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 是游戏过程,历史与成就是用户资产。** + +后续页面、后端模型、联调策略和配置体系,都应围绕这四个对象继续收口。 diff --git a/doc/gameplay/多赛道Variant五层设计草案.md b/doc/gameplay/多赛道Variant五层设计草案.md new file mode 100644 index 0000000..97bca91 --- /dev/null +++ b/doc/gameplay/多赛道Variant五层设计草案.md @@ -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 级事实,再去实现准备页手选、随机分配和后台指定等具体交互。** diff --git a/doc/gameplay/多赛道Variant前后端最小契约.md b/doc/gameplay/多赛道Variant前后端最小契约.md new file mode 100644 index 0000000..fe41d71 --- /dev/null +++ b/doc/gameplay/多赛道Variant前后端最小契约.md @@ -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` 成为贯穿一局的稳定事实。** diff --git a/doc/gameplay/游戏规则架构.md b/doc/gameplay/游戏规则架构.md index 9f28dbf..437c0dd 100644 --- a/doc/gameplay/游戏规则架构.md +++ b/doc/gameplay/游戏规则架构.md @@ -1,6 +1,6 @@ # 游戏规则架构 > 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 最后更新:2026-04-02 18:33:00 本文档用于说明当前项目中“游戏规则”在文档、配置文件、样例 JSON、解析代码和运行时规则引擎之间的实际组织方式。 @@ -68,6 +68,8 @@ - [程序默认规则基线](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) - `doc/games/<游戏名称>/规则说明文档.md` diff --git a/doc/文档索引.md b/doc/文档索引.md index 5551454..554067b 100644 --- a/doc/文档索引.md +++ b/doc/文档索引.md @@ -1,6 +1,6 @@ # 文档索引 > 文档版本: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) +- [多赛道 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) +- [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) diff --git a/f2b.md b/f2b.md index ce96a9f..6f15645 100644 --- a/f2b.md +++ b/f2b.md @@ -1,6 +1,6 @@ # F2B 协作清单 -> 文档版本:v1.0 -> 最后更新:2026-04-02 08:28:05 +> 文档版本:v1.3 +> 最后更新:2026-04-02 15:19:37 说明: @@ -14,34 +14,33 @@ ## 待确认 -### F2B-004 +### F2B-007 -- 时间:2026-04-01 +- 时间:2026-04-02 - 提出方:前端 - 当前事实: - - 前端当前依赖以下 launch 字段: - - `resolvedRelease.manifestUrl` - - `resolvedRelease.releaseId` - - `business.sessionId` - - `business.sessionToken` - - `business.sessionTokenExpiresAt` + - 前端已完成多赛道第一阶段接入: + - 活动页、准备页可展示 `assignmentMode / courseVariants` + - 当 `assignmentMode=manual` 时,准备页会让用户选择赛道 + - 前端会把选中的 `variantId` 带入 `POST /events/{eventPublicID}/launch` - 需要对方确认什么: - - 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` - - `/events/{eventPublicID}/play` - `/sessions/{sessionPublicID}/result` + - `/me/results` - 需要对方确认什么: - - `cancelled` 后不再作为 ongoing 返回 - - `failed` 后不再作为 ongoing 返回 - - `finished` 后结果摘要与首页摘要口径一致 + - 请 backend 确认以上摘要链是否已完成 variant 回写 + - 如还未全部完成,请给出可联调时间点或先可用的接口范围 - 状态:待确认 --- @@ -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 提供联调数据 + diff --git a/miniprogram/app.json b/miniprogram/app.json index 98c33d3..edda61c 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -4,7 +4,9 @@ "pages/login/login", "pages/home/home", "pages/event/event", + "pages/event-prepare/event-prepare", "pages/result/result", + "pages/results/results", "pages/map/map", "pages/experience-webview/experience-webview", "pages/webview-test/webview-test", diff --git a/miniprogram/app.ts b/miniprogram/app.ts index 5e6974f..49ab83a 100644 --- a/miniprogram/app.ts +++ b/miniprogram/app.ts @@ -6,6 +6,8 @@ App({ telemetryPlayerProfile: null, backendBaseUrl: null, backendAuthTokens: null, + pendingResultSnapshot: null, + pendingHeartRateAutoConnect: null, }, onLaunch() { this.globalData.backendBaseUrl = loadBackendBaseUrl() diff --git a/miniprogram/pages/event-prepare/event-prepare.ts b/miniprogram/pages/event-prepare/event-prepare.ts new file mode 100644 index 0000000..d591136 --- /dev/null +++ b/miniprogram/pages/event-prepare/event-prepare.ts @@ -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() + 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 + : {} + 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() + 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}`, + }) + } + }, +}) diff --git a/miniprogram/pages/event-prepare/event-prepare.wxml b/miniprogram/pages/event-prepare/event-prepare.wxml new file mode 100644 index 0000000..167e77d --- /dev/null +++ b/miniprogram/pages/event-prepare/event-prepare.wxml @@ -0,0 +1,105 @@ + + + + Prepare + {{titleText}} + {{summaryText}} + + + + 活动与发布 + Release:{{releaseText}} + 主动作:{{actionText}} + 状态:{{statusText}} + 赛道模式:{{variantModeText}} + 赛道摘要:{{variantSummaryText}} + 当前选择:{{selectedVariantText}} + + + + 赛道选择 + 当前活动要求手动指定赛道。这里的选择会随 launch 一起带给后端,最终绑定以后端返回为准。 + + + + + {{item.name}} + 已选中 + + {{item.routeCodeText}} + {{item.descriptionText}} + + + + + + + 设备准备 + 这一页现在负责局前设备准备。定位权限先在这里确认,心率带支持先连后进图,地图内仍保留局中快速重连入口。 + + 定位状态 + {{locationStatusText}} + + 已完成前台定位授权;如果后续需要后台持续定位,请在系统设置中补齐后台权限。 + + + + + + 心率带 + {{heartRateStatusText}} + + + 当前设备 + {{heartRateDeviceText}} + + + 扫描状态 + {{heartRateScanText}} + + + 模拟源 + {{mockSourceStatusText}} + + + + + + + + + + + 开始比赛 + 这一页先承担局前准备壳子,后面会继续接定位权限、心率带局前连接和设备检查。 + + + + + + + + + + + + 选择心率带设备 + + + 扫描状态:{{heartRateScanText}} + 当前还没有发现设备,可先点“重新扫描”。 + + + + + {{item.name}} + 首选 + 已连接 + + {{item.rssiText}} + + + + + + diff --git a/miniprogram/pages/event-prepare/event-prepare.wxss b/miniprogram/pages/event-prepare/event-prepare.wxss new file mode 100644 index 0000000..bad2149 --- /dev/null +++ b/miniprogram/pages/event-prepare/event-prepare.wxss @@ -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; +} diff --git a/miniprogram/pages/event/event.ts b/miniprogram/pages/event/event.ts index 6d42f02..31ec24b 100644 --- a/miniprogram/pages/event/event.ts +++ b/miniprogram/pages/event/event.ts @@ -1,7 +1,5 @@ import { loadBackendAuthTokens, loadBackendBaseUrl } from '../../utils/backendAuth' -import { getEventPlay, launchEvent, type BackendEventPlayResult } from '../../utils/backendApi' -import { adaptBackendLaunchResultToEnvelope } from '../../utils/backendLaunchAdapter' -import { prepareMapPageUrlForLaunch } from '../../utils/gameLaunch' +import { getEventPlay, type BackendEventPlayResult } from '../../utils/backendApi' type EventPageData = { eventId: string @@ -11,6 +9,33 @@ type EventPageData = { releaseText: string actionText: 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 { @@ -30,6 +55,8 @@ Page({ releaseText: '--', actionText: '--', statusText: '待加载', + variantModeText: '--', + variantSummaryText: '--', } as EventPageData, onLoad(query: { eventId?: string }) { @@ -83,6 +110,8 @@ Page({ : '当前无可用 release', actionText: `${result.play.primaryAction} / ${result.play.reason}`, statusText: result.play.canLaunch ? '可启动' : '当前不可启动', + variantModeText: formatAssignmentMode(result.play.assignmentMode), + variantSummaryText: formatVariantSummary(result), }) }, @@ -91,33 +120,8 @@ Page({ }, async handleLaunch() { - const accessToken = getAccessToken() - if (!accessToken) { - wx.redirectTo({ url: '/pages/login/login' }) - return - } - - this.setData({ - statusText: '正在创建 session 并进入地图', + wx.navigateTo({ + url: `/pages/event-prepare/event-prepare?eventId=${encodeURIComponent(this.data.eventId)}`, }) - - try { - const result = await launchEvent({ - baseUrl: loadBackendBaseUrl(), - eventId: this.data.eventId, - accessToken, - clientType: 'wechat', - deviceKey: 'mini-dev-device-001', - }) - const envelope = adaptBackendLaunchResultToEnvelope(result) - wx.navigateTo({ - url: prepareMapPageUrlForLaunch(envelope), - }) - } catch (error) { - const message = error && (error as { message?: string }).message ? (error as { message: string }).message : '未知错误' - this.setData({ - statusText: `launch 失败:${message}`, - }) - } }, }) diff --git a/miniprogram/pages/event/event.wxml b/miniprogram/pages/event/event.wxml index 05eecd1..a08f3d6 100644 --- a/miniprogram/pages/event/event.wxml +++ b/miniprogram/pages/event/event.wxml @@ -11,9 +11,11 @@ Release:{{releaseText}} 主动作:{{actionText}} 状态:{{statusText}} + 赛道模式:{{variantModeText}} + 赛道摘要:{{variantSummaryText}} - + diff --git a/miniprogram/pages/home/home.ts b/miniprogram/pages/home/home.ts index f2b1726..15a0bce 100644 --- a/miniprogram/pages/home/home.ts +++ b/miniprogram/pages/home/home.ts @@ -15,6 +15,17 @@ type HomePageData = { 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 { const app = getApp() const tokens = app.globalData && app.globalData.backendAuthTokens @@ -79,12 +90,8 @@ Page({ userNameText: result.user.nickname || result.user.publicId || result.user.id, tenantText: `${result.tenant.name} (${result.tenant.code})`, channelText: `${result.channel.displayName} / ${result.channel.code}`, - ongoingSessionText: result.ongoingSession - ? `${result.ongoingSession.eventName || result.ongoingSession.eventDisplayName || result.ongoingSession.eventId || result.ongoingSession.id || result.ongoingSession.sessionId} / ${result.ongoingSession.status || result.ongoingSession.sessionStatus}` - : '无', - recentSessionText: result.recentSession - ? `${result.recentSession.eventName || result.recentSession.eventDisplayName || result.recentSession.eventId || result.recentSession.id || result.recentSession.sessionId} / ${result.recentSession.status || result.recentSession.sessionStatus}` - : '无', + ongoingSessionText: formatSessionSummary(result.ongoingSession), + recentSessionText: formatSessionSummary(result.recentSession), cards: result.cards || [], }) }, @@ -110,7 +117,7 @@ Page({ handleOpenRecentResult() { wx.navigateTo({ - url: '/pages/result/result', + url: '/pages/results/results', }) }, diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index de5fef4..457e3e1 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -71,6 +71,7 @@ type MapPageData = MapEngineViewState & { showGameInfoPanel: boolean showResultScene: boolean showSystemSettingsPanel: boolean + showHeartRateDevicePicker: boolean showCenterScaleRuler: boolean showPunchHintBanner: boolean punchHintFxClass: string @@ -92,6 +93,7 @@ type MapPageData = MapEngineViewState & { resultSceneHeroLabel: string resultSceneHeroValue: string resultSceneRows: MapEngineGameInfoRow[] + resultSceneCountdownText: string panelTimerText: string panelTimerMode: 'elapsed' | 'countdown' panelMileageText: string @@ -157,6 +159,7 @@ const PUNCH_HINT_AUTO_HIDE_MS = 30000 const PUNCH_HINT_FX_DURATION_MS = 420 const PUNCH_HINT_HAPTIC_GAP_MS = 2400 const SESSION_RECOVERY_PERSIST_INTERVAL_MS = 5000 +const RESULT_EXIT_REDIRECT_DELAY_MS = 3000 let currentGameLaunchEnvelope: GameLaunchEnvelope = getDemoGameLaunchEnvelope() let mapEngine: MapEngine | null = null let stageCanvasAttached = false @@ -172,6 +175,8 @@ let panelMileageFxTimer = 0 let panelSpeedFxTimer = 0 let panelHeartRateFxTimer = 0 let sessionRecoveryPersistTimer = 0 +let resultExitRedirectTimer = 0 +let resultExitCountdownTimer = 0 let lastPunchHintHapticAt = 0 let currentSystemSettingsConfig: SystemSettingsConfig | undefined let currentRemoteMapConfig: RemoteMapConfig | undefined @@ -179,6 +184,8 @@ let systemSettingsLockLifetimeActive = false let syncedBackendSessionStartId = '' let syncedBackendSessionFinishId = '' 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_AUTO_CONNECT_STORAGE_KEY = 'cmr.debug.autoConnectMockSources.v1' 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 { if (!options) { return false @@ -776,11 +811,12 @@ function buildEmptyResultSceneSnapshot(): MapEngineResultSnapshot { Page({ data: { - showDebugPanel: false, - showGameInfoPanel: false, - showResultScene: false, - showSystemSettingsPanel: false, - showCenterScaleRuler: false, + showDebugPanel: false, + showGameInfoPanel: false, + showResultScene: false, + showSystemSettingsPanel: false, + showHeartRateDevicePicker: false, + showCenterScaleRuler: false, statusBarHeight: 0, topInsetHeight: 12, hudPanelIndex: 0, @@ -798,6 +834,7 @@ Page({ resultSceneHeroLabel: '本局用时', resultSceneHeroValue: '--', resultSceneRows: buildEmptyResultSceneSnapshot().rows, + resultSceneCountdownText: '', panelTimerText: '00:00:00', panelTimerMode: 'elapsed', panelMileageText: '0m', @@ -927,8 +964,11 @@ Page({ onLoad(options: MapPageLaunchOptions) { clearSessionRecoveryPersistTimer() + clearResultExitRedirectTimer() + clearResultExitCountdownTimer() syncedBackendSessionStartId = '' syncedBackendSessionFinishId = '' + redirectedToResultPage = false shouldAutoRestoreRecoverySnapshot = options && options.recoverSession === '1' currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options) if (!hasExplicitLaunchOptions(options)) { @@ -959,6 +999,7 @@ Page({ const includeRulerFields = this.data.showCenterScaleRuler let shouldSyncRuntimeSystemSettings = false let nextLockLifetimeActive = isSystemSettingsLockLifetimeActive() + let heartRateSwitchToastText = '' const nextData: Partial = filterDebugOnlyPatch({ ...nextPatch, }, includeDebugFields, includeRulerFields) @@ -1054,6 +1095,8 @@ Page({ : this.data.animationLevel let shouldSyncBackendSessionStart = false let backendSessionFinishStatus: 'finished' | 'failed' | null = null + let shouldOpenResultExitPrompt = false + let resultPageSnapshot: MapEngineResultSnapshot | null = null if (nextAnimationLevel === 'lite') { clearHudFxTimer('timer') @@ -1112,13 +1155,24 @@ Page({ shouldSyncRuntimeSystemSettings = true clearSessionRecoverySnapshot() clearSessionRecoveryPersistTimer() - this.syncResultSceneSnapshot() + clearResultExitRedirectTimer() + clearResultExitCountdownTimer() + resultPageSnapshot = mapEngine ? mapEngine.getResultSceneSnapshot() : null nextData.showResultScene = true nextData.showDebugPanel = false nextData.showGameInfoPanel = false nextData.showSystemSettingsPanel = false clearGameInfoPanelSyncTimer() 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 ( nextPatch.gameSessionStatus !== this.data.gameSessionStatus && nextPatch.gameSessionStatus === 'idle' @@ -1128,6 +1182,8 @@ Page({ shouldSyncRuntimeSystemSettings = true clearSessionRecoverySnapshot() clearSessionRecoveryPersistTimer() + clearResultExitRedirectTimer() + clearResultExitCountdownTimer() } else if ( nextPatch.gameSessionStatus !== this.data.gameSessionStatus && 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) { this.setData({ ...nextData, @@ -1152,9 +1221,20 @@ Page({ if (backendSessionFinishStatus) { this.syncBackendSessionFinish(backendSessionFinishStatus) } - if (shouldSyncRuntimeSystemSettings) { - this.applyRuntimeSystemSettings(nextLockLifetimeActive) - } + if (shouldOpenResultExitPrompt && resultPageSnapshot) { + this.stashPendingResultSnapshot(resultPageSnapshot) + this.presentResultExitPrompt() + } + if (heartRateSwitchToastText) { + wx.showToast({ + title: `${heartRateSwitchToastText},并设为首选设备`, + icon: 'none', + duration: 1800, + }) + } + if (shouldSyncRuntimeSystemSettings) { + this.applyRuntimeSystemSettings(nextLockLifetimeActive) + } if (this.data.showGameInfoPanel) { this.scheduleGameInfoPanelSnapshotSync() } @@ -1169,6 +1249,10 @@ Page({ if (backendSessionFinishStatus) { this.syncBackendSessionFinish(backendSessionFinishStatus) } + if (shouldOpenResultExitPrompt && resultPageSnapshot) { + this.stashPendingResultSnapshot(resultPageSnapshot) + this.presentResultExitPrompt() + } if (shouldSyncRuntimeSystemSettings) { this.applyRuntimeSystemSettings(nextLockLifetimeActive) } @@ -1209,6 +1293,7 @@ Page({ ...buildResolvedSystemSettingsPatch(systemSettingsState), showDebugPanel: false, showGameInfoPanel: false, + showResultScene: false, showSystemSettingsPanel: false, statusBarHeight, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), @@ -1218,6 +1303,12 @@ Page({ gameInfoSubtitle: '未开始', gameInfoLocalRows: [], gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows, + resultSceneTitle: '本局结果', + resultSceneSubtitle: '未开始', + resultSceneHeroLabel: '本局用时', + resultSceneHeroValue: '--', + resultSceneRows: buildEmptyResultSceneSnapshot().rows, + resultSceneCountdownText: '', panelTimerText: '00:00:00', panelTimerMode: 'elapsed', panelTimerFxClass: '', @@ -1349,6 +1440,18 @@ Page({ stageCanvasAttached = false this.measureStageAndCanvas() this.loadGameLaunchEnvelope(currentGameLaunchEnvelope) + const app = getApp() + 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() { @@ -1360,6 +1463,8 @@ Page({ onHide() { this.persistSessionRecoverySnapshot() + clearResultExitRedirectTimer() + clearResultExitCountdownTimer() if (mapEngine) { mapEngine.handleAppHide() } @@ -1368,6 +1473,8 @@ Page({ onUnload() { this.persistSessionRecoverySnapshot() clearSessionRecoveryPersistTimer() + clearResultExitRedirectTimer() + clearResultExitCountdownTimer() syncedBackendSessionStartId = '' syncedBackendSessionFinishId = '' clearGameInfoPanelSyncTimer() @@ -1388,6 +1495,7 @@ Page({ systemSettingsLockLifetimeActive = false currentGameLaunchEnvelope = getDemoGameLaunchEnvelope() shouldAutoRestoreRecoverySnapshot = false + redirectedToResultPage = false stageCanvasAttached = false }, @@ -1528,6 +1636,57 @@ Page({ }) }, + stashPendingResultSnapshot(snapshot: MapEngineResultSnapshot) { + const app = getApp() + 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) { systemSettingsLockLifetimeActive = true this.applyRuntimeSystemSettings(true) @@ -2052,20 +2211,53 @@ Page({ }, handleConnectHeartRate() { - if (mapEngine) { - mapEngine.handleConnectHeartRate() - } - }, - - handleDisconnectHeartRate() { - if (mapEngine) { - mapEngine.handleDisconnectHeartRate() + if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') { + return } + if (mapEngine) { + 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() { + if (this.data.lockHeartRateDevice || this.data.heartRateSourceMode !== 'real') { + return + } + if (mapEngine) { + mapEngine.handleDisconnectHeartRate() + } }, handleConnectHeartRateDevice(event: WechatMiniprogram.BaseEvent<{ deviceId?: string }>) { 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: '取消', success: (result) => { if (result.confirm && mapEngine) { + clearResultExitRedirectTimer() + clearResultExitCountdownTimer() this.syncBackendSessionFinish('cancelled') + clearSessionRecoverySnapshot() + clearSessionRecoveryPersistTimer() systemSettingsLockLifetimeActive = false mapEngine.handleForceExitGame() + wx.showToast({ + title: '已退出当前对局', + icon: 'none', + duration: 1000, + }) + setTimeout(() => { + navigateAwayFromMapAfterCancel() + }, 180) } }, }) @@ -2312,24 +2516,11 @@ Page({ handleResultSceneTap() {}, handleCloseResultScene() { - this.setData({ - showResultScene: false, - }) + this.redirectToResultPage() }, handleRestartFromResult() { - if (!mapEngine) { - return - } - this.setData({ - showResultScene: false, - }, () => { - if (mapEngine) { - systemSettingsLockLifetimeActive = true - this.applyRuntimeSystemSettings(true) - mapEngine.handleStartGame() - } - }) + this.redirectToResultPage() }, handleOpenSystemSettingsPanel() { diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 4d96bce..98916f2 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -324,7 +324,7 @@ - RESULT + FINISH {{resultSceneTitle}} {{resultSceneSubtitle}} @@ -340,9 +340,10 @@ + {{resultSceneCountdownText}} + - 返回地图 - 再来一局 + 查看成绩 @@ -726,13 +727,31 @@ 16. 心率设备 - 清除已记住的首选心率带设备,下次重新选择 + 局内正式入口,可快速更换、重连或断开当前心率带 {{lockHeartRateDevice ? '配置锁定' : '允许调整'}} + + 当前状态 + {{heartRateStatusText}}{{heartRateSourceMode !== 'real' ? ' · 当前为模拟模式' : ''}} + + + 当前设备 + {{heartRateDeviceText}} + + + 扫描状态 + {{heartRateScanText}} + + 当前为模拟心率模式,如需连接真实心率带,请先在调试面板切回“真实心率”。 + + 更换心率带 + {{heartRateConnected ? '重新扫描' : '连接心率带'}} + 断开心率带 + 清除首选设备 @@ -897,25 +916,10 @@ HR Scan {{heartRateScanText}} - - - - - {{item.name}} - 首选 - - {{item.rssiText}} - - {{item.connected ? '已连接' : '连接'}} - - - {{heartRateConnected ? '心率带已连接' : '连接心率带'}} - 断开心率带 - - - 清除首选 + {{heartRateConnected ? '重新扫描' : '连接心率带'}} + 正式用户入口已放到系统设置;这里仅保留心率源切换与开发调试能力。 心率模拟状态 {{mockHeartRateBridgeStatusText}} @@ -1169,9 +1173,32 @@ {{networkFetchCount}} - + + + + + + + + 选择心率带设备 + + + 扫描状态:{{heartRateScanText}} + 当前还没有发现设备,可先点“重新扫描”。 + + + + + {{item.name}} + 首选 + 已连接 + + {{item.rssiText}} + + + + - diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index cf7c561..cc561fa 100644 --- a/miniprogram/pages/map/map.wxss +++ b/miniprogram/pages/map/map.wxss @@ -1458,6 +1458,14 @@ 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 { margin-top: 28rpx; display: flex; @@ -1781,6 +1789,143 @@ 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 { display: flex; gap: 14rpx; diff --git a/miniprogram/pages/result/result.ts b/miniprogram/pages/result/result.ts index 79e491a..cff5978 100644 --- a/miniprogram/pages/result/result.ts +++ b/miniprogram/pages/result/result.ts @@ -1,5 +1,6 @@ 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 = { sessionId: string @@ -7,7 +8,6 @@ type ResultPageData = { sessionTitleText: string sessionSubtitleText: string rows: Array<{ label: string; value: string }> - recentResults: BackendSessionResultView[] } function getAccessToken(): string | null { @@ -25,6 +25,22 @@ function formatValue(value: unknown): string { 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({ data: { sessionId: '', @@ -32,17 +48,49 @@ Page({ sessionTitleText: '结果页', sessionSubtitleText: '未加载', rows: [], - recentResults: [], } as ResultPageData, onLoad(query: { sessionId?: string }) { const sessionId = query && query.sessionId ? decodeURIComponent(query.sessionId) : '' this.setData({ sessionId }) + this.applyPendingResultSnapshot() if (sessionId) { this.loadSingleResult(sessionId) return } - this.loadRecentResults() + this.setData({ + statusText: '未提供单局会话,已跳转历史结果', + }) + wx.redirectTo({ + url: '/pages/results/results', + }) + }, + + applyPendingResultSnapshot() { + const app = getApp() + 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) { @@ -65,8 +113,9 @@ Page({ this.setData({ statusText: '单局结果加载完成', sessionTitleText: result.session.eventName || result.session.eventDisplayName || result.session.eventId || result.session.id || result.session.sessionId, - sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status}`, + sessionSubtitleText: `${result.session.status || result.session.sessionStatus} / ${result.result.status} / ${formatRouteSummary(result.session)}`, rows: [ + { label: '赛道版本', value: formatRouteSummary(result.session) }, { label: '最终得分', value: formatValue(result.result.finalScore) }, { label: '最终用时(秒)', value: formatValue(result.result.finalDurationSec) }, { 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() { - this.setData({ - sessionId: '', - rows: [], + wx.redirectTo({ + url: '/pages/results/results', }) - this.loadRecentResults() }, }) diff --git a/miniprogram/pages/result/result.wxml b/miniprogram/pages/result/result.wxml index e355a2f..f5e4805 100644 --- a/miniprogram/pages/result/result.wxml +++ b/miniprogram/pages/result/result.wxml @@ -9,7 +9,7 @@ 当前状态 {{statusText}} - + @@ -19,15 +19,5 @@ {{item.value}} - - - 最近结果 - 当前没有结果记录 - - {{item.session.eventName || item.session.id}} - {{item.result.status}} / {{item.session.status}} - 得分 {{item.result.finalScore || '--'}} / 用时 {{item.result.finalDurationSec || '--'}}s - - diff --git a/miniprogram/pages/results/results.ts b/miniprogram/pages/results/results.ts new file mode 100644 index 0000000..1d3a834 --- /dev/null +++ b/miniprogram/pages/results/results.ts @@ -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() + 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)}`, + }) + }, +}) diff --git a/miniprogram/pages/results/results.wxml b/miniprogram/pages/results/results.wxml new file mode 100644 index 0000000..8861088 --- /dev/null +++ b/miniprogram/pages/results/results.wxml @@ -0,0 +1,25 @@ + + + + Results + 历史结果 + 查看最近联调与正式对局结果 + + + + 当前状态 + {{statusText}} + + + + 结果列表 + 当前没有结果记录 + + {{item.titleText}} + {{item.statusText}} + {{item.scoreText}} + {{item.routeText}} + + + + diff --git a/miniprogram/pages/results/results.wxss b/miniprogram/pages/results/results.wxss new file mode 100644 index 0000000..4c5f6b0 --- /dev/null +++ b/miniprogram/pages/results/results.wxss @@ -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; +} diff --git a/miniprogram/utils/backendApi.ts b/miniprogram/utils/backendApi.ts index e1b7f0b..01f6deb 100644 --- a/miniprogram/utils/backendApi.ts +++ b/miniprogram/utils/backendApi.ts @@ -30,6 +30,21 @@ export interface BackendResolvedRelease { 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 { id: string status: string @@ -38,6 +53,8 @@ export interface BackendEntrySessionSummary { releaseId?: string | null configLabel?: string | null routeCode?: string | null + variantId?: string | null + variantName?: string | null launchedAt?: string | null startedAt?: string | null endedAt?: string | null @@ -111,6 +128,8 @@ export interface BackendEventPlayResult { primaryAction: string reason: string launchSource?: string + assignmentMode?: string | null + courseVariants?: BackendCourseVariantSummary[] | null ongoingSession?: BackendEntrySessionSummary | null recentSession?: BackendEntrySessionSummary | null } @@ -139,6 +158,7 @@ export interface BackendLaunchResult { sessionTokenExpiresAt: string routeCode?: string | null } + variant?: BackendLaunchVariantSummary | null } } @@ -294,6 +314,7 @@ export function launchEvent(input: { eventId: string accessToken: string releaseId?: string + variantId?: string clientType: string deviceKey: string }): Promise { @@ -304,6 +325,9 @@ export function launchEvent(input: { if (input.releaseId) { body.releaseId = input.releaseId } + if (input.variantId) { + body.variantId = input.variantId + } return requestBackend({ method: 'POST', baseUrl: input.baseUrl, diff --git a/miniprogram/utils/backendLaunchAdapter.ts b/miniprogram/utils/backendLaunchAdapter.ts index e09692a..0b96ed3 100644 --- a/miniprogram/utils/backendLaunchAdapter.ts +++ b/miniprogram/utils/backendLaunchAdapter.ts @@ -17,5 +17,17 @@ export function adaptBackendLaunchResultToEnvelope(result: BackendLaunchResult): sessionToken: result.launch.business.sessionToken, 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, } } diff --git a/miniprogram/utils/gameLaunch.ts b/miniprogram/utils/gameLaunch.ts index ab84c7a..b2f7b3e 100644 --- a/miniprogram/utils/gameLaunch.ts +++ b/miniprogram/utils/gameLaunch.ts @@ -22,9 +22,17 @@ export interface BusinessLaunchContext { realtimeToken?: string | null } +export interface GameVariantLaunchContext { + variantId?: string | null + variantName?: string | null + routeCode?: string | null + assignmentMode?: string | null +} + export interface GameLaunchEnvelope { config: GameConfigLaunchRequest business: BusinessLaunchContext | null + variant?: GameVariantLaunchContext | null } export interface MapPageLaunchOptions { @@ -46,6 +54,9 @@ export interface MapPageLaunchOptions { sessionTokenExpiresAt?: string realtimeEndpoint?: string realtimeToken?: string + variantId?: string + variantName?: string + assignmentMode?: string } type PendingGameLaunchStore = Record @@ -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 { try { const stored = wx.getStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY) @@ -146,6 +179,7 @@ export function getDemoGameLaunchEnvelope(preset: DemoGamePreset = 'classic'): G business: { source: 'demo', }, + variant: null, } } @@ -217,6 +251,7 @@ export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null) routeCode: normalizeOptionalString(options ? options.routeCode : undefined), }, business: buildBusinessLaunchContext(options), + variant: buildVariantLaunchContext(options), } } diff --git a/typings/index.d.ts b/typings/index.d.ts index 2409417..d44b37b 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -6,6 +6,11 @@ interface IAppOption { telemetryPlayerProfile?: import('../miniprogram/game/telemetry/playerTelemetryProfile').PlayerTelemetryProfile | null, backendBaseUrl?: string | 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, }