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

This commit is contained in:
2026-04-01 13:12:39 +08:00
parent 3ef841ecc7
commit 175a16001e
14 changed files with 1695 additions and 315 deletions

333
doc/MyToDo.md Normal file
View File

@@ -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模拟器加个日志输出功能把调试期间不方便打在调试面板里的信息输出到模拟器上你觉得如何这样更方便后期调试如果可以先给个方案

View File

@@ -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. 一句话总结
线上系统负责“把用户送进正确的一局游戏”,配置系统负责“定义这局游戏是什么”。

View File

@@ -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` 处理。
## 适用场景
- 两台手机同时接同一台本地模拟器服务
- 一个调试人员同时开多台模拟器页面
- 同时联调多个儿童设备
## 当前边界
这套最小方案只解决:
- 多路数据隔离
不解决:
- 房间管理
- 成员列表
- 批量启动/停止
- 同步起跑
- 多控制台协作
如果后面真的需要这些,再升级到房间模型。

View File

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

View File

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

View File

@@ -10,11 +10,13 @@
## 当前主文档 ## 当前主文档
- [模拟器控制面板重构方案](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md) - [模拟器控制面板重构方案](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md)
用于说明新版模拟器工作台布局、新旧并行策略和重构目标。 用于说明新版模拟器工作台布局和重构目标。
- [平台能力说明](/D:/dev/cmr-mini/doc/debug/平台能力说明.md) - [平台能力说明](/D:/dev/cmr-mini/doc/debug/平台能力说明.md)
用于记录主体能力、`web-view`、传感器等平台边界。 用于记录主体能力、`web-view`、传感器等平台边界。
- [模拟器调试日志方案](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md) - [模拟器调试日志方案](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md)
用于说明 mock simulator 的日志旁路与 `debug-log` 协议。 用于说明 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)
用于看当前已确认的传感器状态与调试结论。 用于看当前已确认的传感器状态与调试结论。
- [罗盘问题记录](/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) 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) 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) 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)
## 使用建议 ## 使用建议
- 看“当前限制”和“为什么会这样”,优先看平台能力说明。 - 看“当前限制”和“为什么会这样”,优先看平台能力说明。
- 看“现在系统是什么状态”,优先看传感器现状总结。 - 看“现在系统是什么状态”,优先看传感器现状总结。
- 看“以后日志怎么打”,优先看模拟器日志方案。 - 看“以后日志怎么打”,优先看模拟器日志方案。
- 看“多人联调怎么隔离”,优先看模拟器多通道联调最小方案。
- 看“为什么罗盘以前坏过”,再去看罗盘问题记录。 - 看“为什么罗盘以前坏过”,再去看罗盘问题记录。

View File

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

View File

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

694
tmp/Client-API.md Normal file
View File

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

View File

@@ -11,12 +11,36 @@ npm run mock-gps-sim
启动后: 启动后:
- 新版工作台: `http://127.0.0.1:17865/` - 新版工作台: `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-gps`
- 小程序心率模拟地址: `ws://127.0.0.1:17865/mock-hr` - 小程序心率模拟地址: `ws://127.0.0.1:17865/mock-hr`
- 小程序调试日志地址: `ws://127.0.0.1:17865/debug-log` - 小程序调试日志地址: `ws://127.0.0.1:17865/debug-log`
- 资源代理: `http://127.0.0.1:17865/proxy?url=<remote-url>` - 资源代理: `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` - 直接载入 `game.json`
@@ -47,6 +71,7 @@ ws://127.0.0.1:17865/debug-log
{ {
"type": "debug-log", "type": "debug-log",
"timestamp": 1712345678901, "timestamp": 1712345678901,
"channelId": "runner-a",
"scope": "gps-logo", "scope": "gps-logo",
"level": "info", "level": "info",
"message": "wx.getImageInfo success", "message": "wx.getImageInfo success",
@@ -89,12 +114,6 @@ ws://127.0.0.1:17865/debug-log
http://127.0.0.1:17865/ http://127.0.0.1:17865/
``` ```
如果需要旧版稳定界面,打开:
```text
http://127.0.0.1:17865/v1/
```
在“新网关桥接”区域可以直接配置: 在“新网关桥接”区域可以直接配置:
- 是否启用桥接 - 是否启用桥接

View File

@@ -14,10 +14,16 @@
<div class="wb-topbar__eyebrow">MOCK GPS SIM</div> <div class="wb-topbar__eyebrow">MOCK GPS SIM</div>
<h1>模拟器工作台</h1> <h1>模拟器工作台</h1>
<div class="wb-topbar__links"> <div class="wb-topbar__links">
<a href="/v1/">打开旧版面板</a> <div id="socketStatus" class="badge badge--muted">未连接</div>
</div> </div>
</div> </div>
<div class="wb-topbar__status"> <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-bar">
<div class="wb-connection-pill"> <div class="wb-connection-pill">
<span class="wb-connection-pill__label">定位模拟</span> <span class="wb-connection-pill__label">定位模拟</span>
@@ -36,29 +42,11 @@
<strong id="topGatewayStatus" class="wb-connection-pill__value">未启用</strong> <strong id="topGatewayStatus" class="wb-connection-pill__value">未启用</strong>
</div> </div>
</div> </div>
<div id="socketStatus" class="badge badge--muted">未连接</div>
</div> </div>
</header> </header>
<div class="wb-layout"> <div class="wb-layout">
<aside class="wb-sidebar"> <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> <details class="wb-section" open>
<summary>资源加载</summary> <summary>资源加载</summary>
<div class="wb-section__body"> <div class="wb-section__body">
@@ -254,15 +242,15 @@
<main class="wb-stage"> <main class="wb-stage">
<div id="map"></div> <div id="map"></div>
<section id="floatingDebugLogPanel" class="floating-debug-log"> <section id="floatingDebugLogPanel" class="floating-debug-log">
<div class="floating-debug-log__header"> <div class="floating-debug-log__header">
<div class="floating-debug-log__title-wrap"> <div class="floating-debug-log__title-wrap">
<div class="floating-debug-log__title">调试日志</div> <div class="floating-debug-log__title">调试日志</div>
<div id="debugLogMeta" class="floating-debug-log__meta">全部 · 0 条</div> <div id="debugLogMeta" class="floating-debug-log__meta">全部 · 0 条</div>
</div> </div>
<div class="floating-debug-log__actions"> <div class="floating-debug-log__actions">
<label class="floating-debug-log__filter"> <label class="floating-debug-log__filter">
<span>范围</span> <span>范围</span>
<select id="debugLogScopeFilter"> <select id="debugLogScopeFilter">
<option value="all">全部</option> <option value="all">全部</option>
</select> </select>
@@ -274,13 +262,31 @@
<div id="debugLog" class="log log--debug log--floating"></div> <div id="debugLog" class="log log--debug log--floating"></div>
</section> </section>
</main> </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> </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> </div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

View File

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

View File

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

View File

@@ -48,6 +48,7 @@ body {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 14px; gap: 14px;
align-items: center;
} }
.wb-topbar__links a { .wb-topbar__links a {
@@ -63,6 +64,26 @@ body {
gap: 16px; 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 { .wb-connection-bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -104,7 +125,7 @@ body {
.wb-layout { .wb-layout {
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-columns: 380px 1fr; grid-template-columns: 380px 1fr 280px;
gap: 18px; gap: 18px;
padding: 18px; padding: 18px;
} }
@@ -115,6 +136,12 @@ body {
padding-right: 4px; padding-right: 4px;
} }
.wb-rail {
min-height: 0;
overflow-y: auto;
padding-left: 4px;
}
.wb-stage { .wb-stage {
position: relative; position: relative;
min-height: 0; min-height: 0;
@@ -123,14 +150,6 @@ body {
box-shadow: 0 28px 60px rgba(20, 41, 31, 0.18); 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 { #map {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -515,7 +534,7 @@ body {
@media (max-width: 1380px) { @media (max-width: 1380px) {
.wb-layout { .wb-layout {
grid-template-columns: 340px 1fr; grid-template-columns: 340px 1fr 250px;
} }
.floating-debug-log { .floating-debug-log {
@@ -526,10 +545,11 @@ body {
@media (max-width: 1120px) { @media (max-width: 1120px) {
.wb-layout { .wb-layout {
grid-template-columns: 1fr; 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; max-height: 32vh;
} }
@@ -549,7 +569,9 @@ body {
align-items: flex-start; align-items: flex-start;
} }
.wb-bottom-strip { .wb-topbar__global,
padding-top: 18px; .wb-topbar__field input {
width: 100%;
min-width: 0;
} }
} }