这篇文章记录一个机器人导览平台 MVP 的技术设计与实现过程。
平台要解决的不是单一页面问题,而是一条完整链路:
导入机器人地图包
→ 在 Web 端编辑点位和区域
→ 编排导览流程
→ 生成机器人可执行的任务链
→ 上传并触发机器人执行
→ 实时展示导览进度
早期设计中混合过几种不同方案,例如由平台主动连接机器人、由机器人主动连接平台,以及不同的状态管理方式。最终实现时,我把地图数据、业务流程和机器人协议拆成了三个相对独立的层次,避免页面逻辑直接依赖机器人通信细节。
一、整体架构选型
前端
-
核心框架:React 19 + TypeScript
前后端都使用 TypeScript,便于统一接口模型和动作类型。 -
UI 组件库:Ant Design
适合管理后台和复杂表单,可以快速搭建地图列表、流程编排、机器人档案等页面。 -
地图渲染:Konva.js / react-konva
Canvas 2D 更适合地图编辑器。底图、路径、点位和区域可以分层渲染,并支持缩放、平移、框选与拖拽。 -
本地编辑状态:Redux Toolkit
用于保存地图编辑中的点位、区域、选择状态和交互模式。 -
服务端状态:TanStack Query
负责接口请求、缓存、刷新和 Mutation 状态,避免把服务端数据重复塞进本地 Store。 -
路由与构建:React Router + Vite
后端
-
框架:NestJS + TypeScript
模块边界清晰,也方便同时承载 REST API、原生 WebSocket 和 Socket.IO。 -
ORM:Prisma
-
数据库:PostgreSQL
地图、点位、流程、步骤和执行记录之间存在明确的关系,关系型数据库更适合维护一致性。
分层后的职责
前端编辑器
└─ 负责交互和可视化
平台业务后端
├─ 地图与流程数据
├─ 任务链生成
└─ 执行状态管理
机器人协议适配层
├─ SSH / SFTP
├─ HTTP 控制命令
└─ 原生 WebSocket 状态
这样做的好处是:即使后续更换机器人协议,地图编辑和流程编排也不需要整体重写。
二、地图包导入
1. 输入不是一张图片,而是一个地图包
机器人地图通常以 ZIP 包交付,里面至少包含:
- 路网描述文件;
- 一张或多张定位底图;
- 与底图对应的 YAML 元数据;
- 路网节点和路径信息。
因此,导入流程不是简单地上传 PNG,而是:
选择 ZIP
→ 后端解压
→ 找到路网描述文件
→ 确定默认定位地图
→ 找到对应 PNG 和 YAML
→ 读取底图尺寸、resolution 和 origin
→ 导入节点与路径
→ 保存地图元数据
如果地图包中有多个定位地图,优先使用调用方指定的定位地图;没有指定时,使用默认项,再兜底到第一项。
伪代码如下:
function resolveLocationMapFiles(
packageRoot: string,
roadnet: RoadNet,
locationMapId?: string,
) {
const info = locationMapId
? roadnet.locationMaps.find(item => item.id === locationMapId)
: roadnet.locationMaps.find(item => item.isDefault)
?? roadnet.locationMaps[0]
return {
imagePath: resolve(packageRoot, info.image),
yamlPath: resolve(packageRoot, info.yaml),
locationMapId: info.id,
}
}
2. 地图元数据
YAML 中真正影响平台显示和坐标换算的核心信息只有:
resolution:一个像素对应现实世界中的多少米;origin:地图原点在世界坐标系中的偏移;- 图片真实宽高。
平台将这些信息保存为地图元数据:
{
"resolution": 0.03,
"origin": [-3.5, 0, 0],
"widthPx": 1433,
"heightPx": 1218,
"locationMapId": "default-location-map"
}
底图文件放入后端静态资源目录,前端通过地图详情接口获取 imageUrl。
3. 节点坐标转换
路网中的节点通常是世界坐标,而 Web 画布使用图片像素坐标。导入时必须统一转换:
pixelX = (worldX - originX) / resolution
pixelY = imageHeight - (worldY - originY) / resolution
Y 轴需要翻转,因为机器人世界坐标通常向上为正,而图片坐标向下为正。
所有点位、路径控制点和后续区域编辑都必须复用同一套坐标转换函数。坐标换算如果散落在不同组件中,后续很容易出现路径偏移、点位对不上底图等问题。
4. 路径导入
导入一条路径时,需要:
- 找到起点和终点对应的节点;
- 选择与当前定位地图匹配的节点坐标;
- 将起点、终点和控制点统一转换为像素坐标;
- 保存路径类型和控制点。
三次贝塞尔曲线可以直接保存:
[起点, 控制点 1, 控制点 2, 终点]
前端读取路径类型后,用 Konva 的线或曲线组件叠加到底图上。
三、地图编辑器
地图编辑页面采用左右布局:
- 左侧是地图画布;
- 右侧是点位或区域的编辑面板。
画布按以下层次渲染:
Stage
├─ 底图层
├─ 路径层
├─ 区域层
└─ 点位层
1. 缩放和平移
Konva 的 Stage 负责整体缩放和平移。滚轮缩放时,要保证鼠标指向的地图位置保持不变:
const mousePointTo = {
x: (pointer.x - stage.x()) / oldScale,
y: (pointer.y - stage.y()) / oldScale,
}
stage.scale({ x: newScale, y: newScale })
stage.position({
x: pointer.x - mousePointTo.x * newScale,
y: pointer.y - mousePointTo.y * newScale,
})
当用户点击画布时,还要把屏幕坐标反算为底图原始像素坐标:
pixelX = (screenX - stageX) / scaleX
pixelY = (screenY - stageY) / scaleY
最终结果需要裁剪在底图范围内,避免在地图外创建点位。
2. 区域框选
区域使用一维坐标数组保存:
[x1, y1, x2, y2, x3, y3, x4, y4]
矩形框选过程为:
mousedown记录起点;mousemove根据当前坐标绘制临时矩形;mouseup固定四个角点;- 写入前端编辑状态;
- 保存时统一提交后端。
选中区域后,拖动区域主体可以整体平移。实现时不单独移动某个顶点,而是把拖拽偏移量应用到所有坐标。
3. 点位配置
点击点位后,右侧面板可以编辑:
- 点位名称;
- 主点或辅助点;
- 默认讲解文本。
点位中的默认讲解文本属于地图业务数据,不属于某一条具体流程。流程步骤可以直接复用,也可以在编排时覆盖。
4. 保存与导出
保存地图时,前端一次提交点位和区域:
{
"points": [
{
"nodeId": "example-point",
"name": "入口迎宾",
"type": "main",
"script": "欢迎各位来宾"
}
],
"areas": [
{
"id": "area-1",
"name": "迎宾区",
"points": [40, 220, 160, 220, 160, 320, 40, 320]
}
]
}
平台同时保存数据库数据和地图包旁的业务配置文件。导出时:
- 用数据库中的最新数据刷新业务配置文件;
- 保留原始路网文件,不进行重写;
- 将原地图目录、底图、YAML 和业务配置重新打包为 ZIP。
这样既能在平台中编辑,也能让地图包继续作为独立交付物使用。
四、流程编排
地图编辑解决“有哪些点”,流程编排解决“按什么顺序去这些点,以及到点后做什么”。
流程编排独立于地图编辑页面,采用左右两栏:
- 左侧:流程列表;
- 右侧:步骤列表和动作配置。
1. 流程和步骤
核心关系如下:
Map
└─ Flow
└─ FlowPoint
└─ Point
Flow 保存流程名称、所属地图和任务链标识。
FlowPoint 保存:
- 所属流程;
- 对应点位;
- 执行顺序
sequence; - 动作配置;
- 是否已完成配置。
2. 添加与排序
添加点位时,不直接在地图上点击,而是打开点位选择弹窗:
- 展示当前地图的全部点位;
- 已经加入流程的点位禁用;
- 用户勾选点位;
- 按选择顺序追加到步骤末尾。
同一个点位在同一流程中只允许出现一次。
调整步骤顺序后,后端重新写入连续的 sequence:
调整前:P1=1, P3=2, P5=3
将 P5 上移后:P1=1, P5=2, P3=3
序号连续比保留间隔值更简单,也便于机器人状态映射回当前步骤。
3. 动作配置
每个步骤卡片直接配置动作,MVP 支持:
- 迎宾播报;
- 讲解播报;
- 肢体动作;
- 固定等待。
动作使用 JSON 保存:
{
"actionKind": "lecture",
"text": "这里是产品展示区"
}
固定等待:
{
"actionKind": "wait",
"seconds": 5
}
讲解动作未填写文本时,默认使用点位上的讲解文本;步骤中填写了文本时,以步骤配置为准。
五、从流程生成机器人任务链
流程数据是面向业务人员的,机器人执行文件是面向协议的。两者不应该共用同一种数据结构。
因此,平台在导出或启动导览时,把流程“编译”为机器人任务链。
1. 展开规则
每个流程步骤展开为:
移动到点位
→ 执行该点位配置的动作
例如:
步骤 1:点位 A + 迎宾
步骤 2:点位 B + 讲解
步骤 3:点位 C + 等待
会生成:
N1 move → A
N2 action → 迎宾
N3 move → B
N4 action → 讲解
N5 move → C
N6 sleep → 等待
2. 移动节点
移动节点使用地图路网中的原始节点 ID:
{
"nodeType": "move_action",
"nodeIdStr": "N1",
"nodes": [
{ "nodeId": "example-roadnet-point" }
]
}
平台 UUID 只用于数据库关联,不能直接下发给机器人。
3. 动作节点
播报和肢体动作转换为脚本执行节点。固定等待可以转换为机器人支持的原生等待节点;如果需要外部跳过,则转换为可被外部停止的等待脚本。
动作映射集中维护:
迎宾播报 → 迎宾脚本 + 文本参数
讲解播报 → 讲解脚本 + 文本参数
肢体动作 → 动作脚本 + 动作资产
固定等待 → 原生等待或可中断等待脚本
不要在页面组件中拼接机器人协议 JSON。前端只保存业务动作,协议转换统一放在后端。
4. 任务链树
全部节点按顺序挂在一个 sequence 下:
root
└─ sequence
├─ N1 move
├─ N2 action
├─ N3 move
├─ N4 action
└─ ...
核心伪代码:
function exportTaskChain(flow: FlowWithSteps) {
const nodes = {}
const children: string[] = []
let index = 1
for (const step of flow.steps.sort(bySequence)) {
const moveId = `N${index++}`
const actionId = `N${index++}`
nodes[moveId] = buildMoveNode(moveId, step.point.nodeId)
nodes[actionId] = buildActionNode(actionId, step.action, step.point.script)
children.push(moveId, actionId)
}
return buildSequenceTaskChain(flow, children, nodes)
}
六、机器人接入与任务下发
平台预先维护机器人档案,包括:
- 机器人名称和地址;
- SSH / SFTP 连接信息;
- 机器人端任务链目录;
- 控制服务配置;
- 设备唯一标识;
- 当前地图和运行状态。
启动导览时,后端按顺序执行:
校验机器人状态
→ 原子占用机器人
→ 生成任务链
→ 创建执行记录
→ SFTP 上传任务链
→ 通知机器人重新加载任务链目录
→ 触发指定任务链
→ 返回 executionId
1. 启动前校验
至少需要校验:
- 机器人已启用;
- 机器人在线;
- 当前状态为空闲;
- 电量达到最低要求;
- 机器人当前地图与流程地图一致。
机器人占用必须是原子操作,不能先查询再更新,否则两个并发请求可能同时启动同一台机器人。
2. 两种通信方向
平台与机器人之间有两类通信:
平台主动调用机器人
- SFTP 上传任务链;
- HTTP 发送重新加载和执行命令。
机器人主动连接平台
- 原生 WebSocket 鉴权;
- 心跳;
- 状态上报。
这两个方向不能混在一起理解。HTTP 控制成功只表示命令已被接受,机器人真实状态仍应以后续状态上报为准。
3. 失败回滚边界
在触发任务链之前失败,平台可以安全地:
- 将执行记录标记为失败;
- 把机器人状态恢复为空闲。
一旦已经尝试触发机器人,就不能简单回滚为“未执行”。因为网络超时并不代表机器人没有收到命令,机器人端可能已经开始运行。
七、实时执行进度
实时状态链路分成两段:
机器人
└─ 原生 WebSocket
└─ 平台状态解析
└─ 更新执行记录
└─ Socket.IO
└─ 浏览器页面
1. 机器人到平台
机器人主动连接平台的协议端点,并持续发送:
- 鉴权信息;
- 心跳;
- 电量与运行状态;
- 当前或最近经过的路网节点;
- 动作执行状态。
平台把连接与机器人档案绑定,并更新在线状态和最后活跃时间。
2. 路网节点映射为流程步骤
机器人上报的是路网节点 ID,而前端需要的是“当前第几步”。
映射过程为:
lastNodeId
→ 在当前地图中找到 Point
→ 在当前 Flow 中找到 FlowPoint
→ 取得 sequence
→ 更新 TourExecution.currentSequence
3. 平台到浏览器
浏览器拿到 executionId 后,订阅对应房间:
socket.emit('execution:subscribe', { executionId })
后端将连接加入:
execution:<executionId>
订阅成功后立即返回一次当前快照,而不是等待下一条机器人状态。这样即使页面刷新或订阅较晚,也能恢复当前进度。
快照可以保持很小:
{
"type": "execution_state",
"executionId": "example-execution",
"status": "running",
"lastNodeId": "example-node",
"currentSequence": 2,
"updatedAt": "2026-01-01T10:20:35Z"
}
前端收到后,只需要根据 currentSequence 高亮对应步骤。
4. 导览完成
机器人上报任务完成后,后端:
- 将执行记录更新为
completed; - 写入结束时间;
- 将机器人恢复为空闲;
- 推送最后一条执行快照;
- 前端停止高亮动画并显示完成状态。
八、核心数据模型
Map
保存:
- 地图名称;
- 地图业务 ID;
- 原始包与解压目录;
- 底图 URL;
- 分辨率、原点、图片尺寸等元数据。
Point
保存:
- 平台 UUID;
- 所属地图;
- 路网原始节点 ID;
- 像素坐标;
- 点位名称、类型和默认讲解文本。
MapPath
保存:
- 路网原始边 ID;
- 起点和终点;
- 直线或曲线类型;
- 像素坐标下的控制点。
Area
保存:
- 所属地图;
- 区域名称;
- 多边形坐标;
- 可选显示颜色。
Flow
保存:
- 所属地图;
- 流程名称和描述;
- 任务链标识;
- 流程状态。
FlowPoint
保存:
- 所属流程;
- 对应点位;
- 连续的步骤序号;
- 动作 JSON;
- 配置状态。
TourExecution
保存:
- 机器人和流程;
- 任务链标识;
- 当前状态;
- 最近节点;
- 当前步骤;
- 开始、更新时间和结束时间。
九、主要接口边界
对外接口按业务能力划分,而不是按页面划分:
地图
POST /maps/upload
GET /maps
GET /maps/{id}
PUT /maps/{id}/tour
GET /maps/{id}/export
流程
POST /flows
POST /flows/{id}/points
PUT /flows/{id}/reorder
PUT /flows/{id}/steps/{stepId}
POST /flows/{id}/export-taskchain
机器人
POST /robots
POST /robots/{id}/request-connection
POST /robots/start-tour
执行
GET /executions/{id}
WebSocket subscribe execution:{executionId}
具体部署路径、主机地址和端口都通过环境配置提供,不写死在前端或业务代码中。
十、实现过程中最重要的几个决策
1. 地图原始数据与业务数据分离
路网文件描述机器人可行走的基础地图,点位名称、讲解文本和区域属于导览业务配置。平台不重写原始路网,只增加独立的业务配置文件。
2. 流程动作与机器人协议分离
流程保存的是 lecture / arm / wait 这类业务动作,后端在导出时再转换为机器人协议。这样流程编辑器不需要理解复杂的协议字段。
3. 统一坐标转换
节点、路径控制点、鼠标点击和区域编辑必须使用同一套世界坐标与像素坐标转换。坐标转换应作为共享工具,而不是散落在组件里。
4. 状态上报才是真实状态
HTTP 命令返回成功不等于机器人已经执行完成。平台可以记录“命令已下发”,但运行中、完成或失败应以后续状态帧为准。
5. 订阅后立即发快照
实时系统不能只依赖增量消息。新订阅者先拿当前快照,再接收后续更新,才能正确处理刷新、断线重连和晚订阅。
6. 控制链路和展示链路分开
机器人通过原生 WebSocket 与平台通信,浏览器通过 Socket.IO 订阅平台状态。平台负责协议翻译,浏览器不直接连接机器人。
十一、MVP 范围与后续方向
MVP 先完成以下闭环:
- 地图包导入与解析;
- 点位、路径和区域展示;
- 点位与区域编辑;
- 流程创建、添加点位和排序;
- 步骤动作配置;
- 任务链生成与上传;
- 机器人启动与状态跟踪;
- 前端实时高亮当前步骤。
后续可以继续扩展:
- 动作资产缩略图与预览;
- 可被外部跳过的等待动作;
- 有限或无限循环导览;
- 未连接机器人时的流程仿真;
- 对话服务随导览自动启停;
- 更完整的失败恢复和状态轮询兜底;
- 多机器人调度。
总结
这个 MVP 最难的地方并不是某一个页面,而是让四种数据在同一条链路中保持一致:
机器人路网坐标
→ Web 地图像素坐标
→ 业务流程步骤
→ 机器人任务链节点
最终架构中,地图负责空间数据,流程负责业务顺序,任务链生成器负责协议转换,执行模块负责机器人状态。把这几个边界拆清楚后,地图编辑、流程编排和机器人接入才能独立演进。
部分协议字段和真机行为仍需要以目标机器人实际版本为准,尤其是注册鉴权、动作完成状态和异常恢复边界。对这类外部协议,代码中的抽象应保持稳定,具体适配则集中在机器人协议层。