瀏覽代碼

chore: 提交调试文档与模拟器改动

zhangyan 1 周之前
父節點
當前提交
175a16001e

+ 333 - 0
doc/MyToDo.md

@@ -0,0 +1,333 @@
+
+结果页会根据客户的要求不停的变换,用什么方案能实现这个需求,其实其他的弹出内容也都存在这个问题,样式,内容都时根据客户需求变化的,怎样一种方案设计比较好呢?
+
+
+我想改造下GPS模拟器,做成一个数据中转程序,这样后面的开发模拟,家长端监控,场控,数据回放就都能支持了,类似于路由器,只中转不保存数据,你觉得可行否?或者还有没有更好的方案?
+技术栈要轻量,健壮,性能第一,类似软路由这类应用
+
+
+目前已经把卡片架构的底座搭出来了,进度可以概括成:
+
+已经完成的
+1. 原生内容卡主链已经成立
+现在控制点内容不再是单一硬编码弹层,而是已经支持:
+
+title / body
+clickTitle / clickBody
+autoPopup
+once
+priority
+template
+而且:
+
+start-1 / control-N / finish-1 都可配置
+起终点同位置时,点击内容优先级也已经处理了
+2. 原生内容卡模板化已经落地
+目前已经有 3 种原生模板:
+
+minimal
+story
+focus
+默认策略也定了:
+
+起点/终点:focus
+普通点:story
+3. 内容展示机制已经不是“弹了就完”
+现在已经有:
+
+自动弹
+手动查看入口
+队列
+优先级覆盖
+once 本局只展示一次
+也就是说,已经开始像正式的“内容体验层”了。
+
+4. H5 分工已经定案并接进去了
+这条边界已经试清楚了:
+
+即时内容弹窗:原生
+H5:详情页 / 互动任务页 / 结果页
+H5 不再直接顶替原生弹窗
+当前实际链路是:
+
+原生内容卡 -> 查看详情 CTA -> H5 详情页
+
+5. H5 容器页已经能用了
+企业主体切过来后:
+
+web-view 已经能打开
+最小测试链路已经通了
+但也验证了一个结论:
+小程序 web-view 不适合做局部弹窗内容区
+适合整页详情/任务/结果页
+现在还没完全做完的
+1. 原生内容卡还不够“组件化”
+现在模板化已经有了,但还没完全形成更正式的:
+
+卡片 ViewModel
+卡片模板注册表
+模板渲染规范
+现在还是“已可用,但还不够体系化”。
+
+2. CTA 还比较初级
+现在 查看详情 能用了,但还缺:
+
+CTA 文案配置化
+CTA 图标 / 样式分级
+多动作支持
+查看详情
+开始互动
+拍照任务
+语音留言
+3. 结果页卡片体系还没完全接完
+结果页最小闭环做过,但还没真正进入:
+
+原生模板化
+H5 增强版
+数据模型与展示模板分层
+4. 原生有限 DSL 还没开始正式做
+我们已经把方向定了:
+
+原生模板
+原生有限 DSL
+H5 扩展
+但目前真正落代码的主要还是原生模板层,DSL 还没开始。
+
+所以当前阶段判断
+如果按“卡片架构”来讲,我会定义成:
+
+已经完成了第一阶段 60%~70%:
+
+主链和边界已经对了
+配置和模板能力已经起了
+还差把它做成真正可长期扩展的“卡片系统”
+这周最该做什么
+我建议这周把这 3 件事做完,卡片架构就算基本立住了:
+
+1. 把原生内容卡正式收成组件化结构
+目标:
+
+ContentCardModel
+模板映射
+渲染入口统一
+2. 把 CTA 体系做出来
+目标:
+
+CTA 类型
+CTA 文案
+CTA 动作模型
+原生 CTA -> H5 详情/任务
+3. 把结果页也纳入同一套卡片/模板思路
+目标:
+
+原生最小结果页模板化
+和内容卡共用部分卡片能力
+为 H5 结果页增强留接口
+一句话结论
+现在卡片架构已经从“零散弹层”进化到“有配置、有模板、有 H5 分工的内容系统雏形”了。
+这周如果继续往下做,最值得的是把:
+
+原生内容卡组件化
+CTA 动作体系
+结果页模板化
+这三件事补齐。
+
+如果你愿意,我下一步可以直接给你列一版:
+本周卡片架构实施清单,按文件和优先级拆开。
+
+
+好的,列个清单看看
+
+
+可以,按这周目标,我建议清单拆成 3 层。
+
+P0 本周必须完成
+
+原生内容卡模型收口
+目标:
+把当前散在地图页状态里的内容卡字段收成统一 ContentCardModel
+明确字段:
+template
+title
+body
+motionClass
+priority
+once
+cta
+建议落点:
+
+D:\dev\cmr-mini\miniprogram\game\experience
+D:\dev\cmr-mini\miniprogram\engine\map\mapEngine.ts
+CTA 动作体系
+目标:
+不再只有一个硬编码 查看详情
+统一成动作模型,例如:
+open-detail
+open-task
+close
+CTA 支持:
+label
+type
+target
+建议先只做一主一辅两个按钮位。
+
+原生内容卡模板注册表
+目标:
+把 minimal / story / focus 从“字符串分支”变成正式模板映射
+先明确每个模板:
+标题样式
+正文字数策略
+CTA 区布局
+动画等级差异
+结果页最小模板化
+目标:
+把当前结果页按内容卡思路收成一个稳定模板
+至少区分:
+result-minimal
+result-rich 预留
+P1 本周最好完成
+
+配置字典补齐卡片字段
+目标:
+文档里明确:
+template
+contentExperience
+clickExperience
+cta
+默认逻辑
+样例配置同步更新
+文件:
+
+D:\dev\cmr-mini\doc\config-option-dictionary.md
+D:\dev\cmr-mini\event\classic-sequential.json
+D:\dev\cmr-mini\event\score-o.json
+内容卡与结果页的 ViewModel 分层
+目标:
+内容卡和结果页都先走 ViewModel
+页面不直接拼业务字段
+建议模型:
+
+ContentCardModel
+ResultCardModel
+动画字典补卡片节点
+目标:
+把以下节点补进动画字典:
+内容卡进入
+内容卡关闭
+CTA 点击
+结果页进入
+结果主数字进入
+P2 可以放下周
+
+原生有限 DSL 第一版
+目标:
+不直接做万能布局
+先支持有限区块组合
+H5 详情/任务页 CTA 扩展
+目标:
+不只 查看详情
+后面支持:
+拍照打卡
+语音留言
+开始互动
+结果页 H5 增强版入口
+目标:
+原生结果页保底
+H5 作为增强页入口
+推荐执行顺序
+
+ContentCardModel
+CTA 动作体系
+模板注册表
+结果页模板化
+文档和样例同步
+动画字典补节点
+一句话
+这周先把“内容卡是一个系统”这件事做实,再把结果页纳入同一思路。
+如果你要,我下一步可以直接按这个清单开始做 P0-1:先收 ContentCardModel + CTA 动作体系。
+
+
+CTA是啥来
+
+
+
+CTA 就是:
+
+Call To Action
+中文可以理解成:
+
+行动按钮
+引导操作
+下一步动作
+在你这个项目里,最直观的例子就是内容卡上的:
+
+查看详情
+关闭
+以后还可以是:
+
+开始互动
+拍照打卡
+语音留言
+查看结果
+继续前进
+也就是说,CTA 不是内容本身,而是用户看到卡片后,可以立刻执行的动作入口。
+
+在你现在这个项目里
+例如这张卡:
+
+标题:比赛开始
+正文:从这里出发,先熟悉地图方向...
+CTA:
+查看详情
+关闭
+所以“CTA 动作体系”说白了就是:
+
+把这些按钮从临时写死,收成统一的动作模型。
+
+比如以后配置里可以变成:
+
+{
+  "cta": {
+    "type": "open-detail",
+    "label": "查看详情"
+  }
+}
+一句话:
+CTA 就是卡片上引导用户下一步操作的按钮。
+
+
+
+1. 先帮我指定一份游戏的最小配置模板,也就是说,去掉所有选配项,最小的游戏可跑配置,每个选项要带说明
+2. 再给我一份当前开发状态下最全的配置模板,每个选项,参数要带说明
+3. 将这2个文档落成文件
+
+
+先把原生内容卡收口,这是系统默认配置,所有配置不起作用得时候,他们兜底,
+在原生卡片上,CTA可以有拍照,语音,开启H5深度内容等能力。
+其中有一个自动出题目的能力,就是2-3位数得加减运算,并提供3个备选项让客户选择,同时有个倒计时,倒计时结束或选择答案后弹出对错提示。答对题目有积分,答错或没答没有积分,正确打点后也收割改点积分,顺序赛默认是1积分,积分赛根据实际点位积分来。
+先实现以上功能
+
+
+接着实现几个功能,细节的问题稍后说
+
+打卡点的样式我需要几套样式,现在是单一标准空心圆圈,太枯燥。
+我有几个想法:
+1. 顺序赛,可以定制打卡点样式,可以定制路线腿样式
+2. 积分赛,可以定制打卡点样式,不同积分可以不同颜色。
+
+基于上面的想法,你有好的实施方案吗?先讨论
+
+
+好的,测试可以,接着讨论下一个问题
+轨迹,我的想法是,用户轨迹有三种形式,无轨迹,全轨迹,拖尾轨迹,如果不走,轨迹最终消失,就是轨迹指着GPS点跑,你有什么方案?先讨论
+
+轨迹选项:无,彗尾,全轨迹
+轨迹样式:
+尾巴:短,中,长
+颜色:可以放8-16种基本色,亮色
+
+再说GPS点,用户位置的GPS点样式也是定制的,先说默认样式,可以定制显示与不显示。现在的样式有点呆和粗糙,我想给GPS点上加一个方向指示的小三角,跟着朝向转,你能理解吗?另外GPS点也有3种大小,用户自己可设置,默认中等大小即可,颜色也可设置。最重要的是,根据我们的经验,很多客户希望可以定制这个定位点,具有商业属性,例如换成商家的LOGO,这个有方案吗?先讨论。
+
+再深一点,自定GPS点能不能做成动画的,停止一个动画,跑起来又是一个动画,甚至可以做些额外的动作。
+
+开个小差,我想临时加个功能,在咱的GPS模拟器加个日志输出功能,把调试期间不方便打在调试面板里的信息输出到模拟器上,你觉得如何?这样更方便后期调试?如果可以先给个方案

+ 338 - 0
doc/config/线上业务接入边界方案.md

@@ -0,0 +1,338 @@
+# 线上业务接入边界方案
+
+## 1. 目的
+
+本文档定义小程序接入线上业务 API 时的架构边界,确保以下原则始终成立:
+
+- 游戏玩法仍然完全由配置驱动
+- 线上 API 只负责业务编排,不负责定义或污染玩法
+- 地图引擎和规则运行时可以继续独立于业务系统运行
+- 本地 demo、离线配置、线上赛事三种入口可以共存
+
+本文档适用于以下接入范围:
+
+- 用户管理
+- 登录与鉴权
+- 首页卡片
+- 赛事详情
+- 地图详情
+- Event 详情
+- 报名
+- launch 启动
+- 后续 session 上报与查询
+
+## 2. 核心结论
+
+线上接入后,系统仍保持两层结构:
+
+- 业务层:决定“用户是谁、能进什么、当前启动什么”
+- 游戏层:决定“地图怎么画、规则怎么跑、控制点怎么判定、体验怎么表现”
+
+两层之间只允许通过一个明确的启动模型通信,不允许业务 API 对游戏规则对象做直接写入。
+
+一句话定义:
+
+> API 负责发放启动上下文,配置负责定义游戏本身。
+
+## 3. 分层原则
+
+### 3.1 业务层职责
+
+业务层负责:
+
+- 登录与 token 管理
+- 用户资料与身体数据
+- 卡片、赛事、地图、Event 列表与详情
+- 报名资格校验
+- launch 启动资格与 session 凭证发放
+- 后续成绩、轨迹、历史记录上传与查询
+
+业务层可以决定:
+
+- 是否允许用户启动
+- 当前应启动哪个 Event
+- 当前应加载哪份配置或 manifest
+- 当前启动绑定的 `session_id`、`session_token`
+
+业务层不可以决定:
+
+- 控制点布局
+- 游戏规则
+- 打卡判定
+- 跳点规则
+- 引导、音效、表现策略
+- 游戏内内容卡的结构和行为定义
+
+### 3.2 游戏层职责
+
+游戏层负责:
+
+- 地图资源加载
+- KML / course / 配置解析
+- `GameDefinition` 构建
+- 规则插件运行
+- 传感器接入
+- HUD、反馈、结果页、本地统计
+- 游戏内 session 生命周期
+
+游戏层只认配置和本地运行态,不认业务 API 对象。
+
+游戏层不应该直接出现以下业务字段:
+
+- `competition_id`
+- `registration_status`
+- `access_token`
+- `refresh_token`
+- `Authorization`
+- `user_id`
+
+业务字段可以存在于页面壳层或业务服务层,但不进入规则层。
+
+## 4. 唯一允许的层间模型
+
+业务层和游戏层之间,统一通过 `GameLaunchEnvelope` 通信。
+
+建议结构如下:
+
+```ts
+interface GameLaunchEnvelope {
+  config: {
+    configUrl: string
+    configLabel: string
+    configChecksumSha256?: string | null
+    releaseId?: string | null
+    routeCode?: string | null
+  }
+  business: {
+    source: 'demo' | 'competition' | 'direct-event' | 'custom'
+    competitionId?: string | null
+    eventId?: string | null
+    launchRequestId?: string | null
+    participantId?: string | null
+    sessionId?: string | null
+    sessionToken?: string | null
+    sessionTokenExpiresAt?: string | null
+    realtimeEndpoint?: string | null
+    realtimeToken?: string | null
+  } | null
+}
+```
+
+解释:
+
+- `config` 是游戏层真正消费的输入
+- `business` 是业务壳保留的上下文
+- 地图页可以同时拿到两者,但地图引擎只读取 `config`
+
+## 5. 推荐启动链路
+
+### 5.1 Demo 启动
+
+适用于本地调试、离线测试、玩法验证。
+
+流程:
+
+1. 页面构建 demo `GameLaunchEnvelope`
+2. `config.configUrl` 指向 demo 配置
+3. `business.source = 'demo'`
+4. 跳转地图页
+5. 地图页加载配置并启动引擎
+
+特点:
+
+- 不依赖业务 API
+- 不依赖登录
+- 不依赖 session
+
+### 5.2 线上赛事启动
+
+适用于正式业务入口。
+
+流程:
+
+1. 业务页请求赛事 / Event 详情
+2. 用户在业务页完成登录、资格校验、报名确认
+3. 用户点击开始,业务页调用 `launch`
+4. 后端返回 `session_id`、`session_token`、`release_id`、`manifest_url`、`route_code`
+5. 业务层把上述信息转换为 `GameLaunchEnvelope`
+6. 地图页只根据 `config` 载入配置
+7. 业务壳层保存 `business` 上下文供后续上报使用
+
+注意:
+
+- `launch` 是业务启动,不等于规则层 `startSession`
+- 规则层本地开始游戏,仍由引擎按配置驱动
+
+### 5.3 线上直入地图启动
+
+适用于地图详情或 Event 直入。
+
+流程与赛事入口基本一致,区别仅在于:
+
+- 入口页不同
+- 资格校验更轻
+- `business.source = 'direct-event'`
+
+## 6. manifest 的角色
+
+后端提供的 `manifest_url` 不应直接变成规则层对象。
+
+推荐做法:
+
+- 业务层或适配层下载 manifest
+- 将 manifest 解析并映射到当前配置体系
+- 输出为当前引擎已支持的配置入口
+
+manifest 是“线上发布描述”,不是“规则运行对象”。
+
+建议把 manifest 适配理解为一个编译过程:
+
+- 输入:后端发布描述
+- 输出:当前配置驱动引擎可识别的配置资源
+
+## 7. 目录建议
+
+建议按三层组织代码:
+
+```text
+miniprogram/
+  services/
+    http.ts
+    client-api.ts
+    auth.ts
+  business/
+    launch/
+      launchBuilder.ts
+      launchStore.ts
+      manifestAdapter.ts
+  utils/
+    gameLaunch.ts
+  pages/
+    login/
+    home/
+    competition-detail/
+    event-detail/
+    map/
+```
+
+说明:
+
+- `services` 只处理 API 通信
+- `business/launch` 只做业务到配置的适配
+- `utils/gameLaunch.ts` 定义启动模型和页面跳转协议
+- `pages/map` 只做配置加载和游戏承载
+
+## 8. 代码边界约束
+
+### 8.1 允许进入地图页的内容
+
+允许进入地图页:
+
+- `GameLaunchEnvelope`
+- `configUrl`
+- `configLabel`
+- `releaseId`
+- `routeCode`
+- `sessionToken`
+
+但地图页内部还要继续区分:
+
+- 引擎可读:`config`
+- 业务壳可读:`business`
+
+### 8.2 不允许进入引擎的内容
+
+以下内容禁止进入 `MapEngine`、`GameRuntime`、`GameDefinition`:
+
+- 用户信息
+- 登录态 token
+- 报名状态
+- 业务接口返回原始对象
+- 赛事详情原始 JSON
+- Event 详情原始 JSON
+
+### 8.3 上报也走旁路
+
+后续若接 `punches`、`finish`、`session-uploads`,建议流程如下:
+
+1. 游戏层产生本地事件
+2. 页面壳层或业务 service 订阅这些事件
+3. 由业务层决定是否调用 API
+
+不要在规则层里直接 `wx.request`。
+
+## 9. 当前项目的落地点
+
+当前项目已具备以下基础:
+
+- 地图页是配置驱动入口
+- `remoteMapConfig.ts` 负责远程配置加载
+- `MapEngine` 负责本地规则与表现运行
+- 已新增 `utils/gameLaunch.ts` 作为启动边界模型
+
+当前建议继续保持:
+
+- `MapEngine` 只接 `RemoteMapConfig`
+- 地图页只从 `GameLaunchEnvelope.config` 获取配置入口
+- 业务上下文保留在地图页外层或页面壳层
+
+## 10. 分阶段落地建议
+
+### 阶段一:边界固化
+
+目标:
+
+- 地图页彻底改为只接 `GameLaunchEnvelope`
+- demo 启动与线上启动走同一套入口协议
+
+验收标准:
+
+- 不再依赖页面内硬编码 URL 作为唯一启动方式
+- 业务字段不进入引擎
+
+### 阶段二:业务壳接入
+
+目标:
+
+- 接入登录、首页卡片、赛事详情、Event 详情、报名、launch
+
+验收标准:
+
+- 能从业务页成功进入地图页
+- 地图仍由配置驱动启动
+
+### 阶段三:manifest 适配
+
+目标:
+
+- 将后端 `manifest_url` 适配为当前配置体系可消费的输入
+
+验收标准:
+
+- 同一个 Event 的线上发布内容可稳定映射为游戏配置入口
+
+### 阶段四:session 下游联通
+
+目标:
+
+- 补充上报、完成、结果、历史查询
+
+验收标准:
+
+- 业务链路打通
+- 规则层仍不直接依赖业务 API
+
+## 11. 必须长期坚持的规则
+
+- 业务 API 不定义玩法
+- 配置文件不承载用户态
+- 引擎不依赖登录状态
+- 引擎不依赖报名状态
+- 业务页不直接修改 `GameDefinition`
+- 规则层不直接请求业务 API
+
+如果后续出现需求需要绕过这几条规则,应视为架构变更,不应当作普通功能迭代处理。
+
+## 12. 一句话总结
+
+线上系统负责“把用户送进正确的一局游戏”,配置系统负责“定义这局游戏是什么”。

+ 141 - 0
doc/debug/模拟器多通道联调最小方案.md

@@ -0,0 +1,141 @@
+# 模拟器多通道联调最小方案
+
+## 目标
+
+在不引入房间系统、不增加复杂编排的前提下,让同一台本地模拟器服务能够同时承接多路联调数据,并保证不同联调对象之间的数据不串线。
+
+## 方案
+
+统一增加一个字段:
+
+- `channelId`
+
+三条链都带这个字段:
+
+- `mock-gps`
+- `mock-hr`
+- `debug-log`
+
+## 设计原则
+
+- 不做 room / participant 管理
+- 不做多人控制台编排
+- 只解决“数据隔离”
+- GPS、心率、日志三条链统一使用同一个模拟通道号
+
+## 默认值
+
+默认通道号:
+
+```json
+"default"
+```
+
+空值、缺失值都归一化成:
+
+```json
+"default"
+```
+
+## 消息格式
+
+### GPS
+
+```json
+{
+  "type": "mock_gps",
+  "timestamp": 1712345678901,
+  "channelId": "runner-a",
+  "lat": 31.2304,
+  "lon": 121.4737,
+  "accuracyMeters": 6,
+  "speedMps": 2.4,
+  "headingDeg": 92
+}
+```
+
+### 心率
+
+```json
+{
+  "type": "mock_heart_rate",
+  "timestamp": 1712345678901,
+  "channelId": "runner-a",
+  "bpm": 148
+}
+```
+
+### 调试日志
+
+```json
+{
+  "type": "debug-log",
+  "timestamp": 1712345678901,
+  "channelId": "runner-a",
+  "scope": "gps-logo",
+  "level": "info",
+  "message": "logo ready",
+  "payload": {
+    "src": "https://example.com/logo.png"
+  }
+}
+```
+
+## 模拟器侧
+
+新版工作台提供一个统一输入:
+
+- `模拟通道号`
+
+它会同时作用于:
+
+- GPS 发送
+- 心率发送
+- 日志过滤
+
+也就是说,一个模拟器页面实例默认对应一个通道。
+
+## 小程序侧
+
+调试面板提供一个统一输入:
+
+- `模拟通道号`
+
+保存后会同步给:
+
+- 定位模拟接收过滤
+- 心率模拟接收过滤
+- logger 发送通道
+
+“一键连接开发调试源”会带上当前通道号一起生效。
+
+## 接收规则
+
+接收端统一按归一化后的 `channelId` 精确匹配:
+
+- 收到的消息 `channelId` 与当前模拟通道号一致才消费
+- 不一致直接忽略
+
+缺失 `channelId` 的旧消息,按 `default` 处理。
+
+## 适用场景
+
+- 两台手机同时接同一台本地模拟器服务
+- 一个调试人员同时开多台模拟器页面
+- 同时联调多个儿童设备
+
+## 当前边界
+
+这套最小方案只解决:
+
+- 多路数据隔离
+
+不解决:
+
+- 房间管理
+- 成员列表
+- 批量启动/停止
+- 同步起跑
+- 多控制台协作
+
+如果后面真的需要这些,再升级到房间模型。

+ 10 - 21
doc/debug/模拟器控制面板重构方案.md

@@ -2,22 +2,22 @@
 
 ## 目标
 
-在不破坏现有老版面板的前提下,新增一套新版控制面板,用于承接更复杂的开发调试工作流。
+在不增加第二套历史 UI 负担的前提下,整理出一套新版控制面板,用于承接更复杂的开发调试工作流。
 
 重构目标:
 
-- 保留老版入口,确保已有使用习惯不受影响
 - 新增工作台式面板,提升连接、控制、观察、排障效率
 - 继续复用现有模拟器脚本和 websocket 协议,避免维护两套逻辑
+- 最终只保留新版入口,避免长期双份维护
 
 ## 设计原则
 
-1. 新旧并行
+1. 单入口维护
    - 新版入口使用 `/`
-   - 旧版入口保留在 `/v1/`
+   - 模拟器只保留一个工作台入口
 2. 逻辑复用
-   - 两个页面共用 `simulator.js`
-   - 只通过不同 HTML 布局和 CSS 风格区分
+   - 继续复用 `simulator.js`
+   - 只维护一套 HTML 布局和 CSS 风格
 3. 面向调试流程
    - 连接优先
    - 控制第二
@@ -44,7 +44,6 @@
 - 心率模拟连接状态
 - 调试日志连接状态
 - 一键连接开发调试源
-- 新旧面板切换入口
 
 ### 2. 左侧控制区
 
@@ -83,26 +82,16 @@
 - 面积更大
 - 便于边看地图边看日志
 
-## 与旧版的关系
-
-旧版和新版应同时可用:
-
-- 新版作为默认工作台
-- 旧版继续作为稳定基线
-- 问题排查时可快速回退旧版
-
 ## 实施顺序
 
 1. 根路径切换到新版工作台
 2. 新增新版样式 `workbench.css`
 3. 复用现有 `simulator.js`
-4. 旧版页面迁移到 `/v1/`
-5. 在旧版和新版之间互相添加跳转入口
-6. 更新 README 和调试文档索引
+4. 清理历史入口和旧文案
+5. 更新 README 和调试文档索引
 
 ## 验收标准
 
-- 老版页面继续正常工作
 - 新版页面可完整使用现有 GPS、心率、日志、路径、网关能力
-- 两个页面共用同一套 websocket 协议和数据逻辑
-- 用户可以在两个版本之间切换
+- 模拟器只保留一个工作台入口
+- websocket 协议和调试逻辑继续复

+ 5 - 3
doc/debug/模拟器调试日志方案.md

@@ -2,11 +2,11 @@
 
 ## 目标
 
-复用现有 GPS 模拟器 websocket,在不污染地图调试面板的前提下,把高频、临时、开发期日志输出到外部模拟器。
+复用现有模拟器服务,在不污染地图调试面板的前提下,把高频、临时、开发期日志输出到外部模拟器。
 
 第一阶段只做最小闭环:
 
-- 复用 `tools/mock-gps-sim` 现有 websocket
+- 复用 `tools/mock-gps-sim` 现有服务
 - 增加 `debug-log` 消息类型
 - 小程序侧增加最小 logger
 - 第一批只发送 `gps-logo` 范围日志
@@ -27,6 +27,7 @@
 {
   "type": "debug-log",
   "timestamp": 1712345678901,
+  "channelId": "runner-a",
   "scope": "gps-logo",
   "level": "info",
   "message": "wx.getImageInfo success",
@@ -45,6 +46,8 @@
   毫秒时间戳
 - `scope`
   日志分类,例如 `gps-logo`、`h5`、`compass`
+- `channelId`
+  日志所属模拟通道,用于多人联调时隔离不同设备的过程日志
 - `level`
   `info / warn / error`
 - `message`
@@ -123,4 +126,3 @@
 ## 当前结论
 
 先把 `gps-logo` 调试链打通,再回头用模拟器日志查 logo 为什么不显示,比继续把临时字段堆在调试面板里更稳。
-

+ 6 - 2
doc/debug/调试文档索引.md

@@ -10,11 +10,13 @@
 ## 当前主文档
 
 - [模拟器控制面板重构方案](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md)
-  用于说明新版模拟器工作台布局、新旧并行策略和重构目标。
+  用于说明新版模拟器工作台布局和重构目标。
 - [平台能力说明](/D:/dev/cmr-mini/doc/debug/平台能力说明.md)
   用于记录主体能力、`web-view`、传感器等平台边界。
 - [模拟器调试日志方案](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md)
   用于说明 mock simulator 的日志旁路与 `debug-log` 协议。
+- [模拟器多通道联调最小方案](/D:/dev/cmr-mini/doc/debug/模拟器多通道联调最小方案.md)
+  用于说明 GPS / 心率 / 日志三条链统一按 `channelId` 隔离的最小实现。
 - [传感器当前状态总结](/D:/dev/cmr-mini/doc/debug/传感器现状总结.md)
   用于看当前已确认的传感器状态与调试结论。
 - [罗盘问题记录](/D:/dev/cmr-mini/doc/debug/罗盘排障记录.md)
@@ -26,11 +28,13 @@
 2. [mock-simulator-control-panel-proposal.md](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md)
 3. [sensor-current-summary.md](/D:/dev/cmr-mini/doc/debug/传感器现状总结.md)
 4. [mock-simulator-debug-log-proposal.md](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md)
-5. [compass-debugging-notes.md](/D:/dev/cmr-mini/doc/debug/罗盘排障记录.md)
+5. [multi-channel-simulator-minimal-plan.md](/D:/dev/cmr-mini/doc/debug/模拟器多通道联调最小方案.md)
+6. [compass-debugging-notes.md](/D:/dev/cmr-mini/doc/debug/罗盘排障记录.md)
 
 ## 使用建议
 
 - 看“当前限制”和“为什么会这样”,优先看平台能力说明。
 - 看“现在系统是什么状态”,优先看传感器现状总结。
 - 看“以后日志怎么打”,优先看模拟器日志方案。
+- 看“多人联调怎么隔离”,优先看模拟器多通道联调最小方案。
 - 看“为什么罗盘以前坏过”,再去看罗盘问题记录。

+ 15 - 0
miniprogram/engine/debug/mockSimulatorDebugLogger.ts

@@ -14,12 +14,18 @@ export interface MockSimulatorDebugLoggerState {
 export interface MockSimulatorDebugLogEntry {
   type: 'debug-log'
   timestamp: number
+  channelId?: string
   scope: string
   level: MockSimulatorDebugLogLevel
   message: string
   payload?: Record<string, unknown>
 }
 
+function normalizeMockSimulatorChannelId(rawChannelId: string | null | undefined): string {
+  const trimmed = String(rawChannelId || '').trim()
+  return trimmed || 'default'
+}
+
 function normalizeMockSimulatorLogUrl(rawUrl: string): string {
   const trimmed = String(rawUrl || '').trim()
   if (!trimmed) {
@@ -45,6 +51,7 @@ export class MockSimulatorDebugLogger {
   connected: boolean
   connecting: boolean
   url: string
+  channelId: string
   queue: MockSimulatorDebugLogEntry[]
   onStateChange?: (state: MockSimulatorDebugLoggerState) => void
 
@@ -54,6 +61,7 @@ export class MockSimulatorDebugLogger {
     this.connected = false
     this.connecting = false
     this.url = DEFAULT_DEBUG_LOG_URL
+    this.channelId = 'default'
     this.queue = []
     this.onStateChange = onStateChange
   }
@@ -114,6 +122,10 @@ export class MockSimulatorDebugLogger {
     this.connect()
   }
 
+  setChannelId(channelId: string): void {
+    this.channelId = normalizeMockSimulatorChannelId(channelId)
+  }
+
   log(
     scope: string,
     level: MockSimulatorDebugLogLevel,
@@ -127,6 +139,7 @@ export class MockSimulatorDebugLogger {
     const entry: MockSimulatorDebugLogEntry = {
       type: 'debug-log',
       timestamp: Date.now(),
+      channelId: this.channelId,
       scope,
       level,
       message,
@@ -185,11 +198,13 @@ export class MockSimulatorDebugLogger {
         this.send({
           type: 'debug-log',
           timestamp: Date.now(),
+          channelId: this.channelId,
           scope: 'logger',
           level: 'info',
           message: 'logger channel connected',
           payload: {
             url: this.url,
+            channelId: this.channelId,
           },
         })
         this.flush()

+ 1 - 1
project.config.json

@@ -46,5 +46,5 @@
     "ignore": [],
     "include": []
   },
-  "appid": "wx9cca5c5a219a4f9c"
+  "appid": "wx0c8b079993bb9d7a"
 }

+ 694 - 0
tmp/Client-API.md

@@ -0,0 +1,694 @@
+# Client API 前端联调文档
+
+文档版本:v1.0.0
+最后更新:2026-03-31
+状态:联调中
+
+## 1. 文档说明
+
+本文档面向前端联调,描述当前 `client-api` 在代码中真实可用的接口。
+
+约定:
+
+- 本文档优先级高于产品阶段的总草案;前端联调以本文档为准
+- 本文档只覆盖 `client-api`
+- 每个接口会标记当前状态:
+  - `已实现,已联调`
+  - `已实现,未联调`
+  - `预留未就绪`
+
+## 2. 通用约定
+
+### 2.1 Base Path
+
+- `client-api`:`/client/v1`
+
+### 2.2 成功响应
+
+```json
+{
+  "request_id": "req_xxx",
+  "data": {}
+}
+```
+
+### 2.3 失败响应
+
+```json
+{
+  "request_id": "req_xxx",
+  "error": {
+    "code": "invalid_request",
+    "message": "xxx",
+    "details": {}
+  }
+}
+```
+
+### 2.4 鉴权说明
+
+- 登录后接口使用:`Authorization: Bearer {access_token}`
+- `launch` 成功后会返回 `session_token`
+- 但当前版本下游 `session` 相关接口尚未开放,因此前端暂时只需要保存 `session_token`
+
+### 2.5 枚举说明
+
+`client_type`
+
+- `app`
+- `wechat`
+
+`body_profile_status`
+
+- `pending`
+- `completed`
+
+## 3. 已实现接口
+
+### 3.1 `POST /client/v1/auth/sms/send`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 发送短信验证码
+- 当前已接阿里云短信
+
+请求参数:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `scene` | `string` | 是 | 当前已使用 `client_login` |
+| `country_code` | `string` | 是 | 国家码,如 `86` |
+| `mobile` | `string` | 是 | 手机号 |
+| `client_type` | `string` | 是 | `app` / `wechat` |
+| `device_id` | `string` | 是 | 设备唯一标识 |
+
+请求示例:
+
+```json
+{
+  "scene": "client_login",
+  "country_code": "86",
+  "mobile": "15168870729",
+  "client_type": "app",
+  "device_id": "dev_iphone_001"
+}
+```
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `cooldown_sec` | `int` | 重发冷却时间 |
+| `expires_in_sec` | `int` | 验证码有效期 |
+
+### 3.2 `POST /client/v1/auth/login/sms`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 短信登录
+- 如果手机号首次登录,后端会自动创建:
+  - `users`
+  - `login_identities`
+  - 默认 `user_body_profiles`
+  - 首条 `user_body_profile_versions`
+
+请求参数:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `login_request_id` | `string` | 是 | 登录幂等号 |
+| `country_code` | `string` | 是 | 国家码 |
+| `mobile` | `string` | 是 | 手机号 |
+| `sms_code` | `string` | 是 | 短信验证码 |
+| `client_type` | `string` | 是 | `app` / `wechat` |
+| `device_id` | `string` | 是 | 设备唯一标识 |
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `user_id` | `string` | 用户公开 ID |
+| `access_token` | `string` | 登录态 token |
+| `refresh_token` | `string` | refresh token |
+| `login_method` | `string` | 当前为 `sms` |
+| `body_profile_status` | `string` | `pending` / `completed` |
+| `expires_in_sec` | `int` | `access_token` 有效期 |
+
+### 3.3 `POST /client/v1/auth/login/wechat`
+
+状态:`已实现,未联调`
+
+接口介绍:
+
+- 微信登录入口已预留
+- 当前仓库里 provider 仍以 mock 为主,尚未做真实联调
+
+请求参数:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `login_request_id` | `string` | 是 | 登录幂等号 |
+| `wechat_login_code` | `string` | 是 | 微信登录 code |
+| `client_type` | `string` | 是 | 建议传 `wechat` |
+| `device_id` | `string` | 是 | 设备唯一标识 |
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `user_id` | `string` | 用户公开 ID |
+| `access_token` | `string` | 登录态 token |
+| `refresh_token` | `string` | refresh token |
+| `login_method` | `string` | 当前为 `wechat` |
+| `body_profile_status` | `string` | `pending` / `completed` |
+| `expires_in_sec` | `int` | `access_token` 有效期 |
+
+### 3.4 `POST /client/v1/auth/refresh`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 使用 refresh token 刷新 `access_token`
+
+请求参数:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `refresh_token` | `string` | 是 | refresh token |
+| `client_type` | `string` | 是 | `app` / `wechat` |
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `access_token` | `string` | 新的登录态 token |
+| `expires_in_sec` | `int` | 有效期 |
+
+### 3.5 `PUT /client/v1/me/body-profile`
+
+状态:`已实现,未联调`
+
+接口介绍:
+
+- 更新身体数据
+- 成功后会更新当前档案,并追加历史版本
+
+认证:
+
+- 需要 `access_token`
+
+请求参数:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `gender` | `string` | 否 | `male` / `female` / `other` / `unknown` |
+| `birth_date` | `string` | 否 | `YYYY-MM-DD` |
+| `height_cm` | `number` | 否 | 身高,厘米 |
+| `weight_kg` | `number` | 否 | 体重,千克 |
+| `resting_heart_rate_bpm` | `int` | 否 | 静息心率 |
+| `max_heart_rate_bpm` | `int` | 否 | 最大心率 |
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `user_id` | `string` | 用户公开 ID |
+| `body_profile_status` | `string` | `pending` / `completed` |
+| `completed_at` | `string` | 首次补全时间,未补全时可能为空 |
+
+### 3.6 `GET /client/v1/cards`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 首页卡片列表
+
+查询参数:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `region_code` | `string` | 否 | 地区编码,当前可空 |
+
+返回参数:
+
+`data.items[]`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `card_id` | `string` | 卡片公开 ID |
+| `card_type` | `string` | 例如 `competition_card` |
+| `display_name` | `string` | 卡片展示名称 |
+| `competition_id` | `string` | 关联赛事 ID,非赛事卡可能为空 |
+| `html_url` | `string` | H5 地址,可空 |
+| `cover_url` | `string` | 封面地址,可空 |
+| `display_slot` | `string` | 展示槽位 |
+| `display_priority` | `int` | 展示优先级 |
+
+### 3.7 `GET /client/v1/competitions/{competition_id}`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 赛事详情页
+- 如果带 `access_token`,会返回当前用户的 `registration_status`
+
+认证:
+
+- 可匿名访问
+- 建议前端在登录后带上 `access_token`
+
+Path 参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `competition_id` | `string` | 赛事公开 ID |
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `competition_id` | `string` | 赛事公开 ID |
+| `display_name` | `string` | 赛事名称 |
+| `competition_status` | `string` | 当前状态 |
+| `registration_enabled` | `bool` | 是否允许报名 |
+| `registration_status` | `string` | 当前用户报名状态,匿名访问时可能为空 |
+| `competition_start_at` | `string` | 赛事开始时间 |
+| `competition_end_at` | `string` | 赛事结束时间 |
+| `leaderboard_enabled` | `bool` | 是否展示排行榜 |
+| `realtime_board_enabled` | `bool` | 是否启用实时榜 |
+
+`data.events[]`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `event_id` | `string` | Event 公开 ID |
+| `display_name` | `string` | Event 名称 |
+| `is_default` | `bool` | 是否默认 Event |
+| `current_release_id` | `string` | 当前发布版 ID |
+| `manifest_url` | `string` | manifest 下载地址 |
+| `manifest_checksum_sha256` | `string` | manifest 校验值 |
+| `relation_status` | `string` | 关联状态 |
+
+### 3.8 `GET /client/v1/competitions/{competition_id}/events/{event_id}`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 赛事上下文下的 Event 详情
+- 前端应在这个页面预加载 manifest,并完成路线预览
+
+认证:
+
+- 可匿名访问
+- 建议前端在登录后带上 `access_token`
+
+Path 参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `competition_id` | `string` | 赛事公开 ID |
+| `event_id` | `string` | Event 公开 ID |
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `competition_id` | `string` | 赛事公开 ID |
+| `event_id` | `string` | Event 公开 ID |
+| `display_name` | `string` | Event 名称 |
+| `current_release_id` | `string` | 当前发布版 ID |
+| `manifest_url` | `string` | manifest 下载地址 |
+| `manifest_checksum_sha256` | `string` | manifest 校验值 |
+| `direct_entry_enabled` | `bool` | 是否支持地图直入 |
+| `playfield_version_id` | `string` | 场地版本 ID |
+| `playfield_kind` | `string` | 如 `course` |
+
+`data.competition_context`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `competition_id` | `string` | 赛事公开 ID |
+| `display_name` | `string` | 赛事名称 |
+| `competition_status` | `string` | 赛事状态 |
+| `registration_status` | `string` | 当前用户报名状态 |
+| `leaderboard_enabled` | `bool` | 是否显示排行榜 |
+| `realtime_board_enabled` | `bool` | 是否启用实时榜 |
+
+`data.map_summary`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `map_id` | `string` | 地图公开 ID |
+| `display_name` | `string` | 地图名称 |
+| `cover_url` | `string` | 封面图,可空 |
+| `scale_text` | `string` | 比例尺,可空 |
+
+`data.preview_summary`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `control_count` | `int` | 控制点数量 |
+| `route_count` | `int` | 路线数量 |
+| `playfield_kind` | `string` | 场地类型 |
+
+### 3.9 `POST /client/v1/competitions/{competition_id}/registrations`
+
+状态:`已实现,未联调`
+
+接口介绍:
+
+- 赛事报名
+
+认证:
+
+- 需要 `access_token`
+
+Path 参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `competition_id` | `string` | 赛事公开 ID |
+
+请求参数:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `group_id` | `string` | 否 | 队伍或分组 ID |
+| `form_payload` | `object` | 否 | 附加报名表单 |
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `registration_id` | `string` | 报名记录 ID |
+| `status` | `string` | 当前报名状态 |
+
+### 3.10 `GET /client/v1/maps`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 地图列表页
+
+查询参数:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `region_code` | `string` | 否 | 地区编码 |
+| `page` | `int` | 否 | 默认 `1` |
+| `page_size` | `int` | 否 | 默认 `20`,最大 `50` |
+
+返回参数:
+
+`data.items[]`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `map_id` | `string` | 地图公开 ID |
+| `display_name` | `string` | 地图名称 |
+| `cover_url` | `string` | 封面图,可空 |
+| `scale_text` | `string` | 比例尺,可空 |
+| `distance_from_user_km` | `number` | 距离,可空 |
+
+说明:
+
+- 当前响应只返回 `items`,不回显 `page/page_size/total`
+
+### 3.11 `GET /client/v1/maps/{map_id}`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 地图详情页
+- 同时返回当前地图下允许直入的 Event 列表
+
+Path 参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `map_id` | `string` | 地图公开 ID |
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `map_id` | `string` | 地图公开 ID |
+| `display_name` | `string` | 地图名称 |
+
+`data.map_summary`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `cover_url` | `string` | 封面图,可空 |
+| `scale_text` | `string` | 比例尺,可空 |
+| `updated_date` | `string` | 更新时间,可空 |
+
+`data.events[]`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `event_id` | `string` | Event 公开 ID |
+| `display_name` | `string` | Event 名称 |
+| `preview_image_url` | `string` | 预览图,可空 |
+| `control_count` | `int` | 控制点数量 |
+| `route_count` | `int` | 路线数量 |
+| `direct_entry_enabled` | `bool` | 是否允许地图直入 |
+| `current_release_id` | `string` | 当前发布版 ID |
+| `manifest_url` | `string` | manifest 下载地址 |
+| `manifest_checksum_sha256` | `string` | manifest 校验值 |
+| `playfield_kind` | `string` | 如 `course` |
+
+### 3.12 `GET /client/v1/events`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 地图直入链路下的 Event 列表
+
+查询参数:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `map_id` | `string` | 否 | 按地图筛选 |
+| `page` | `int` | 否 | 默认 `1` |
+| `page_size` | `int` | 否 | 默认 `20`,最大 `50` |
+
+返回参数:
+
+`data.items[]`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `event_id` | `string` | Event 公开 ID |
+| `display_name` | `string` | Event 名称 |
+| `map_id` | `string` | 地图公开 ID |
+| `map_display_name` | `string` | 地图名称 |
+| `preview_image_url` | `string` | 预览图,可空 |
+| `control_count` | `int` | 控制点数量 |
+| `route_count` | `int` | 路线数量 |
+| `direct_entry_enabled` | `bool` | 是否允许地图直入 |
+| `current_release_id` | `string` | 当前发布版 ID |
+| `manifest_url` | `string` | manifest 下载地址 |
+| `manifest_checksum_sha256` | `string` | manifest 校验值 |
+| `playfield_kind` | `string` | 如 `course` |
+
+说明:
+
+- 当前响应只返回 `items`,不回显 `page/page_size/total`
+
+### 3.13 `GET /client/v1/events/{event_id}`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 地图直入上下文下的 Event 详情
+- 与赛事入口页共用同一套 Event 详情视图
+
+Path 参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `event_id` | `string` | Event 公开 ID |
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `event_id` | `string` | Event 公开 ID |
+| `display_name` | `string` | Event 名称 |
+| `direct_entry_enabled` | `bool` | 是否允许地图直入 |
+| `current_release_id` | `string` | 当前发布版 ID |
+| `manifest_url` | `string` | manifest 下载地址 |
+| `manifest_checksum_sha256` | `string` | manifest 校验值 |
+| `playfield_version_id` | `string` | 场地版本 ID |
+| `playfield_kind` | `string` | 如 `course` |
+
+`data.map_summary`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `map_id` | `string` | 地图公开 ID |
+| `display_name` | `string` | 地图名称 |
+| `cover_url` | `string` | 封面图,可空 |
+| `scale_text` | `string` | 比例尺,可空 |
+
+`data.event_summary`
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `control_count` | `int` | 控制点数量 |
+| `route_count` | `int` | 路线数量 |
+| `playfield_kind` | `string` | 场地类型 |
+
+### 3.14 `POST /client/v1/competitions/{competition_id}/events/{event_id}/launch`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 赛事入口 `launch`
+- 会校验赛事时间窗、报名状态、`release_id`
+- 成功后创建 session,并返回 `session_token`
+
+认证:
+
+- 需要 `access_token`
+
+Path 参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `competition_id` | `string` | 赛事公开 ID |
+| `event_id` | `string` | Event 公开 ID |
+
+请求参数:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `launch_request_id` | `string` | 是 | 启动幂等号 |
+| `device_id` | `string` | 是 | 设备唯一标识 |
+| `client_type` | `string` | 是 | 必须与 `access_token` 内声明一致 |
+| `release_id` | `string` | 是 | 前端当前持有的发布版 ID |
+| `route_code` | `string` | 否 | 当前选中的路线编码 |
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `session_id` | `string` | 会话 ID |
+| `session_token` | `string` | 会话 token |
+| `session_token_expires_at` | `string` | 会话 token 过期时间 |
+| `participant_id` | `string` | 参赛身份 ID |
+| `competition_id` | `string` | 赛事公开 ID |
+| `event_id` | `string` | Event 公开 ID |
+| `event_release_id` | `string` | 实际冻结的发布版 ID |
+| `playfield_version_id` | `string` | 场地版本 ID |
+| `route_code` | `string` | 当前冻结的路线编码 |
+| `realtime_endpoint` | `string` | realtime-center 地址 |
+| `realtime_token` | `string` | 当前版本通常为空 |
+
+当前注意:
+
+- 若前端传入的 `release_id` 已过期,会返回 `EVENT_RELEASE_STALE`
+- 当前后端会冻结并回传 `route_code`,但还没有对 manifest 内路线做严格校验
+
+### 3.15 `POST /client/v1/events/{event_id}/launch`
+
+状态:`已实现,已联调`
+
+接口介绍:
+
+- 地图直入 `launch`
+- 不校验赛事报名资格
+- 成功后创建 session,并返回 `session_token`
+
+认证:
+
+- 需要 `access_token`
+
+Path 参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `event_id` | `string` | Event 公开 ID |
+
+请求参数:
+
+| 字段 | 类型 | 必填 | 说明 |
+| --- | --- | --- | --- |
+| `launch_request_id` | `string` | 是 | 启动幂等号 |
+| `device_id` | `string` | 是 | 设备唯一标识 |
+| `client_type` | `string` | 是 | 必须与 `access_token` 内声明一致 |
+| `release_id` | `string` | 是 | 前端当前持有的发布版 ID |
+| `route_code` | `string` | 否 | 当前选中的路线编码 |
+
+返回参数:
+
+| 字段 | 类型 | 说明 |
+| --- | --- | --- |
+| `session_id` | `string` | 会话 ID |
+| `session_token` | `string` | 会话 token |
+| `session_token_expires_at` | `string` | 会话 token 过期时间 |
+| `participant_id` | `string` | 参赛身份 ID |
+| `event_id` | `string` | Event 公开 ID |
+| `event_release_id` | `string` | 实际冻结的发布版 ID |
+| `playfield_version_id` | `string` | 场地版本 ID |
+| `route_code` | `string` | 当前冻结的路线编码 |
+| `realtime_endpoint` | `string` | realtime-center 地址 |
+| `realtime_token` | `string` | 当前版本通常为空 |
+
+当前注意:
+
+- 若前端传入的 `release_id` 已过期,会返回 `EVENT_RELEASE_STALE`
+- 当前后端会冻结并回传 `route_code`,但还没有对 manifest 内路线做严格校验
+
+## 4. 预留未就绪接口
+
+以下接口当前在路由中已占位,但实际会直接返回 `not implemented`:
+
+| 接口 | 当前错误码 |
+| --- | --- |
+| `POST /client/v1/session-uploads` | `session_upload_not_ready` |
+| `POST /client/v1/punches` | `session_punch_not_ready` |
+| `POST /client/v1/sessions/{session_id}/finish` | `session_finish_not_ready` |
+| `GET /client/v1/sessions/{session_id}/result` | `session_result_not_ready` |
+| `GET /client/v1/sessions/{session_id}/replay-summary` | `session_replay_summary_not_ready` |
+| `GET /client/v1/sessions/{session_id}/gps-track` | `session_gps_track_not_ready` |
+| `GET /client/v1/sessions/{session_id}/heart-rate` | `session_heart_rate_not_ready` |
+| `GET /client/v1/me/sessions` | `session_history_not_ready` |
+
+说明:
+
+- 所以当前不是“只有 GPS / 心率上报没测”
+- 而是整条 `session` 下游链路都还没开放
+- 当前已实测闭环停在 `launch` 成功返回 `session_token`
+
+## 5. 当前测试数据
+
+以下数据可直接用于本地联调:
+
+| 类型 | ID |
+| --- | --- |
+| `card_id` | `crd_classic_demo_001` |
+| `competition_id` | `cmp_classic_demo_001` |
+| `map_id` | `lxcb-001` |
+| `event_id` | `sample-classic-001` |
+| `release_id` | `sample-classic-001-rel-1` |
+| `route_code` | `classic-001` |
+
+说明:
+
+- 已存在测试赛事卡片、地图、Event、manifest 绑定关系
+- 已存在一个已批准报名的测试用户,可直接验证赛事入口 `launch`

+ 26 - 7
tools/mock-gps-sim/README.md

@@ -11,12 +11,36 @@ npm run mock-gps-sim
 启动后:
 
 - 新版工作台: `http://127.0.0.1:17865/`
-- 旧版面板: `http://127.0.0.1:17865/v1/`
 - 小程序定位模拟地址: `ws://127.0.0.1:17865/mock-gps`
 - 小程序心率模拟地址: `ws://127.0.0.1:17865/mock-hr`
 - 小程序调试日志地址: `ws://127.0.0.1:17865/debug-log`
 - 资源代理: `http://127.0.0.1:17865/proxy?url=<remote-url>`
 
+## 多通道联调
+
+模拟器现在支持一个最小的多通道隔离方案:
+
+- GPS 模拟消息带 `channelId`
+- 心率模拟消息带 `channelId`
+- 调试日志消息带 `channelId`
+- 小程序端按同一个模拟通道号过滤三条链
+
+默认通道号:
+
+```text
+default
+```
+
+如果需要多人并行联调,可以在模拟器工作台里把“模拟通道号”改成例如:
+
+```text
+runner-a
+runner-b
+group-01
+```
+
+然后在小程序调试面板里把“模拟通道号”也配成同一个值。
+
 ## 当前能力
 
 - 直接载入 `game.json`
@@ -47,6 +71,7 @@ ws://127.0.0.1:17865/debug-log
 {
   "type": "debug-log",
   "timestamp": 1712345678901,
+  "channelId": "runner-a",
   "scope": "gps-logo",
   "level": "info",
   "message": "wx.getImageInfo success",
@@ -89,12 +114,6 @@ ws://127.0.0.1:17865/debug-log
 http://127.0.0.1:17865/
 ```
 
-如果需要旧版稳定界面,打开:
-
-```text
-http://127.0.0.1:17865/v1/
-```
-
 在“新网关桥接”区域可以直接配置:
 
 - 是否启用桥接

+ 40 - 34
tools/mock-gps-sim/public/index.html

@@ -14,10 +14,16 @@
           <div class="wb-topbar__eyebrow">MOCK GPS SIM</div>
           <h1>模拟器工作台</h1>
           <div class="wb-topbar__links">
-            <a href="/v1/">打开旧版面板</a>
+            <div id="socketStatus" class="badge badge--muted">未连接</div>
           </div>
         </div>
         <div class="wb-topbar__status">
+          <div class="wb-topbar__global">
+            <label class="field wb-topbar__field">
+              <span>模拟通道号</span>
+              <input id="simChannelIdInput" type="text" placeholder="default / runner-a">
+            </label>
+          </div>
           <div class="wb-connection-bar">
             <div class="wb-connection-pill">
               <span class="wb-connection-pill__label">定位模拟</span>
@@ -36,29 +42,11 @@
               <strong id="topGatewayStatus" class="wb-connection-pill__value">未启用</strong>
             </div>
           </div>
-          <div id="socketStatus" class="badge badge--muted">未连接</div>
         </div>
       </header>
 
       <div class="wb-layout">
         <aside class="wb-sidebar">
-          <section class="wb-card">
-            <div class="wb-card__title">运行摘要</div>
-            <div class="stat"><span>资源状态</span><strong id="summaryResourceText">未载入</strong></div>
-            <div class="stat"><span>定位发送</span><strong id="summaryGpsSendText">待命</strong></div>
-            <div class="stat"><span>心率发送</span><strong id="summaryHrSendText">待命</strong></div>
-            <div class="stat"><span>路径状态</span><strong id="summaryPathText">待命</strong></div>
-            <div class="stat"><span>网关桥接</span><strong id="summaryGatewayText">未启用</strong></div>
-          </section>
-
-          <section class="wb-card">
-            <div class="wb-card__title">当前位置</div>
-            <div class="stat"><span>纬度</span><strong id="latText">--</strong></div>
-            <div class="stat"><span>经度</span><strong id="lonText">--</strong></div>
-            <div class="stat"><span>航向</span><strong id="headingText">--</strong></div>
-            <div class="stat"><span>路径点</span><strong id="pathCountText">0</strong></div>
-          </section>
-
           <details class="wb-section" open>
             <summary>资源加载</summary>
             <div class="wb-section__body">
@@ -254,15 +242,15 @@
 
         <main class="wb-stage">
           <div id="map"></div>
-            <section id="floatingDebugLogPanel" class="floating-debug-log">
-              <div class="floating-debug-log__header">
-                <div class="floating-debug-log__title-wrap">
-                  <div class="floating-debug-log__title">调试日志</div>
-                  <div id="debugLogMeta" class="floating-debug-log__meta">全部 · 0 条</div>
-                </div>
-                <div class="floating-debug-log__actions">
-                  <label class="floating-debug-log__filter">
-                    <span>范围</span>
+          <section id="floatingDebugLogPanel" class="floating-debug-log">
+            <div class="floating-debug-log__header">
+              <div class="floating-debug-log__title-wrap">
+                <div class="floating-debug-log__title">调试日志</div>
+                <div id="debugLogMeta" class="floating-debug-log__meta">全部 · 0 条</div>
+              </div>
+              <div class="floating-debug-log__actions">
+                <label class="floating-debug-log__filter">
+                  <span>范围</span>
                   <select id="debugLogScopeFilter">
                     <option value="all">全部</option>
                   </select>
@@ -274,13 +262,31 @@
             <div id="debugLog" class="log log--debug log--floating"></div>
           </section>
         </main>
+
+        <aside class="wb-rail">
+          <section class="wb-card">
+            <div class="wb-card__title">运行摘要</div>
+            <div class="stat"><span>资源状态</span><strong id="summaryResourceText">未载入</strong></div>
+            <div class="stat"><span>定位发送</span><strong id="summaryGpsSendText">待命</strong></div>
+            <div class="stat"><span>心率发送</span><strong id="summaryHrSendText">待命</strong></div>
+            <div class="stat"><span>路径状态</span><strong id="summaryPathText">待命</strong></div>
+            <div class="stat"><span>网关桥接</span><strong id="summaryGatewayText">未启用</strong></div>
+          </section>
+
+          <section class="wb-card">
+            <div class="wb-card__title">当前位置</div>
+            <div class="stat"><span>纬度</span><strong id="latText">--</strong></div>
+            <div class="stat"><span>经度</span><strong id="lonText">--</strong></div>
+            <div class="stat"><span>航向</span><strong id="headingText">--</strong></div>
+            <div class="stat"><span>路径点</span><strong id="pathCountText">0</strong></div>
+          </section>
+
+          <section class="wb-card">
+            <div class="wb-card__title">最近事件</div>
+            <div id="log" class="log"></div>
+          </section>
+        </aside>
       </div>
-      <section class="wb-bottom-strip">
-        <section class="wb-card wb-card--bottom">
-          <div class="wb-card__title">最近事件</div>
-          <div id="log" class="log"></div>
-        </section>
-      </section>
     </div>
 
     <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

+ 50 - 2
tools/mock-gps-sim/public/simulator.js

@@ -13,8 +13,14 @@
   ])
   const BRIDGE_CONFIG_STORAGE_KEY = 'mock-gps-sim.bridge-config'
   const BRIDGE_PRESETS_STORAGE_KEY = 'mock-gps-sim.bridge-presets'
+  const SIM_CHANNEL_STORAGE_KEY = 'mock-gps-sim.channel-id'
   const MAX_DEBUG_LOG_LINES = 400
 
+  function normalizeSimChannelId(rawValue) {
+    const trimmed = String(rawValue || '').trim()
+    return trimmed || 'default'
+  }
+
   const map = L.map('map').setView(DEFAULT_CENTER, 16)
   let tileLayer = createTileLayer(DEFAULT_TILE_URL, {
     maxZoom: 20,
@@ -60,6 +66,7 @@
     lastHeartRateSentText: '--',
     lastResourceDetailText: '尚未载入资源',
     lastTrackSourceText: '路径待命',
+    simChannelId: 'default',
     currentLatLng: L.latLng(DEFAULT_CENTER[0], DEFAULT_CENTER[1]),
     headingDeg: 0,
     currentSegmentIndex: 0,
@@ -128,6 +135,7 @@
     trackFileInput: document.getElementById('trackFileInput'),
     importTrackBtn: document.getElementById('importTrackBtn'),
     connectBtn: document.getElementById('connectBtn'),
+    simChannelIdInput: document.getElementById('simChannelIdInput'),
     sendOnceBtn: document.getElementById('sendOnceBtn'),
     streamBtn: document.getElementById('streamBtn'),
     stopStreamBtn: document.getElementById('stopStreamBtn'),
@@ -164,6 +172,7 @@
   }
 
   elements.configUrlInput.value = DEFAULT_CONFIG_URL
+  applySimChannelId(loadSimChannelId(), false)
 
   function createTileLayer(urlTemplate, extraOptions) {
     return L.tileLayer(urlTemplate, Object.assign({
@@ -177,6 +186,33 @@
     elements.log.textContent = `[${time}] ${message}\n` + elements.log.textContent
   }
 
+  function loadSimChannelId() {
+    try {
+      return normalizeSimChannelId(window.localStorage.getItem(SIM_CHANNEL_STORAGE_KEY))
+    } catch (_error) {
+      return 'default'
+    }
+  }
+
+  function saveSimChannelId(channelId) {
+    try {
+      window.localStorage.setItem(SIM_CHANNEL_STORAGE_KEY, normalizeSimChannelId(channelId))
+    } catch (_error) {
+      // noop
+    }
+  }
+
+  function applySimChannelId(channelId, persist) {
+    state.simChannelId = normalizeSimChannelId(channelId)
+    if (elements.simChannelIdInput) {
+      elements.simChannelIdInput.value = state.simChannelId
+    }
+    if (persist) {
+      saveSimChannelId(state.simChannelId)
+    }
+    renderDebugLog()
+  }
+
   function logDebug(entry) {
     if (!elements.debugLog) {
       return
@@ -184,6 +220,7 @@
 
     const normalized = {
       timestamp: entry.timestamp || Date.now(),
+      channelId: normalizeSimChannelId(entry.channelId),
       scope: String(entry.scope || 'app'),
       level: String(entry.level || 'info'),
       message: String(entry.message || ''),
@@ -231,12 +268,15 @@
     }
 
     const filteredEntries = state.debugLogEntries.filter((entry) => {
+      if (normalizeSimChannelId(entry.channelId) !== state.simChannelId) {
+        return false
+      }
       return state.debugLogScopeFilter === 'all' || entry.scope === state.debugLogScopeFilter
     })
 
     if (elements.debugLogMeta) {
       const scopeLabel = state.debugLogScopeFilter === 'all' ? '全部' : state.debugLogScopeFilter
-      elements.debugLogMeta.textContent = `${scopeLabel} · ${filteredEntries.length} 条`
+      elements.debugLogMeta.textContent = `通道 ${state.simChannelId} · ${scopeLabel} · ${filteredEntries.length} 条`
     }
 
     elements.debugLog.textContent = filteredEntries
@@ -244,7 +284,7 @@
         const time = new Date(entry.timestamp || Date.now()).toLocaleTimeString()
         const level = String(entry.level || 'info').toUpperCase()
         const payloadText = entry.payload ? ` ${JSON.stringify(entry.payload)}` : ''
-        return `[${time}] [${entry.scope}] [${level}] ${entry.message}${payloadText}`
+        return `[${time}] [${entry.channelId}] [${entry.scope}] [${level}] ${entry.message}${payloadText}`
       })
       .join('\n')
   }
@@ -1389,6 +1429,7 @@
     const payload = {
       type: 'mock_gps',
       timestamp: Date.now(),
+      channelId: state.simChannelId,
       lat: Number(state.currentLatLng.lat.toFixed(6)),
       lon: Number(state.currentLatLng.lng.toFixed(6)),
       accuracyMeters: getAccuracy(),
@@ -1409,6 +1450,7 @@
     const payload = {
       type: 'mock_heart_rate',
       timestamp: Date.now(),
+      channelId: state.simChannelId,
       bpm: state.heartRateSampleMode ? getSampleHeartRateBpm() : getHeartRateBpm(),
     }
     state.heartRateSocket.send(JSON.stringify(payload))
@@ -1840,6 +1882,12 @@
   })
 
   elements.connectBtn.addEventListener('click', connectSocket)
+  if (elements.simChannelIdInput) {
+    elements.simChannelIdInput.addEventListener('change', () => {
+      applySimChannelId(elements.simChannelIdInput.value, true)
+      log(`已切换模拟通道 ${state.simChannelId}`)
+    })
+  }
   elements.applyGatewayBridgePresetBtn.addEventListener('click', applyBridgePresetToForm)
   elements.saveGatewayBridgePresetBtn.addEventListener('click', saveCurrentBridgePreset)
   elements.deleteGatewayBridgePresetBtn.addEventListener('click', deleteSelectedBridgePreset)

+ 0 - 231
tools/mock-gps-sim/public/v1/index.html

@@ -1,231 +0,0 @@
-<!doctype html>
-<html lang="zh-CN">
-  <head>
-    <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title>Mock GPS Simulator v1</title>
-    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
-    <link rel="stylesheet" href="../style.css">
-  </head>
-  <body>
-    <div class="layout">
-      <aside class="panel">
-        <div class="panel__header">
-          <div class="panel__eyebrow">MOCK GPS SIM</div>
-          <h1>外部模拟器</h1>
-          <div class="panel__links"><a href="/">打开新版工作台</a></div>
-          <div id="socketStatus" class="badge badge--muted">未连接</div>
-        </div>
-
-        <section class="group">
-          <div class="group__title">资源加载</div>
-          <label class="field">
-            <span>游戏配置 URL</span>
-            <input id="configUrlInput" type="text" value="https://oss-mbh5.colormaprun.com/wxmini/test/game.json">
-          </label>
-          <div class="row">
-            <button id="loadConfigBtn" class="btn btn--primary">载入配置</button>
-            <button id="fitCourseBtn" class="btn">适配视野</button>
-          </div>
-          <label class="field">
-            <span>瓦片模板</span>
-            <input id="tileUrlInput" type="text" placeholder="https://host/tiles/{z}/{x}/{y}.webp">
-          </label>
-          <div class="row">
-            <button id="applyTilesBtn" class="btn">应用瓦片</button>
-            <button id="resetTilesBtn" class="btn">恢复 OSM</button>
-          </div>
-          <label class="field">
-            <span>KML URL</span>
-            <input id="courseUrlInput" type="text" placeholder="https://host/course/c01.kml">
-          </label>
-          <div class="row">
-            <button id="loadCourseBtn" class="btn">载入控制点</button>
-            <button id="clearCourseBtn" class="btn">清空控制点</button>
-          </div>
-          <div id="resourceStatus" class="hint">支持直接载入 game.json,也支持单独填瓦片模板和 KML 地址。</div>
-          <div id="resourceDetail" class="group__status">尚未载入资源</div>
-          <div id="courseJumpList" class="jump-list"></div>
-        </section>
-
-        <section class="group">
-          <div class="group__title">实时发送</div>
-          <div id="realtimeStatus" class="group__status">桥接未连接</div>
-          <div id="lastSendStatus" class="group__status">最近发送: --</div>
-          <div class="row">
-            <button id="connectBtn" class="btn btn--primary">连接桥接</button>
-            <button id="sendOnceBtn" class="btn">发送一次</button>
-          </div>
-          <div class="row">
-            <button id="streamBtn" class="btn btn--accent">开始连续发送</button>
-            <button id="stopStreamBtn" class="btn">停止发送</button>
-          </div>
-          <label class="field">
-            <span>发送频率</span>
-            <select id="hzSelect">
-              <option value="2">2 Hz</option>
-              <option value="5" selected>5 Hz</option>
-              <option value="10">10 Hz</option>
-            </select>
-          </label>
-          <label class="field">
-            <span>精度 (m)</span>
-            <input id="accuracyInput" type="number" min="1" max="100" value="6">
-          </label>
-        </section>
-
-        <section class="group">
-          <div class="group__title">新网关桥接</div>
-          <div id="gatewayBridgeStatus" class="group__status">未启用</div>
-          <div id="gatewayBridgeTarget" class="group__status">目标设备: --</div>
-          <div id="gatewayBridgeLast" class="group__status">最近状态: --</div>
-          <label class="field">
-            <span>桥接预设</span>
-            <select id="gatewayBridgePresetSelect">
-              <option value="">选择预设</option>
-            </select>
-          </label>
-          <label class="field">
-            <span>预设名称</span>
-            <input id="gatewayBridgePresetNameInput" type="text" placeholder="例如:家长端-A / 场控-B">
-          </label>
-          <div class="row">
-            <button id="applyGatewayBridgePresetBtn" class="btn">套用预设</button>
-            <button id="saveGatewayBridgePresetBtn" class="btn">保存预设</button>
-          </div>
-          <div class="row">
-            <button id="deleteGatewayBridgePresetBtn" class="btn">删除预设</button>
-          </div>
-          <label class="field field--check">
-            <input id="gatewayBridgeEnabledInput" type="checkbox">
-            <span>启用新网关桥接</span>
-          </label>
-          <label class="field">
-            <span>网关地址</span>
-            <input id="gatewayBridgeUrlInput" type="text" placeholder="ws://127.0.0.1:18080/ws">
-          </label>
-          <label class="field">
-            <span>Producer Token / Channel Token</span>
-            <input id="gatewayBridgeTokenInput" type="text" placeholder="producerToken 或 dev-producer-token">
-          </label>
-          <label class="field">
-            <span>Channel ID</span>
-            <input id="gatewayBridgeChannelIdInput" type="text" placeholder="ch-xxxx">
-          </label>
-          <label class="field">
-            <span>目标 Device ID</span>
-            <input id="gatewayBridgeDeviceIdInput" type="text" placeholder="child-001">
-          </label>
-          <label class="field">
-            <span>目标 Group ID</span>
-            <input id="gatewayBridgeGroupIdInput" type="text" placeholder="class-a">
-          </label>
-          <label class="field">
-            <span>Source ID</span>
-            <input id="gatewayBridgeSourceIdInput" type="text" placeholder="mock-gps-sim">
-          </label>
-          <label class="field">
-            <span>Source Mode</span>
-            <input id="gatewayBridgeSourceModeInput" type="text" placeholder="mock">
-          </label>
-          <div class="row">
-            <button id="applyGatewayBridgeConfigBtn" class="btn btn--primary">应用桥接配置</button>
-            <button id="reloadGatewayBridgeConfigBtn" class="btn">重新读取</button>
-          </div>
-        </section>
-
-        <section class="group">
-          <div class="group__title">心率模拟</div>
-          <div id="heartRateStatus" class="group__status">心率模拟待命</div>
-          <div id="lastHeartRateStatus" class="group__status">最近发送: --</div>
-          <div class="row">
-            <button id="sendHeartRateOnceBtn" class="btn">发送一次</button>
-            <button id="startHeartRateStreamBtn" class="btn btn--accent">开始连续发送</button>
-          </div>
-          <div class="row">
-            <button id="stopHeartRateStreamBtn" class="btn">停止发送</button>
-            <button id="applyHeartRatePresetBtn" class="btn">应用分区样本</button>
-          </div>
-          <div class="row">
-            <button id="toggleHeartRateSampleBtn" class="btn">模拟真实样本</button>
-          </div>
-          <label class="field">
-            <span>心率值 (bpm)</span>
-            <input id="heartRateInput" type="number" min="40" max="220" value="120">
-          </label>
-          <label class="field">
-            <span>发送频率</span>
-            <select id="heartRateHzSelect">
-              <option value="1" selected>1 Hz</option>
-              <option value="2">2 Hz</option>
-              <option value="4">4 Hz</option>
-            </select>
-          </label>
-          <label class="field">
-            <span>样本模板</span>
-            <select id="heartRateSampleTemplateSelect">
-              <option value="jog" selected>慢跑样本</option>
-              <option value="tempo">节奏跑样本</option>
-              <option value="interval">间歇跑样本</option>
-              <option value="recovery">恢复走样本</option>
-            </select>
-          </label>
-        </section>
-
-        <section class="group">
-          <div class="group__title">路径回放</div>
-          <div id="playbackStatus" class="group__status">路径待命</div>
-          <input id="trackFileInput" class="file-input-hidden" type="file" accept=".gpx,.kml,.geojson,.json,application/json,application/gpx+xml,application/vnd.google-earth.kml+xml">
-          <div class="row">
-            <button id="importTrackBtn" class="btn">导入轨迹文件</button>
-            <button id="togglePathModeBtn" class="btn">开启路径编辑</button>
-          </div>
-          <div class="row">
-            <button id="clearPathBtn" class="btn">清空路径</button>
-            <button id="fitPathBtn" class="btn">适配路径</button>
-          </div>
-          <div class="row">
-            <button id="playPathBtn" class="btn btn--accent">开始回放</button>
-            <button id="pausePathBtn" class="btn">暂停回放</button>
-          </div>
-          <label class="field">
-            <span>移动速度 (km/h)</span>
-            <input id="speedInput" type="number" min="1" max="25" step="0.1" value="6">
-          </label>
-          <label class="field field--check">
-            <input id="loopPathInput" type="checkbox" checked>
-            <span>循环回放</span>
-          </label>
-          <div id="pathHint" class="hint">点击“开启路径编辑”后,在地图上逐点添加路径。</div>
-        </section>
-
-        <section class="group">
-          <div class="group__title">当前位置</div>
-          <div class="stat"><span>纬度</span><strong id="latText">--</strong></div>
-          <div class="stat"><span>经度</span><strong id="lonText">--</strong></div>
-          <div class="stat"><span>航向</span><strong id="headingText">--</strong></div>
-          <div class="stat"><span>路径点</span><strong id="pathCountText">0</strong></div>
-        </section>
-
-        <section class="group">
-          <div class="group__title">日志</div>
-          <div id="log" class="log"></div>
-        </section>
-      </aside>
-
-      <main class="map-shell">
-        <div id="map"></div>
-        <section class="floating-debug-log">
-          <div class="floating-debug-log__header">
-            <div class="floating-debug-log__title">调试日志</div>
-            <button id="clearDebugLogBtn" class="floating-debug-log__clear" type="button">清空</button>
-          </div>
-          <div id="debugLog" class="log log--debug log--floating"></div>
-        </section>
-      </main>
-    </div>
-
-    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
-    <script src="../simulator.js"></script>
-  </body>
-  </html>

+ 36 - 14
tools/mock-gps-sim/public/workbench.css

@@ -48,6 +48,7 @@ body {
   display: flex;
   flex-wrap: wrap;
   gap: 14px;
+  align-items: center;
 }
 
 .wb-topbar__links a {
@@ -63,6 +64,26 @@ body {
   gap: 16px;
 }
 
+.wb-topbar__global {
+  min-width: 220px;
+}
+
+.wb-topbar__field {
+  margin-bottom: 0;
+}
+
+.wb-topbar__field span {
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0.06em;
+  color: #5d786c;
+}
+
+.wb-topbar__field input {
+  min-width: 220px;
+  background: rgba(255, 255, 255, 0.92);
+}
+
 .wb-connection-bar {
   display: flex;
   flex-wrap: wrap;
@@ -104,7 +125,7 @@ body {
 .wb-layout {
   min-height: 0;
   display: grid;
-  grid-template-columns: 380px 1fr;
+  grid-template-columns: 380px 1fr 280px;
   gap: 18px;
   padding: 18px;
 }
@@ -115,6 +136,12 @@ body {
   padding-right: 4px;
 }
 
+.wb-rail {
+  min-height: 0;
+  overflow-y: auto;
+  padding-left: 4px;
+}
+
 .wb-stage {
   position: relative;
   min-height: 0;
@@ -123,14 +150,6 @@ body {
   box-shadow: 0 28px 60px rgba(20, 41, 31, 0.18);
 }
 
-.wb-bottom-strip {
-  padding: 0 18px 18px;
-}
-
-.wb-card--bottom .log {
-  max-height: 180px;
-}
-
 #map {
   width: 100%;
   height: 100%;
@@ -515,7 +534,7 @@ body {
 
 @media (max-width: 1380px) {
   .wb-layout {
-    grid-template-columns: 340px 1fr;
+    grid-template-columns: 340px 1fr 250px;
   }
 
   .floating-debug-log {
@@ -526,10 +545,11 @@ body {
 @media (max-width: 1120px) {
   .wb-layout {
     grid-template-columns: 1fr;
-    grid-template-rows: auto minmax(420px, 1fr);
+    grid-template-rows: auto minmax(420px, 1fr) auto;
   }
 
-  .wb-sidebar {
+  .wb-sidebar,
+  .wb-rail {
     max-height: 32vh;
   }
 
@@ -549,7 +569,9 @@ body {
     align-items: flex-start;
   }
 
-  .wb-bottom-strip {
-    padding-top: 18px;
+  .wb-topbar__global,
+  .wb-topbar__field input {
+    width: 100%;
+    min-width: 0;
   }
 }