feat: enhance WebSocket service with heartbeat and reconnection logic; update environment configuration for development

This commit is contained in:
徐旦 2025-06-19 22:04:57 +08:00
commit d9b80e9719
16 changed files with 5720 additions and 20 deletions

View File

@ -1,3 +1,7 @@
ENV_APP_TITLE=运输控制系统(开发)
# ENV_HTTP_BASE=/mocks
ENV_WEBSOCKET_BASE=/ws
ENV_WEBSOCKET_BASE=/ws
# 开发环境token配置 - 可以手动设置或从另一个项目获取后填入
ENV_DEV_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTAzMzkwMTcsInVzZXJuYW1lIjoiYWRtaW4ifQ.uGWMIPH9-sdyEwr0bQBMKQSTAjYBZhlIVDRHGtheENE
ENV_DEV_TENANT_ID=1000

440
Canvas绘制技术详解.md Normal file
View File

@ -0,0 +1,440 @@
# Canvas 2D 绘制技术详解
## 📖 概述
本文档详细分析场景编辑器中的自定义绘制函数,这些函数基于 **HTML5 Canvas 2D API** 在浏览器页面上绘制各种图形元素(点位、路线、区域、机器人)。
## 🎯 核心技术栈
### 1. HTML5 Canvas 2D API
- **技术原理**Canvas 是 HTML5 提供的位图绘制 API
- **绘制方式**:使用 JavaScript 在 Canvas 画布上逐像素绘制
- **坐标系统**:左上角为原点 (0,0)X轴向右Y轴向下
- **绘制上下文**:通过 `CanvasRenderingContext2D` 对象进行所有绘制操作
### 2. Meta2D 引擎集成
- **自定义绘制**:通过 `registerCanvasDraw()` 注册自定义绘制函数
- **图形对象**:每个绘制函数接收 `MapPen` 对象,包含图形的所有属性
- **渲染时机**:引擎在每次重绘时自动调用对应的绘制函数
---
## 🎨 绘制函数详细分析
### 1. 点位绘制函数 `drawPoint()`
#### 函数签名和参数
```typescript
function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void;
```
#### 代码逐行分析
```typescript
// 1. 获取全局主题配置
const theme = sTheme.editor;
```
**分析**:从全局主题服务获取编辑器主题配置,用于确定颜色、样式等视觉属性。
```typescript
// 2. 从计算属性中提取绘制参数
const { active, iconSize: r = 0, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {};
```
**分析**
- `active`:图形是否处于选中状态
- `iconSize`:图标大小,重命名为 `r`(半径)
- `fontSize/lineHeight/fontFamily`:文本绘制参数
```typescript
// 3. 获取世界坐标系下的矩形区域
const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {};
```
**分析**Meta2D 引擎会自动计算图形在世界坐标系下的实际位置和大小。
```typescript
// 4. 获取业务属性
const { type } = pen.point ?? {};
const { label = '' } = pen ?? {};
```
```typescript
// 5. 保存当前画布状态
ctx.save();
```
**分析**`save()` 保存当前的绘制状态(变换矩阵、样式等),避免影响其他图形。
#### 小点位绘制类型1-9
```typescript
switch (type) {
case MapPointType.普通点:
case MapPointType.等待点:
case MapPointType.避让点:
case MapPointType.临时避让点:
// 绘制圆角菱形
ctx.beginPath();
ctx.moveTo(x + w / 2 - r, y + r);
ctx.arcTo(x + w / 2, y, x + w - r, y + h / 2 - r, r);
ctx.arcTo(x + w, y + h / 2, x + w / 2 + r, y + h - r, r);
ctx.arcTo(x + w / 2, y + h, x + r, y + h / 2 + r, r);
ctx.arcTo(x, y + h / 2, x + r, y + h / 2 - r, r);
ctx.closePath();
```
**分析**
- `beginPath()`:开始新的绘制路径
- `moveTo()`:移动画笔到起始点
- `arcTo()`:绘制圆弧连接线,创建圆角效果
- `closePath()`:闭合路径形成完整图形
```typescript
// 填充背景色
ctx.fillStyle = get(theme, `point-s.fill-${type}`) ?? '';
ctx.fill();
// 绘制边框
ctx.strokeStyle = get(theme, active ? 'point-s.strokeActive' : 'point-s.stroke') ?? '';
```
**分析**:根据点位类型和激活状态设置不同的填充色和边框色。
#### 临时避让点特殊标记
```typescript
if (type === MapPointType.临时避让点) {
ctx.lineCap = 'round'; // 设置线条端点为圆形
ctx.beginPath();
// 绘制8个短线标记形成放射状效果
ctx.moveTo(x + 0.66 * r, y + h / 2 - 0.66 * r);
ctx.lineTo(x + r, y + h / 2 - r);
// ... 其他7个方向的短线
}
```
**分析**在菱形的8个方向绘制短线形成特殊的视觉标识。
#### 大点位绘制类型11+
```typescript
case MapPointType.电梯点:
case MapPointType.自动门点:
case MapPointType.充电点:
case MapPointType.停靠点:
case MapPointType.动作点:
case MapPointType.禁行点:
ctx.roundRect(x, y, w, h, r); // 绘制圆角矩形
ctx.strokeStyle = get(theme, active ? 'point-l.strokeActive' : 'point-l.stroke') ?? '';
ctx.stroke();
```
**分析**:大点位使用圆角矩形,通过 `roundRect()` API 一次性绘制。
#### 文本标签绘制
```typescript
// 设置文本样式
ctx.fillStyle = get(theme, 'color') ?? '';
ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`;
ctx.textAlign = 'center'; // 水平居中
ctx.textBaseline = 'top'; // 垂直顶部对齐
// 在点位上方绘制标签
ctx.fillText(label, x + w / 2, y - fontSize * lineHeight);
```
```typescript
// 恢复画布状态
ctx.restore();
```
---
### 2. 路线绘制函数 `drawLine()`
#### 核心绘制逻辑
```typescript
// 1. 获取路线的两个端点坐标
const [p1, p2] = pen.calculative?.worldAnchors ?? [];
const { x: x1 = 0, y: y1 = 0 } = p1 ?? {};
const { x: x2 = 0, y: y2 = 0 } = p2 ?? {};
```
```typescript
// 2. 获取路线属性
const { type, direction = 1, pass = 0, c1, c2 } = pen.route ?? {};
const { x: dx1 = 0, y: dy1 = 0 } = c1 ?? {}; // 控制点1偏移
const { x: dx2 = 0, y: dy2 = 0 } = c2 ?? {}; // 控制点2偏移
```
#### 路线类型绘制
```typescript
ctx.moveTo(x1, y1); // 移动到起点
switch (type) {
case MapRouteType.直线:
ctx.lineTo(x2, y2); // 直接连线到终点
break;
case MapRouteType.二阶贝塞尔曲线:
// 使用一个控制点绘制曲线
ctx.quadraticCurveTo(x1 + dx1 * s, y1 + dy1 * s, x2, y2);
break;
case MapRouteType.三阶贝塞尔曲线:
// 使用两个控制点绘制更复杂的曲线
ctx.bezierCurveTo(x1 + dx1 * s, y1 + dy1 * s, x2 + dx2 * s, y2 + dy2 * s, x2, y2);
break;
}
```
**贝塞尔曲线原理**
- **二阶贝塞尔曲线**:由起点、一个控制点、终点定义的曲线
- **三阶贝塞尔曲线**:由起点、两个控制点、终点定义的更灵活曲线
- **数学公式**:基于参数方程计算曲线上的每个点
#### 禁行路线绘制
```typescript
if (pass === MapRoutePassType.禁行) {
ctx.setLineDash([s * 5]); // 设置虚线样式
}
ctx.stroke(); // 绘制路线
```
#### 方向箭头绘制
```typescript
// 1. 计算箭头角度
let r = (() => {
switch (type) {
case MapRouteType.直线:
return Math.atan2(y2 - y1, x2 - x1); // 直线的角度
case MapRouteType.二阶贝塞尔曲线:
// 根据控制点计算切线角度
return direction < 0 ? Math.atan2(dy1 * s, dx1 * s) : Math.atan2(y2 - y1 - dy1 * s, x2 - x1 - dx1 * s);
// ...
}
})();
```
```typescript
// 2. 移动坐标系到箭头位置
if (direction < 0) {
ctx.translate(x1, y1); // 反向箭头在起点
} else {
ctx.translate(x2, y2); // 正向箭头在终点
r += Math.PI; // 旋转180度
}
// 3. 绘制箭头(两条线段形成尖角)
ctx.moveTo(Math.cos(r + Math.PI / 5) * s * 10, Math.sin(r + Math.PI / 5) * s * 10);
ctx.lineTo(0, 0);
ctx.lineTo(Math.cos(r - Math.PI / 5) * s * 10, Math.sin(r - Math.PI / 5) * s * 10);
```
**箭头绘制原理**
- 使用三角函数计算箭头两条边的端点
- `Math.PI / 5` (36度) 是箭头的张开角度
- 通过坐标变换将箭头定位到正确位置和角度
---
### 3. 区域绘制函数 `drawArea()`
#### 矩形区域绘制
```typescript
// 1. 绘制填充矩形
ctx.rect(x, y, w, h); // 定义矩形路径
ctx.fillStyle = get(theme, `area.fill-${type}`) ?? ''; // 设置填充色
ctx.fill(); // 填充矩形
// 2. 绘制边框
ctx.strokeStyle = get(theme, active ? 'area.strokeActive' : `area.stroke-${type}`) ?? '';
ctx.stroke(); // 绘制边框
```
#### 区域标签
```typescript
// 在区域上方居中显示标签
ctx.fillStyle = get(theme, 'color') ?? '';
ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(label, x + w / 2, y - fontSize * lineHeight);
```
---
### 4. 机器人绘制函数 `drawRobot()`
#### 机器人本体绘制
```typescript
const ox = x + w / 2; // 机器人中心X坐标
const oy = y + h / 2; // 机器人中心Y坐标
// 绘制椭圆形机器人
ctx.ellipse(ox, oy, w / 2, h / 2, 0, 0, Math.PI * 2);
ctx.fillStyle = get(theme, 'robot.fill') ?? '';
ctx.fill();
ctx.strokeStyle = get(theme, 'robot.stroke') ?? '';
ctx.stroke();
```
#### 路径轨迹绘制
```typescript
if (path?.length) {
// 设置路径样式
ctx.strokeStyle = get(theme, 'robot.line') ?? '';
ctx.lineCap = 'round'; // 圆形线帽
ctx.lineWidth = s * 4; // 粗线条
ctx.setLineDash([s * 5, s * 10]); // 虚线样式
// 坐标变换:移动到机器人中心并旋转
ctx.translate(ox, oy);
ctx.rotate((-deg * Math.PI) / 180);
// 绘制路径线条
ctx.beginPath();
ctx.moveTo(0, 0);
path.forEach((d) => ctx.lineTo(d.x * s, d.y * s));
ctx.stroke();
}
```
#### 路径终点箭头
```typescript
// 计算路径最后两个点的方向
const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {}; // 最后一个点
const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {}; // 倒数第二个点
const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI;
// 在路径终点绘制箭头
ctx.translate(ex1 * s, ey1 * s);
ctx.beginPath();
ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10);
ctx.lineTo(0, 0);
ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10);
ctx.stroke();
```
---
## 🔧 Canvas 2D API 核心方法说明
### 路径绘制方法
| 方法 | 功能 | 示例 |
| --------------------------------------------- | ------------------ | -------------------- |
| `beginPath()` | 开始新的绘制路径 | 每次绘制新图形前调用 |
| `moveTo(x, y)` | 移动画笔到指定位置 | 设置绘制起点 |
| `lineTo(x, y)` | 画直线到指定位置 | 绘制线段 |
| `arcTo(x1, y1, x2, y2, r)` | 绘制圆弧连接 | 创建圆角效果 |
| `quadraticCurveTo(cpx, cpy, x, y)` | 二阶贝塞尔曲线 | 简单曲线 |
| `bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)` | 三阶贝塞尔曲线 | 复杂曲线 |
| `rect(x, y, w, h)` | 矩形路径 | 绘制矩形 |
| `ellipse(x, y, rx, ry, rotation, start, end)` | 椭圆路径 | 绘制椭圆/圆形 |
| `closePath()` | 闭合当前路径 | 连接起点和终点 |
### 样式设置方法
| 属性/方法 | 功能 | 示例 |
| -------------------- | ---------------- | ------------------------------- |
| `fillStyle` | 设置填充颜色 | `ctx.fillStyle = '#ff0000'` |
| `strokeStyle` | 设置描边颜色 | `ctx.strokeStyle = '#0000ff'` |
| `lineWidth` | 设置线条宽度 | `ctx.lineWidth = 2` |
| `lineCap` | 设置线条端点样式 | `'round'`, `'square'`, `'butt'` |
| `setLineDash([...])` | 设置虚线样式 | `ctx.setLineDash([5, 5])` |
### 变换方法
| 方法 | 功能 | 说明 |
| -------------------------------- | ------------ | -------------- |
| `translate(x, y)` | 平移坐标系 | 移动原点位置 |
| `rotate(angle)` | 旋转坐标系 | 按弧度旋转 |
| `setTransform(a, b, c, d, e, f)` | 重置变换矩阵 | 恢复标准坐标系 |
### 状态管理方法
| 方法 | 功能 | 说明 |
| ----------- | ------------ | ---------- |
| `save()` | 保存当前状态 | 压入状态栈 |
| `restore()` | 恢复之前状态 | 弹出状态栈 |
---
## 🎯 绘制流程总结
### 1. 标准绘制流程
```typescript
function customDraw(ctx: CanvasRenderingContext2D, pen: MapPen): void {
// 1. 保存画布状态
ctx.save();
// 2. 提取绘制参数
const { x, y, width, height } = pen.calculative?.worldRect ?? {};
// 3. 设置样式属性
ctx.fillStyle = '填充色';
ctx.strokeStyle = '边框色';
// 4. 创建绘制路径
ctx.beginPath();
// ... 具体绘制操作
// 5. 执行绘制
ctx.fill(); // 填充
ctx.stroke(); // 描边
// 6. 恢复画布状态
ctx.restore();
}
```
### 2. 性能优化要点
- **状态管理**:及时调用 `save()``restore()` 避免状态污染
- **路径复用**:合理使用 `beginPath()` 清除之前的路径
- **批量绘制**:同类型图形可以合并绘制操作
- **避免重复计算**:缓存复杂的数学计算结果
---
## 📊 技术优势
### 1. Canvas 2D 的优势
- **高性能**:直接操作像素,渲染速度快
- **灵活性**:可以绘制任意复杂的图形
- **交互性**:支持鼠标事件检测和处理
- **兼容性**:现代浏览器完全支持
### 2. 自定义绘制的优势
- **个性化**:完全定制化的视觉效果
- **主题支持**:动态切换颜色主题
- **状态反馈**:不同状态显示不同样式
- **扩展性**:易于添加新的图形类型
---
## 🔚 结语
本场景编辑器通过 Canvas 2D API 实现了丰富的图形绘制功能,每个绘制函数都经过精心设计,既保证了视觉效果,又兼顾了性能表现。理解这些绘制原理对于进一步扩展和优化编辑器功能具有重要意义。

600
Meta2D引擎作用详解.md Normal file
View File

@ -0,0 +1,600 @@
# Meta2D引擎作用详解
## 🤔 您的疑问Meta2D到底做了什么
您的观察很准确确实具体的绘制操作都是通过HTML5 Canvas原生API实现的。那么Meta2D引擎到底在做什么呢
**简单类比**如果说Canvas API是"画笔和颜料"那么Meta2D就是"画师的大脑和手" - 它决定什么时候画、画在哪里、画什么样式,以及如何响应用户的操作。
---
## 🎯 Meta2D引擎的核心作用
Meta2D引擎并不是替代Canvas API而是在Canvas API之上构建了一个完整的**图形管理和渲染框架**。它的主要作用包括:
### 1. 🎨 渲染管理系统
```typescript
// 我们只需要注册绘制函数
this.registerCanvasDraw({
point: drawPoint,
line: drawLine,
area: drawArea,
robot: drawRobot,
});
// Meta2D会自动调用这些函数
```
**Meta2D负责**
- **何时渲染**:自动检测数据变化,决定何时重绘
- **渲染顺序**:管理图层顺序,确保正确的绘制层级
- **性能优化**:只重绘需要更新的部分,避免全量重绘
- **调用时机**:在正确的时机调用我们的绘制函数
### 2. 🎮 图形对象管理
```typescript
// 创建一个点位 - 我们只需要定义数据结构
const pen: MapPen = {
id: 'point1',
name: 'point',
x: 100,
y: 100,
width: 24,
height: 24,
point: { type: MapPointType.普通点 },
};
// Meta2D会管理这个对象的整个生命周期
await this.addPen(pen, false, true, true);
```
**Meta2D负责**
- **对象存储**:维护所有图形对象的数据结构
- **坐标转换**:从逻辑坐标转换为屏幕坐标
- **状态管理**:追踪每个对象的状态(选中、激活、可见等)
- **生命周期**:管理对象的创建、更新、删除
### 3. 🖱️ 事件处理系统
```typescript
// 我们只需要监听高级事件
this.on('click', (e, data) => {
// Meta2D已经处理了鼠标点击的复杂逻辑
console.log('点击了图形:', data);
});
```
**Meta2D负责**
- **事件捕获**监听原生DOM事件mousedown、mousemove、mouseup等
- **坐标转换**:将屏幕坐标转换为画布坐标
- **碰撞检测**:判断点击了哪个图形对象
- **事件分发**:将事件分发给正确的处理器
### 4. 📐 坐标系统管理
```typescript
// 我们在绘制函数中使用的坐标
const { x, y, width, height } = pen.calculative?.worldRect ?? {};
```
**Meta2D负责**
- **坐标计算**:自动计算`worldRect`(世界坐标)
- **缩放处理**:处理画布缩放时的坐标转换
- **视口管理**:管理可视区域和裁剪
- **变换矩阵**:处理复杂的坐标变换
---
## 🔄 Meta2D的工作流程
### 1. 初始化阶段
```typescript
export class EditorService extends Meta2d {
constructor(container: HTMLDivElement) {
// 1. 创建Meta2D实例传入配置
super(container, EDITOR_CONFIG);
// 2. 注册自定义绘制函数
this.#register();
// 3. 监听事件
this.on('*', (e, v) => this.#listen(e, v));
}
}
```
### 2. 图形创建阶段
```typescript
// 用户调用
await this.addPoint({ x: 100, y: 100 }, MapPointType.普通点);
// Meta2D内部流程
// 1. 创建图形对象数据结构
// 2. 分配唯一ID
// 3. 计算坐标和尺寸
// 4. 添加到图形列表
// 5. 触发重绘
```
### 3. 渲染阶段
```typescript
// Meta2D的渲染循环简化版
function render() {
// 1. 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 2. 遍历所有图形对象
for (const pen of this.store.data.pens) {
// 3. 计算世界坐标
this.updateWorldRect(pen);
// 4. 调用对应的绘制函数
const drawFn = this.canvasDrawMap[pen.name]; // 获取我们注册的绘制函数
if (drawFn) {
drawFn(ctx, pen); // 调用 drawPoint、drawLine 等
}
}
}
```
### 4. 事件处理阶段
```typescript
// 用户点击画布
canvas.addEventListener('click', (e) => {
// Meta2D内部处理
// 1. 获取点击坐标
const point = { x: e.offsetX, y: e.offsetY };
// 2. 转换为画布坐标
const worldPoint = this.screenToWorld(point);
// 3. 碰撞检测 - 判断点击了哪个图形
const hitPen = this.hitTest(worldPoint);
// 4. 触发相应事件
if (hitPen) {
this.emit('click', e, hitPen);
}
});
```
---
## 🎯 Meta2D的核心价值
### 1. 抽象层次提升
```typescript
// 没有Meta2D我们需要手动处理
canvas.addEventListener('mousedown', (e) => {
// 计算点击坐标
// 检测点击了哪个图形
// 处理拖拽逻辑
// 重绘画布
// ... 大量底层代码
});
// 有了Meta2D我们只需要
this.on('mousedown', (e, pen) => {
// 直接处理业务逻辑
console.log('点击了图形:', pen.id);
});
```
### 2. 数据驱动渲染
```typescript
// 数据变化自动触发重绘
this.setValue({ id: 'point1', x: 200 }); // Meta2D会自动重绘
```
### 3. 复杂交互支持
```typescript
// 选择、拖拽、缩放、旋转等复杂交互
this.active(['point1', 'point2']); // 多选
this.inactive(); // 取消选择
this.delete([pen]); // 删除图形
```
### 4. 性能优化
- **脏矩形重绘**:只重绘变化的区域
- **离屏渲染**复杂图形使用离屏Canvas
- **层级管理**:合理的图层分离
- **事件优化**:高效的碰撞检测算法
---
## 🏗️ 架构分层对比
### 传统Canvas开发
```
┌─────────────────────┐
│ 业务逻辑层 │
├─────────────────────┤
│ 手动管理层 │ ← 需要自己实现
│ (对象管理/事件/渲染) │
├─────────────────────┤
│ Canvas 2D API │
├─────────────────────┤
│ 浏览器引擎 │
└─────────────────────┘
```
### 使用Meta2D
```
┌─────────────────────┐
│ 业务逻辑层 │ ← 我们专注于这里
├─────────────────────┤
│ Meta2D 引擎 │ ← 引擎处理复杂逻辑
├─────────────────────┤
│ Canvas 2D API │ ← 底层绘制API
├─────────────────────┤
│ 浏览器引擎 │
└─────────────────────┘
```
---
## 🎨 实际代码示例
### 没有Meta2D的代码复杂
```typescript
class ManualCanvas {
private pens: MapPen[] = [];
private selectedPens: MapPen[] = [];
constructor(private canvas: HTMLCanvasElement) {
this.canvas.addEventListener('click', this.onClick.bind(this));
this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this));
// ... 更多事件监听
}
onClick(e: MouseEvent) {
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 手动碰撞检测
for (const pen of this.pens) {
if (this.isPointInPen(x, y, pen)) {
this.selectPen(pen);
this.render(); // 手动重绘
break;
}
}
}
render() {
const ctx = this.canvas.getContext('2d')!;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 手动绘制每个图形
for (const pen of this.pens) {
this.drawPen(ctx, pen);
}
}
// ... 大量的手动管理代码
}
```
### 使用Meta2D的代码简洁
```typescript
class EditorService extends Meta2d {
constructor(container: HTMLDivElement) {
super(container, EDITOR_CONFIG);
// 注册绘制函数
this.registerCanvasDraw({ point: drawPoint });
// 监听事件
this.on('click', (e, pen) => {
// 直接处理业务逻辑
this.handlePenClick(pen);
});
}
async addPoint(p: Point, type: MapPointType) {
const pen: MapPen = {
// ... 定义数据结构
};
// Meta2D自动处理渲染
await this.addPen(pen);
}
}
```
---
## 🎯 总结
Meta2D引擎的作用就像是一个**智能管家**
1. **您专注于业务**:定义数据结构和绘制逻辑
2. **引擎处理细节**:坐标转换、事件处理、渲染优化
3. **原生API执行**最终通过Canvas API完成绘制
这种分工让您可以:
- 🎯 **专注业务逻辑**:不需要处理复杂的底层细节
- 🚀 **提高开发效率**:大量重复的工作由引擎完成
- 🎨 **获得更好的性能**:引擎内置了各种优化策略
- 🔧 **更容易维护**:清晰的架构分层
**Meta2D = 图形管理框架 + 事件处理系统 + 渲染优化引擎**
它不是替代Canvas API而是在Canvas API之上构建了一个完整的企业级图形编辑解决方案
---
## 📱 项目中的实际应用
### 1. 响应式数据流集成
```typescript
export class EditorService extends Meta2d {
// Meta2D处理底层变化我们用RxJS处理业务逻辑
readonly #change$$ = new Subject<boolean>();
public readonly current = useObservable<MapPen>(
this.#change$$.pipe(
debounceTime(100),
map(() => <MapPen>clone(this.store.active?.[0])),
),
);
// Meta2D的事件 → RxJS流 → Vue响应式数据
#listen(e: unknown, v: any) {
switch (e) {
case 'add':
case 'delete':
case 'update':
this.#change$$.next(true); // 通知数据变化
break;
}
}
}
```
### 2. 复杂业务逻辑简化
```typescript
// 创建区域时的智能关联
public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string) {
// Meta2D自动处理选中状态
const selected = <MapPen[]>this.store.active;
// 根据区域类型自动关联相关元素
switch (type) {
case MapAreaType.库区:
selected?.filter(({ point }) => point?.type === MapPointType.动作点)
.forEach(({ id }) => points.push(id!));
break;
case MapAreaType.互斥区:
selected?.filter(({ point }) => point?.type).forEach(({ id }) => points.push(id!));
selected?.filter(({ route }) => route?.type).forEach(({ id }) => routes.push(id!));
break;
}
// Meta2D自动处理图形创建和渲染
const area = await this.addPen(pen, true, true, true);
this.bottom(area); // 自动层级管理
}
```
### 3. 主题系统集成
```typescript
// 监听主题变化Meta2D自动重绘
watch(
() => sTheme.theme,
(theme) => {
this.setTheme(theme); // Meta2D内置主题系统
// 重新应用主题到自定义绘制
this.find('point').forEach((pen) => {
if (pen.point?.type >= 10) {
this.canvas.updateValue(pen, this.#mapPointImage(pen.point.type));
}
});
this.render(); // 触发重绘
},
{ immediate: true },
);
```
### 4. 实时数据更新
```typescript
// 机器人实时位置更新
public refreshRobot(id: RobotInfo['id'], info: Partial<RobotRealtimeInfo>): void {
const { x: cx = 37, y: cy = 37, active, angle, path: points } = info;
// Meta2D自动处理坐标转换和重绘
this.setValue({
id,
x: cx - 37,
y: cy - 37,
rotate: angle,
robot: { ...robot, active, path },
visible: true
}, { render: true, history: false, doEvent: false });
}
```
### 5. 事件系统的实际使用
```typescript
constructor(container: HTMLDivElement) {
super(container, EDITOR_CONFIG);
// Meta2D统一事件处理
this.on('*', (e, v) => this.#listen(e, v));
// 具体事件映射到业务逻辑
#listen(e: unknown, v: any) {
switch (e) {
case 'click':
case 'mousedown':
case 'mouseup':
// 转换为响应式数据流
this.#mouse$$.next({ type: e, value: pick(v, 'x', 'y') });
break;
case 'active':
case 'inactive':
// 选中状态变化
this.#change$$.next(false);
break;
}
}
}
```
---
## 🎯 如果没有Meta2D会怎样
假设我们要自己实现同样的功能,需要处理的复杂度:
### 1. 坐标系统管理
```typescript
// 需要手动处理缩放、平移、坐标转换
class CoordinateSystem {
private scale = 1;
private offsetX = 0;
private offsetY = 0;
screenToWorld(screenPoint: Point): Point {
return {
x: (screenPoint.x - this.offsetX) / this.scale,
y: (screenPoint.y - this.offsetY) / this.scale,
};
}
worldToScreen(worldPoint: Point): Point {
return {
x: worldPoint.x * this.scale + this.offsetX,
y: worldPoint.y * this.scale + this.offsetY,
};
}
// 还需要处理矩阵变换、旋转等复杂情况...
}
```
### 2. 碰撞检测系统
```typescript
// 需要为每种图形实现碰撞检测
class HitTest {
hitTestPoint(point: Point, pen: MapPen): boolean {
const rect = this.getPenRect(pen);
return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height;
}
hitTestLine(point: Point, pen: MapPen): boolean {
// 需要实现点到线段的距离计算
// 还要考虑贝塞尔曲线的复杂情况
}
hitTestArea(point: Point, pen: MapPen): boolean {
// 矩形碰撞检测
}
// 还需要处理旋转、缩放后的碰撞检测...
}
```
### 3. 渲染管理系统
```typescript
// 需要手动管理渲染队列和优化
class RenderManager {
private dirtyRects: Rect[] = [];
private renderQueue: MapPen[] = [];
markDirty(pen: MapPen) {
this.dirtyRects.push(this.getPenRect(pen));
this.renderQueue.push(pen);
}
render() {
// 计算需要重绘的区域
const mergedRect = this.mergeDirtyRects();
// 清空脏区域
this.ctx.clearRect(mergedRect.x, mergedRect.y, mergedRect.width, mergedRect.height);
// 重绘相关图形
for (const pen of this.getIntersectingPens(mergedRect)) {
this.drawPen(pen);
}
}
// 大量的优化逻辑...
}
```
**这些复杂的底层逻辑Meta2D都已经帮我们处理好了**
---
## 🚀 Meta2D带来的开发体验提升
### 开发效率对比
| 功能 | 纯Canvas开发 | 使用Meta2D |
| -------------------- | ------------ | ----------------------------- |
| 创建一个可点击的图形 | ~100行代码 | ~10行代码 |
| 实现拖拽功能 | ~200行代码 | 内置支持 |
| 多选和批量操作 | ~300行代码 | `this.active([...])` |
| 撤销重做 | ~500行代码 | 内置支持 |
| 图形层级管理 | ~100行代码 | `this.top()`, `this.bottom()` |
| 响应式数据绑定 | 需要自己实现 | 内置事件系统 |
### 维护成本对比
| 场景 | 纯Canvas | Meta2D |
| -------------- | ---------------- | ------------------ |
| 添加新图形类型 | 修改多个系统 | 添加绘制函数即可 |
| 性能优化 | 需要深度优化 | 引擎已优化 |
| Bug修复 | 涉及多个底层模块 | 通常只涉及业务逻辑 |
| 功能扩展 | 可能需要重构架构 | 基于现有API扩展 |
---
## 🏆 总结
Meta2D引擎就像是为Canvas开发者提供的一个**超级工具箱**
- 🎨 **您负责创意**:定义什么样的图形、什么样的交互
- 🔧 **Meta2D负责实现**:处理所有复杂的底层逻辑
- 🚀 **Canvas负责绘制**:最终的像素级渲染
这种架构让我们的场景编辑器项目能够:
- ✅ 快速开发复杂的图形编辑功能
- ✅ 获得企业级的性能和稳定性
- ✅ 专注于业务逻辑而不是底层实现
- ✅ 轻松维护和扩展功能
**Meta2D不是画笔而是整个画室的管理系统**

View File

@ -0,0 +1,646 @@
# WebSocket增强服务技术设计文档
## 概述
本文档详细解释了 `src/services/ws.ts` 的技术设计思路、架构选择和实现细节。这个文件实现了一个增强的WebSocket服务在保持原有接口不变的前提下添加了心跳检测、自动重连、错误处理等企业级功能。
## 设计目标
### 主要目标
1. **零侵入性**:业务代码无需修改,完全透明的功能增强
2. **企业级稳定性**:心跳检测、自动重连、错误恢复
3. **可配置性**:全局配置,易于调整和优化
4. **类型安全**完整的TypeScript类型支持
5. **内存安全**:正确的资源管理,防止内存泄漏
### 兼容性目标
- 保持原有 `create(path): Promise<WebSocket>` 接口不变
- 返回标准WebSocket实例支持所有原生API
- 业务代码中的 `ws.onmessage`, `ws.close()` 等调用完全兼容
## 架构设计
### 整体架构图
```
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ 业务代码 │ │ EnhancedWebSocket │ │ 原生WebSocket │
│ │ │ (包装器) │ │ │
│ ws.onmessage = ... │───▶│ 事件拦截和过滤 │───▶│ 实际网络连接 │
│ ws.send(data) │ │ 心跳检测逻辑 │ │ │
│ ws.close() │ │ 重连管理 │ │ │
└─────────────────────┘ └──────────────────────┘ └─────────────────────┘
┌──────────────────────┐
│ WS_CONFIG │
│ (全局配置) │
│ - 心跳间隔 │
│ - 重连策略 │
│ - 超时设置 │
└──────────────────────┘
```
### 设计模式选择
#### 1. 包装器模式 (Wrapper Pattern)
```typescript
class EnhancedWebSocket {
private ws: WebSocket; // 包装原生WebSocket
}
```
**为什么选择包装器而不是继承?**
1. **继承的问题**
```typescript
// 继承方式的问题
class EnhancedWebSocket extends WebSocket {
constructor(url: string) {
super(url); // 连接立即开始,无法在事件处理器设置前进行拦截
}
}
```
2. **包装器的优势**
```typescript
// 包装器方式的优势
class EnhancedWebSocket {
constructor(path: string, baseUrl: string) {
this.ws = new WebSocket(baseUrl + path); // 控制创建时机
this.setupHandlers(); // 立即设置我们的处理器
}
}
```
#### 2. 代理模式 (Proxy Pattern)
通过getter/setter拦截用户对事件处理器的设置
```typescript
get onmessage(): ((event: MessageEvent) => void) | null {
return this.userOnMessage;
}
set onmessage(handler: ((event: MessageEvent) => void) | null) {
this.userOnMessage = handler; // 保存用户的处理器
// 我们的处理器已经在构造时设置,会调用用户的处理器
}
```
## 核心技术实现
### 1. Class 设计选择
#### 为什么使用 Class
```typescript
class EnhancedWebSocket {
// 私有状态管理
private ws: WebSocket;
private path: string;
private heartbeatTimer?: NodeJS.Timeout;
// ...
}
```
**选择Class的原因**
1. **状态封装**WebSocket连接需要管理多个状态连接、定时器、配置等
2. **方法绑定**事件处理器需要访问实例状态Class提供了自然的this绑定
3. **生命周期管理**:连接的创建、维护、销毁有清晰的生命周期
4. **类型安全**TypeScript对Class有更好的类型推导和检查
**与函数式方案的对比:**
```typescript
// 函数式方案的问题
function createEnhancedWS(path: string) {
let heartbeatTimer: NodeJS.Timeout;
let reconnectTimer: NodeJS.Timeout;
// 需要大量闭包来管理状态,复杂度高
}
// Class方案的优势
class EnhancedWebSocket {
private heartbeatTimer?: NodeJS.Timeout; // 清晰的状态管理
private reconnectTimer?: NodeJS.Timeout;
// 方法可以直接访问状态
}
```
### 2. Private 成员设计
#### 为什么大量使用 private
```typescript
class EnhancedWebSocket {
private ws: WebSocket; // 内部WebSocket实例
private path: string; // 连接路径
private heartbeatTimer?: NodeJS.Timeout; // 心跳定时器
private reconnectTimer?: NodeJS.Timeout; // 重连定时器
private reconnectAttempts: number = 0; // 重连次数
private isManualClose: boolean = false; // 手动关闭标志
private isHeartbeatTimeout: boolean = false; // 心跳超时标志
}
```
**Private的重要性**
1. **封装原则**:防止外部直接访问和修改内部状态
2. **API稳定性**:内部实现可以随时重构,不影响公共接口
3. **状态一致性**:防止外部代码破坏内部状态的一致性
4. **错误预防**避免用户误用内部API导致的bug
**示例对比:**
```typescript
// 如果没有private用户可能这样做
const ws = new EnhancedWebSocket('/test');
ws.heartbeatTimer = undefined; // 💥 破坏了心跳检测
ws.reconnectAttempts = -1; // 💥 破坏了重连逻辑
// 有了private这些操作被编译器阻止
// ✅ 确保了内部状态的安全性
```
### 3. Constructor 设计
#### 构造函数的关键作用
```typescript
constructor(path: string, baseUrl: string) {
this.path = path;
this.baseUrl = baseUrl;
this.ws = new WebSocket(baseUrl + path); // 创建实际连接
this.setupHandlers(); // 立即设置事件处理器
}
```
**设计要点:**
1. **立即执行**:构造时立即创建连接和设置处理器
2. **状态初始化**:确保所有私有状态都有正确的初始值
3. **参数验证**:(可以添加)对输入参数进行验证
4. **最小权限**:只接收必要的参数,其他配置使用全局配置
**为什么不延迟创建连接?**
```typescript
// ❌ 错误方案:延迟创建
constructor(path: string, baseUrl: string) {
this.path = path;
this.baseUrl = baseUrl;
// 不创建连接等用户调用connect()
}
// ✅ 正确方案:立即创建
constructor(path: string, baseUrl: string) {
// 立即创建,因为原有接口期望构造后就有连接
this.ws = new WebSocket(baseUrl + path);
this.setupHandlers();
}
```
### 4. Getter/Setter 设计
#### 透明的属性代理
```typescript
// 只读属性的getter
get readyState(): number {
return this.ws.readyState; // 直接代理到内部WebSocket
}
get url(): string {
return this.ws.url;
}
// 可写属性的getter/setter
get binaryType(): BinaryType {
return this.ws.binaryType;
}
set binaryType(value: BinaryType) {
this.ws.binaryType = value;
}
```
**为什么需要这些getter/setter**
1. **API兼容性**用户期望能够访问标准WebSocket的所有属性
2. **透明代理**用户感觉在使用标准WebSocket实际上是我们的增强版本
3. **状态同步**确保外部看到的状态与内部WebSocket状态一致
#### 事件处理器的特殊getter/setter
```typescript
// 事件处理器的拦截
get onmessage(): ((event: MessageEvent) => void) | null {
return this.userOnMessage; // 返回用户设置的处理器
}
set onmessage(handler: ((event: MessageEvent) => void) | null) {
this.userOnMessage = handler; // 保存用户的处理器
// 我们的内部处理器会调用用户的处理器
}
```
**关键设计思路:**
1. **双层处理**:我们的处理器 + 用户的处理器
2. **透明性**用户感觉直接在设置WebSocket的事件处理器
3. **控制权**:我们先处理(如过滤心跳),再传递给用户
### 5. 事件处理架构
#### 事件流设计
```
WebSocket原生事件 → 我们的处理器 → 过滤/处理 → 用户的处理器
```
#### 具体实现
```typescript
private setupHandlers(): void {
// 1. 设置我们的处理器
this.ws.onmessage = (event) => {
const messageData = event.data;
// 2. 我们先处理(心跳检测)
let isHeartbeatResponse = false;
if (typeof messageData === 'string' && messageData === WS_CONFIG.heartbeatResponseType) {
isHeartbeatResponse = true;
}
if (isHeartbeatResponse) {
this.clearHeartbeatTimeout(); // 清除心跳超时
return; // 不传递给用户
}
// 3. 传递给用户的处理器
if (this.userOnMessage) {
this.userOnMessage(event);
}
};
}
```
**设计优势:**
1. **消息过滤**:自动过滤心跳消息,用户无感知
2. **状态管理**:自动处理连接状态变化
3. **错误恢复**:自动处理连接错误和重连
### 6. 定时器管理
#### 定时器生命周期管理
```typescript
class EnhancedWebSocket {
private heartbeatTimer?: NodeJS.Timeout; // 心跳发送定时器
private heartbeatTimeoutTimer?: NodeJS.Timeout; // 心跳响应超时定时器
private reconnectTimer?: NodeJS.Timeout; // 重连定时器
}
```
**为什么需要三个定时器?**
1. **heartbeatTimer**:定期发送心跳包
2. **heartbeatTimeoutTimer**:检测心跳响应超时
3. **reconnectTimer**:延迟重连
#### 定时器清理策略
```typescript
// 停止心跳检测
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = undefined; // 重置为undefined
}
this.clearHeartbeatTimeout(); // 同时清理超时检测
}
// 清除心跳响应超时检测
private clearHeartbeatTimeout(): void {
if (this.heartbeatTimeoutTimer) {
clearTimeout(this.heartbeatTimeoutTimer);
this.heartbeatTimeoutTimer = undefined; // 重置为undefined
}
}
```
**内存安全保证:**
1. **及时清理**:每次停止时都清理定时器
2. **状态重置**清理后设置为undefined
3. **多重清理**:在多个关键点都进行清理(连接关闭、手动关闭等)
### 7. 状态标志设计
#### 关键状态标志
```typescript
private isManualClose: boolean = false; // 是否手动关闭
private isHeartbeatTimeout: boolean = false; // 是否心跳超时
private reconnectAttempts: number = 0; // 重连次数
```
**为什么需要这些标志?**
1. **区分关闭原因**:手动关闭 vs 异常断开 vs 心跳超时
2. **重连决策**:根据不同原因决定是否重连
3. **状态跟踪**:跟踪重连进度和次数
#### 状态转换逻辑
```typescript
// 心跳超时时
private startHeartbeatTimeout(): void {
this.heartbeatTimeoutTimer = setTimeout(() => {
this.isHeartbeatTimeout = true; // 设置心跳超时标志
this.ws.close(1000, 'Heartbeat timeout');
}, WS_CONFIG.heartbeatTimeout);
}
// 连接关闭时的决策
this.ws.onclose = (event) => {
// 如果不是手动关闭,或者是心跳超时导致的关闭,则重连
if (!this.isManualClose || this.isHeartbeatTimeout) {
this.scheduleReconnect();
}
this.isHeartbeatTimeout = false; // 重置标志
};
```
### 8. addEventListener/removeEventListener 实现
#### 为什么需要这些方法?
```typescript
addEventListener<K extends keyof WebSocketEventMap>(
type: K,
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
): void {
this.ws.addEventListener(type, listener, options);
}
removeEventListener<K extends keyof WebSocketEventMap>(
type: K,
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any,
options?: boolean | EventListenerOptions
): void {
this.ws.removeEventListener(type, listener, options);
}
```
**重要性:**
1. **完整的API兼容性**某些业务代码可能使用addEventListener而不是onXXX
2. **事件管理**:支持多个监听器
3. **标准兼容**遵循WebSocket标准API
**类型安全:**
- 使用泛型 `<K extends keyof WebSocketEventMap>` 确保事件类型正确
- listener参数的类型根据事件类型自动推导
### 9. 心跳检测机制
#### 心跳超时检测逻辑
```typescript
// 发送心跳时,只在没有超时检测时才设置新的
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(WS_CONFIG.heartbeatMessage);
if (!this.heartbeatTimeoutTimer) {
// 关键:避免重复设置
this.startHeartbeatTimeout();
}
}
}, WS_CONFIG.heartbeatInterval);
```
**设计要点:**
1. **避免重复设置**:只有在没有超时检测时才设置新的
2. **超时逻辑**:设定时间内没收到响应就断开连接
3. **状态同步**:收到响应时清除超时检测
#### 心跳响应处理
```typescript
// 检查是否为心跳响应支持字符串和JSON格式
let isHeartbeatResponse = false;
// 1. 检查简单字符串格式
if (typeof messageData === 'string' && messageData === WS_CONFIG.heartbeatResponseType) {
isHeartbeatResponse = true;
}
// 2. 检查JSON格式
if (!isHeartbeatResponse && typeof messageData === 'string') {
try {
const data = JSON.parse(messageData);
if (data.type === WS_CONFIG.heartbeatResponseType) {
isHeartbeatResponse = true;
}
} catch (e) {
// JSON解析失败不是JSON格式的心跳响应
}
}
```
**兼容性设计**:支持两种心跳响应格式,适应不同的服务器实现。
### 10. 重连机制
#### 指数退避算法
```typescript
private scheduleReconnect(): void {
if (this.isManualClose || this.reconnectAttempts >= WS_CONFIG.maxReconnectAttempts) {
return;
}
this.reconnectAttempts++;
// 指数退避重连策略
const delay = Math.min(
WS_CONFIG.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1),
WS_CONFIG.maxReconnectDelay
);
this.reconnectTimer = setTimeout(() => {
this.reconnect();
}, delay);
}
```
**算法解释:**
- 第1次重连1000ms 后
- 第2次重连2000ms 后
- 第3次重连4000ms 后
- 第4次重连8000ms 后
- 第5次重连16000ms 后受maxReconnectDelay限制实际为30000ms
**设计考虑:**
1. **指数退避**:避免对服务器造成压力
2. **最大延迟限制**:防止延迟过长
3. **次数限制**:避免无限重连
4. **服务器友好**:给服务器恢复时间
### 11. 类型安全设计
#### 严格的类型定义
```typescript
// 事件处理器类型
private userOnMessage: ((event: MessageEvent) => void) | null = null;
private userOnClose: ((event: CloseEvent) => void) | null = null;
private userOnError: ((event: Event) => void) | null = null;
private userOnOpen: ((event: Event) => void) | null = null;
```
**类型安全的好处:**
1. **编译时检查**:在编译时捕获类型错误
2. **IDE支持**:更好的自动补全和错误提示
3. **重构安全**:类型系统确保重构的正确性
#### 泛型的使用
```typescript
addEventListener<K extends keyof WebSocketEventMap>(
type: K,
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any,
options?: boolean | AddEventListenerOptions
): void
```
**泛型的价值:**
- `K extends keyof WebSocketEventMap`确保事件类型只能是WebSocket支持的类型
- `ev: WebSocketEventMap[K]`:根据事件类型自动推导事件对象类型
### 12. 资源管理
#### 完整的清理机制
```typescript
close(code?: number, reason?: string): void {
console.log(`手动关闭WebSocket: ${this.path}`);
this.isManualClose = true;
this.isHeartbeatTimeout = false; // 重置心跳超时标志
this.stopHeartbeat(); // 清理心跳定时器
this.clearReconnectTimer(); // 清理重连定时器
this.ws.close(code, reason); // 关闭实际连接
}
```
**资源清理的重要性:**
1. **内存泄漏预防**:确保所有定时器都被清理
2. **状态一致性**:重置所有状态标志
3. **优雅关闭**:按正确顺序清理资源
## 配置设计
### 全局配置对象
```typescript
const WS_CONFIG = {
heartbeatInterval: 3000, // 心跳间隔
heartbeatTimeout: 5000, // 心跳响应超时时间
maxReconnectAttempts: 5, // 最大重连次数
reconnectBaseDelay: 1000, // 重连基础延迟
maxReconnectDelay: 30000, // 最大重连延迟
heartbeatMessage: 'ping', // 心跳消息
heartbeatResponseType: 'pong', // 心跳响应类型
};
```
**配置设计原则:**
1. **集中管理**:所有配置在一个地方,易于维护
2. **合理默认值**:开箱即用的配置
3. **易于调整**:生产环境可以快速调整参数
4. **文档化**:每个配置都有清晰的注释
## 接口兼容性
### 原有接口保持不变
```typescript
// 原有接口
function create(path: string): Promise<WebSocket> {
const baseUrl = import.meta.env.ENV_WEBSOCKET_BASE ?? '';
const ws = new EnhancedWebSocket(path, baseUrl) as any;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ws.close();
reject(new Error('WebSocket connection timeout'));
}, 10000);
ws.addEventListener('open', () => {
clearTimeout(timeout);
resolve(ws); // 返回增强的WebSocket但类型为WebSocket
});
ws.addEventListener('error', (e: any) => {
clearTimeout(timeout);
reject(e);
});
});
}
```
**兼容性保证:**
1. **相同的函数签名**`create(path: string): Promise<WebSocket>`
2. **相同的返回类型**返回Promise<WebSocket>
3. **相同的使用方式**:业务代码无需任何修改
## 总结
### 技术选择总结
| 技术选择 | 原因 | 替代方案 | 为什么不选择替代方案 |
| ------------- | -------------------------------- | ------------ | ----------------------- |
| Class | 状态封装、方法绑定、生命周期管理 | 函数+闭包 | 复杂度高,类型支持差 |
| 包装器模式 | 控制创建时机、事件拦截 | 继承 | 无法在事件设置前拦截 |
| Private成员 | 封装、API稳定性、状态保护 | Public成员 | 容易被误用,状态不安全 |
| Getter/Setter | 透明代理、API兼容性 | 直接方法 | 不符合WebSocket API习惯 |
| 多定时器 | 职责分离、精确控制 | 单定时器 | 逻辑混乱,难以维护 |
| 状态标志 | 精确控制重连逻辑 | 仅依赖状态码 | WebSocket状态码限制多 |
### 架构优势
1. **零侵入性**:业务代码完全无需修改
2. **高可靠性**:多重保障确保连接稳定
3. **高可维护性**:清晰的架构和完整的类型支持
4. **高性能**:最小的性能开销
5. **高扩展性**:易于添加新功能
### 最佳实践体现
1. **单一职责原则**:每个方法只负责一个功能
2. **开闭原则**:对扩展开放,对修改封闭
3. **依赖倒置原则**:依赖抽象(接口)而非具体实现
4. **接口隔离原则**:用户只看到需要的接口
5. **里氏替换原则**:增强版本完全可以替换原版本
这个实现展示了如何在保持向后兼容的同时,提供企业级的功能增强,是一个很好的渐进式增强的例子。

View File

@ -6,7 +6,6 @@
"scripts": {
"start": "vite",
"build": "vue-tsc -b && vite build",
"copy-mocks": "node -e \"const fs=require('fs');const path=require('path');function copyDir(s,d){if(!fs.existsSync(d))fs.mkdirSync(d,{recursive:true});fs.readdirSync(s).forEach(f=>{const sp=path.join(s,f),dp=path.join(d,f);fs.statSync(sp).isDirectory()?copyDir(sp,dp):fs.copyFileSync(sp,dp)})}copyDir('mocks','dist/mocks')\"",
"preview": "vite preview"
},
"dependencies": {

View File

@ -1,77 +1,160 @@
import { KeydownType, type Options } from '@meta2d/core';
import { invert } from 'lodash-es';
/**
*
*
* 1-910
*/
//#region 点位
export enum MapPointType {
/** 普通点 - 基础导航点,机器人可通过 */
= 1,
/** 等待点 - 机器人等待或暂停的位置 */
,
/** 避让点 - 机器人主动避让其他机器人的点位 */
,
/** 临时避让点 - 动态生成的临时避让位置,有特殊标记 */
,
/** 电梯点 - 机器人乘坐电梯的专用点位 */
= 11,
/** 自动门点 - 需要自动门控制的通行点位 */
,
/** 充电点 - 机器人充电的专用位置,可绑定特定机器人 */
,
/** 停靠点 - 机器人停靠等待的位置,可绑定特定机器人 */
,
/** 动作点 - 机器人执行特定动作的位置(如取货、放货) */
,
/** 禁行点 - 禁止机器人通过的点位 */
,
}
/**
* UI显示和选择
*
*/
export const MAP_POINT_TYPES = Object.freeze(
<[string, MapPointType][]>Object.entries(MapPointType).filter(([, v]) => typeof v === 'number'),
);
//#endregion
/**
* 线
*
*/
//#region 线路
export enum MapRouteType {
/** 直线 - 两点间直线连接 */
线 = 'line',
/** 二阶贝塞尔曲线 - 带一个控制点的曲线 */
线 = 'bezier2',
/** 三阶贝塞尔曲线 - 带两个控制点的曲线,更灵活 */
线 = 'bezier3',
}
/**
* 线
*/
export const MAP_ROUTE_TYPE = invert(MapRouteType);
/**
* 线UI显示和选择
*/
export const MAP_ROUTE_TYPES = Object.freeze(<[string, MapRouteType][]>Object.entries(MapRouteType));
/**
* 线
* 线
*/
export enum MapRoutePassType {
/** 无限制 - 所有机器人都可以通行 */
,
/** 仅空载可通行 - 只有空载的机器人可以通过 */
,
/** 仅载货可通行 - 只有载货的机器人可以通过 */
,
/** 禁行 - 禁止所有机器人通行,显示为虚线 */
= 10,
}
/**
* 线UI显示和选择
*/
export const MAP_ROUTE_PASS_TYPES = Object.freeze(
<[string, MapRoutePassType][]>Object.entries(MapRoutePassType).filter(([, v]) => typeof v === 'number'),
);
//#endregion
/**
*
*
*/
//#region 区域
export enum MapAreaType {
/** 库区 - 仓储作业区域,包含动作点 */
= 1,
/** 互斥区 - 同时只能有一个机器人进入的区域 */
= 11,
/** 非互斥区 - 可以同时有多个机器人进入的区域 */
,
}
/**
* UI显示和选择
*/
export const MAP_AREA_TYPES = Object.freeze(
<[string, MapAreaType][]>Object.entries(MapAreaType).filter(([, v]) => typeof v === 'number'),
);
//#endregion
/**
*
* Meta2D引擎的编辑器配置参数
*/
export const EDITOR_CONFIG: Options = {
/** 键盘事件类型 - 禁用所有键盘快捷键 */
keydown: KeydownType.None,
/** 严格作用域 - 限制编辑操作范围 */
strictScope: true,
/** 移动连接线 - 禁用拖动时自动移动连接的线条 */
moveConnectedLine: false,
/** 禁用输入 - 禁用文本输入功能 */
disableInput: true,
/** 禁用旋转 - 禁用图形旋转功能 */
disableRotate: true,
/** 禁用尺寸调整 - 禁用图形大小调整 */
disableSize: true,
/** 禁用锚点 - 禁用连接锚点显示 */
disableAnchor: true,
/** 禁用空线条 - 不允许创建没有连接点的线条 */
disableEmptyLine: true,
/** 禁用重复线条 - 不允许在同一对点之间创建多条线 */
disableRepeatLine: true,
/** 最小缩放比例 - 画布最小缩放到24% */
minScale: 0.24,
/** 最大缩放比例 - 画布最大缩放到401% */
maxScale: 4.01,
scaleOff: 0.01,
/** 缩放步长 - 每次滚轮滚动的缩放幅度5% */
scaleOff: 0.05,
/** 默认锚点 - 不设置默认锚点 */
defaultAnchors: [],
/** 全局透明度 - 普通状态下图形透明度0为不透明 */
globalAlpha: 0,
/** 激活状态全局透明度 - 选中状态下图形透明度 */
activeGlobalAlpha: 0,
/** 默认字体大小 - 14像素 */
fontSize: 14,
/** 行高倍数 - 1.5倍行高 */
lineHeight: 1.5,
/** 字体族 - 使用系统默认字体 */
fontFamily: 'system-ui',
/** 文本旋转 - 禁用文本跟随图形旋转 */
textRotate: false,
/** 文本水平对齐 - 居中对齐 */
textAlign: 'center',
/** 文本垂直基线 - 顶部对齐 */
textBaseline: 'top',
};

View File

@ -86,7 +86,7 @@ const selectRobot = (id: string) => {
<a-layout class="full">
<a-layout-header class="p-16" style="height: 64px">
<a-flex justify="space-between" align="center">
<a-typography-text class="title">{{ title }}</a-typography-text>
<a-typography-text class="title">{{ title }}--场景仿真</a-typography-text>
</a-flex>
</a-layout-header>

View File

@ -105,7 +105,7 @@ const selectRobot = (id: string) => {
<a-layout class="full">
<a-layout-header class="p-16" style="height: 64px">
<a-flex justify="space-between" align="center">
<a-typography-text class="title">{{ title }}</a-typography-text>
<a-typography-text class="title">{{ title }} --场景编辑</a-typography-text>
<a-space align="center">
<a-button v-if="editable" class="warning" @click="editable = false">
<i class="icon exit size-18 mr-8" />

View File

@ -27,8 +27,25 @@ import { clone, get, isEmpty, isNil, isString, nth, omitBy, pick, remove, some }
import { BehaviorSubject, debounceTime, filter, map, Subject, switchMap } from 'rxjs';
import { reactive, watch } from 'vue';
/**
*
* Meta2D
*
*
* -
* - 线
* -
* -
* -
*/
export class EditorService extends Meta2d {
//#region 场景文件
//#region 场景文件管理
/**
*
* @param map JSON字符串
* @param editable
* @param detail
*/
public async load(map?: string, editable = false, detail?: Partial<GroupSceneDetail>): Promise<void> {
const scene: StandardScene = map ? JSON.parse(map) : {};
if (!isEmpty(detail?.group)) {
@ -45,6 +62,10 @@ export class EditorService extends Meta2d {
this.store.historyIndex = undefined;
this.store.histories = [];
}
/**
* JSON字符串
* @returns JSON字符串
*/
public save(): string {
const scene: StandardScene = {
robotGroups: this.robotGroups.value,
@ -57,11 +78,20 @@ export class EditorService extends Meta2d {
return JSON.stringify(scene);
}
/**
*
* @param groups
* @param robots
*/
#loadRobots(groups?: RobotGroup[], robots?: RobotInfo[]): void {
this.#robotMap.clear();
robots?.forEach((v) => this.#robotMap.set(v.id, v));
this.#robotGroups$$.next(groups ?? []);
}
/**
*
* @param points
*/
async #loadScenePoints(points?: StandardScenePoint[]): Promise<void> {
if (!points?.length) return;
await Promise.all(
@ -75,6 +105,10 @@ export class EditorService extends Meta2d {
}),
);
}
/**
* 线
* @param routes 线
*/
#loadSceneRoutes(routes?: StandardSceneRoute[]): void {
if (!routes?.length) return;
routes.map((v) => {
@ -206,6 +240,10 @@ export class EditorService extends Meta2d {
}
//#endregion
/**
*
* @param editable true为可编辑状态false为只读状态
*/
public setState(editable?: boolean): void {
this.lock(editable ? LockState.None : LockState.DisableEdit);
}
@ -214,7 +252,10 @@ export class EditorService extends Meta2d {
return super.data();
}
/** 鼠标事件流主体,用于内部事件分发 */
readonly #mouse$$ = new Subject<{ type: 'click' | 'mousedown' | 'mouseup'; value: Point }>();
/** 鼠标点击事件的响应式流,防抖处理后的点击坐标 */
public readonly mouseClick = useObservable<Point>(
this.#mouse$$.pipe(
filter(({ type }) => type === 'click'),
@ -222,6 +263,8 @@ export class EditorService extends Meta2d {
map(({ value }) => value),
),
);
/** 鼠标拖拽事件的响应式流,返回起始点和结束点坐标,用于创建区域 */
public readonly mouseBrush = useObservable<[Point, Point]>(
this.#mouse$$.pipe(
filter(({ type }) => type === 'mousedown'),
@ -234,8 +277,11 @@ export class EditorService extends Meta2d {
),
);
//#region 机器人组
//#region 机器人组管理
/** 机器人信息映射表,响应式存储所有机器人数据 */
readonly #robotMap = reactive<Map<RobotInfo['id'], RobotInfo>>(new Map());
/** 获取所有机器人信息数组 */
public get robots(): RobotInfo[] {
return Array.from(this.#robotMap.values());
}
@ -319,13 +365,18 @@ export class EditorService extends Meta2d {
}
//#endregion
/** 画布变化事件流,用于触发响应式数据更新 */
readonly #change$$ = new Subject<boolean>();
/** 当前选中的图形对象,响应式更新 */
public readonly current = useObservable<MapPen>(
this.#change$$.pipe(
debounceTime(100),
map(() => <MapPen>clone(this.store.active?.[0])),
),
);
/** 当前选中的图形ID列表响应式更新 */
public readonly selected = useObservable<string[], string[]>(
this.#change$$.pipe(
filter((v) => !v),
@ -334,6 +385,8 @@ export class EditorService extends Meta2d {
),
{ initialValue: new Array<string>() },
);
/** 画布上所有图形对象列表,响应式更新 */
public readonly pens = useObservable<MapPen[]>(
this.#change$$.pipe(
filter((v) => v),
@ -432,6 +485,7 @@ export class EditorService extends Meta2d {
//#endregion
//#region 点位
/** 画布上所有点位对象列表,响应式更新 */
public readonly points = useObservable<MapPen[], MapPen[]>(
this.#change$$.pipe(
filter((v) => v),
@ -447,6 +501,12 @@ export class EditorService extends Meta2d {
return { x: x + width / 2, y: y + height / 2, width, height };
}
/**
*
* @param p
* @param type
* @param id ID
*/
public async addPoint(p: Point, type = MapPointType., id?: string): Promise<void> {
id ||= s8();
const pen: MapPen = {
@ -503,6 +563,7 @@ export class EditorService extends Meta2d {
//#endregion
//#region 线路
/** 画布上所有路线对象列表,响应式更新,包含动态生成的标签 */
public readonly routes = useObservable<MapPen[], MapPen[]>(
this.#change$$.pipe(
filter((v) => v),
@ -525,6 +586,12 @@ export class EditorService extends Meta2d {
return `${p1.label}${(d ?? direction) > 0 ? '→' : '←'}${p2.label}`;
}
/**
* 线
* @param p
* @param type 线线
* @param id 线ID
*/
public addRoute(p: [MapPen, MapPen], type = MapRouteType.线, id?: string): void {
const [p1, p2] = p;
if (!p1?.anchors?.length || !p2?.anchors?.length) return;
@ -553,6 +620,7 @@ export class EditorService extends Meta2d {
//#endregion
//#region 区域
/** 画布上所有区域对象列表,响应式更新 */
public readonly areas = useObservable<MapPen[], MapPen[]>(
this.#change$$.pipe(
filter((v) => v),
@ -571,6 +639,13 @@ export class EditorService extends Meta2d {
});
}
/**
*
* @param p1
* @param p2
* @param type
* @param id ID
*/
public async addArea(p1: Point, p2: Point, type = MapAreaType., id?: string) {
const scale = this.data().scale ?? 1;
const w = Math.abs(p1.x - p2.x);
@ -621,13 +696,21 @@ export class EditorService extends Meta2d {
}
//#endregion
/**
* -
* @param container DOM元素
*/
constructor(container: HTMLDivElement) {
super(container, EDITOR_CONFIG);
// 禁用第6个子元素的拖放功能
(<HTMLDivElement>container.children.item(5)).ondrop = null;
// 监听所有画布事件
this.on('*', (e, v) => this.#listen(e, v));
// 注册自定义绘制函数和锚点
this.#register();
// 监听主题变化并重新加载样式
watch(
() => sTheme.theme,
(v) => this.#load(v),
@ -711,7 +794,12 @@ export class EditorService extends Meta2d {
}
}
//#region 绘制函数
//#region 自定义绘制函数
/**
*
* @param ctx Canvas 2D绘制上下文
* @param pen
*/
function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const theme = sTheme.editor;
const { active, iconSize: r = 0, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {};
@ -777,6 +865,10 @@ function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void {
ctx.fillText(label, x + w / 2, y - fontSize * lineHeight);
ctx.restore();
}
/**
*
* @param pen
*/
function anchorPoint(pen: MapPen): void {
pen.anchors = [
{ penId: pen.id, id: '0', x: 0.5, y: 0.5 },
@ -787,6 +879,11 @@ function anchorPoint(pen: MapPen): void {
];
}
/**
* 线
* @param ctx Canvas 2D绘制上下文
* @param pen 线
*/
function drawLine(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const theme = sTheme.editor;
const { active, lineWidth: s = 1 } = pen.calculative ?? {};
@ -874,6 +971,11 @@ function lineBezier3(_: Meta2dStore, pen: MapPen): void {
pen.calculative.worldAnchors[1].prev = { x: x2 + dx2 * s, y: y2 + dy2 * s };
}
/**
*
* @param ctx Canvas 2D绘制上下文
* @param pen
*/
function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const theme = sTheme.editor;
const { active, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {};
@ -895,6 +997,11 @@ function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void {
ctx.restore();
}
/**
*
* @param ctx Canvas 2D绘制上下文
* @param pen
*/
function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const theme = sTheme.editor;
const { lineWidth: s = 1 } = pen.calculative ?? {};

View File

@ -11,15 +11,29 @@ export default http;
// 添加请求拦截器
http.interceptors.request.use(
(config) => {
async (config) => {
try {
const token =
JSON.parse(localStorage.getItem('VWED_AMR调度系统__PRODUCTION__3.7.1__COMMON__LOCAL__KEY__') || '{}')?.value
.TOKEN__.value || '';
let token = '';
let tenantId = '';
// 开发环境处理逻辑
if (import.meta.env.DEV) {
try {
token = import.meta.env.ENV_DEV_TOKEN;
tenantId = import.meta.env.ENV_DEV_TENANT_ID;
} catch (error) {
console.error('获取开发环境token失败:', error);
}
} else {
// 生产环境直接从localStorage获取
const localStorageData = JSON.parse(
localStorage.getItem('VWED_AMR调度系统__PRODUCTION__3.7.1__COMMON__LOCAL__KEY__') || '{}',
);
token = localStorageData?.value?.TOKEN__?.value || '';
tenantId = localStorageData?.value?.TENANT_ID?.value || '';
}
config.headers['x-access-token'] = token;
const tenantId =
JSON.parse(localStorage.getItem('VWED_AMR调度系统__PRODUCTION__3.7.1__COMMON__LOCAL__KEY__') || '{}')?.value
.TENANT_ID.value || '';
config.headers['x-tenant-id'] = tenantId;
console.log(config);
} catch (error) {

View File

@ -1,8 +1,328 @@
// WebSocket全局配置
const WS_CONFIG = {
heartbeatInterval: 30000, // 30秒心跳间隔
heartbeatTimeout: 5000, // 心跳响应超时时间5秒
maxReconnectAttempts: 5, // 最大重连次数
reconnectBaseDelay: 1000, // 重连基础延迟1秒
maxReconnectDelay: 30000, // 最大重连延迟30秒
heartbeatMessage: 'ping', // 心跳消息
heartbeatResponseType: 'pong', // 心跳响应类型
};
// 增强的WebSocket包装器
class EnhancedWebSocket {
private ws: WebSocket;
private path: string;
private baseUrl: string;
private heartbeatTimer?: NodeJS.Timeout;
private heartbeatTimeoutTimer?: NodeJS.Timeout;
private reconnectTimer?: NodeJS.Timeout;
private reconnectAttempts: number = 0;
private isManualClose: boolean = false;
private isHeartbeatTimeout: boolean = false;
private userOnMessage: ((event: MessageEvent) => void) | null = null;
private userOnClose: ((event: CloseEvent) => void) | null = null;
private userOnError: ((event: Event) => void) | null = null;
private userOnOpen: ((event: Event) => void) | null = null;
constructor(path: string, baseUrl: string) {
this.path = path;
this.baseUrl = baseUrl;
this.ws = new WebSocket(baseUrl + path);
this.setupHandlers();
}
// 设置事件处理器
private setupHandlers(): void {
this.ws.onopen = (event) => {
console.log(`WebSocket连接已建立: ${this.path}`);
this.reconnectAttempts = 0;
this.clearReconnectTimer();
this.startHeartbeat();
if (this.userOnOpen) {
this.userOnOpen(event);
}
};
this.ws.onmessage = (event) => {
const messageData = event.data;
// 检查是否为心跳响应支持字符串和JSON格式
let isHeartbeatResponse = false;
// 1. 检查是否为简单字符串心跳响应
if (typeof messageData === 'string' && messageData === WS_CONFIG.heartbeatResponseType) {
isHeartbeatResponse = true;
}
// 2. 检查是否为JSON格式心跳响应
if (!isHeartbeatResponse && typeof messageData === 'string') {
try {
const data = JSON.parse(messageData);
if (data.type === WS_CONFIG.heartbeatResponseType) {
isHeartbeatResponse = true;
}
} catch (e) {
// JSON解析失败不是JSON格式的心跳响应
}
}
if (isHeartbeatResponse) {
// 收到心跳响应,清除超时定时器
this.clearHeartbeatTimeout();
return;
}
// 传递给业务代码
if (this.userOnMessage) {
this.userOnMessage(event);
}
};
this.ws.onclose = (event) => {
console.log(`WebSocket连接关闭: ${this.path}`, event.code, event.reason);
this.stopHeartbeat();
// 先调用业务代码的关闭处理
if (this.userOnClose) {
this.userOnClose(event);
}
// 如果不是手动关闭,或者是心跳超时导致的关闭,则重连
if (!this.isManualClose || this.isHeartbeatTimeout) {
this.scheduleReconnect();
}
// 重置心跳超时标志
this.isHeartbeatTimeout = false;
};
this.ws.onerror = (event) => {
console.error(`WebSocket连接错误: ${this.path}`, event);
this.stopHeartbeat();
if (this.userOnError) {
this.userOnError(event);
}
};
}
// 开始心跳检测
private startHeartbeat(): void {
this.stopHeartbeat();
console.log(`开始心跳检测: ${this.path}, 间隔: ${WS_CONFIG.heartbeatInterval}ms`);
this.heartbeatTimer = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(WS_CONFIG.heartbeatMessage);
// 只有在没有进行超时检测时才设置新的超时检测
if (!this.heartbeatTimeoutTimer) {
this.startHeartbeatTimeout();
}
}
}, WS_CONFIG.heartbeatInterval);
}
// 停止心跳检测
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
this.clearHeartbeatTimeout();
}
// 开始心跳响应超时检测
private startHeartbeatTimeout(): void {
// 不再自动清除,只在收到响应时清除
this.heartbeatTimeoutTimer = setTimeout(() => {
console.log(`心跳响应超时: ${this.path}, ${WS_CONFIG.heartbeatTimeout}ms内未收到响应主动断开连接`);
// 设置心跳超时标志,触发重连
this.isHeartbeatTimeout = true;
this.ws.close(1000, 'Heartbeat timeout'); // 使用正常关闭状态码,通过标志来判断是否重连
}, WS_CONFIG.heartbeatTimeout);
}
// 清除心跳响应超时检测
private clearHeartbeatTimeout(): void {
if (this.heartbeatTimeoutTimer) {
clearTimeout(this.heartbeatTimeoutTimer);
this.heartbeatTimeoutTimer = undefined;
}
}
// 安排重连
private scheduleReconnect(): void {
if (this.isManualClose || this.reconnectAttempts >= WS_CONFIG.maxReconnectAttempts) {
console.log(`停止重连: ${this.path}, 手动关闭: ${this.isManualClose}, 重连次数: ${this.reconnectAttempts}`);
return;
}
this.reconnectAttempts++;
// 指数退避重连策略
const delay = Math.min(
WS_CONFIG.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1),
WS_CONFIG.maxReconnectDelay,
);
console.log(
`WebSocket将在${delay}ms后重连: ${this.path} (${this.reconnectAttempts}/${WS_CONFIG.maxReconnectAttempts})`,
);
this.reconnectTimer = setTimeout(() => {
this.reconnect();
}, delay);
}
// 重连逻辑
private reconnect(): void {
if (this.isManualClose) return;
console.log(`WebSocket重连尝试: ${this.path} (${this.reconnectAttempts}/${WS_CONFIG.maxReconnectAttempts})`);
// 创建新的WebSocket连接
this.ws = new WebSocket(this.baseUrl + this.path);
this.setupHandlers();
}
// 清理重连定时器
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = undefined;
}
}
// 公开的WebSocket属性和方法
get readyState(): number {
return this.ws.readyState;
}
get url(): string {
return this.ws.url;
}
get protocol(): string {
return this.ws.protocol;
}
get extensions(): string {
return this.ws.extensions;
}
get bufferedAmount(): number {
return this.ws.bufferedAmount;
}
get binaryType(): BinaryType {
return this.ws.binaryType;
}
set binaryType(value: BinaryType) {
this.ws.binaryType = value;
}
// 事件处理器属性
get onopen(): ((event: Event) => void) | null {
return this.userOnOpen;
}
set onopen(handler: ((event: Event) => void) | null) {
this.userOnOpen = handler;
}
get onmessage(): ((event: MessageEvent) => void) | null {
return this.userOnMessage;
}
set onmessage(handler: ((event: MessageEvent) => void) | null) {
this.userOnMessage = handler;
}
get onclose(): ((event: CloseEvent) => void) | null {
return this.userOnClose;
}
set onclose(handler: ((event: CloseEvent) => void) | null) {
this.userOnClose = handler;
}
get onerror(): ((event: Event) => void) | null {
return this.userOnError;
}
set onerror(handler: ((event: Event) => void) | null) {
this.userOnError = handler;
}
// WebSocket方法
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
this.ws.send(data);
}
close(code?: number, reason?: string): void {
console.log(`手动关闭WebSocket: ${this.path}`);
this.isManualClose = true;
this.isHeartbeatTimeout = false; // 手动关闭时重置心跳超时标志
this.stopHeartbeat();
this.clearReconnectTimer();
this.ws.close(code, reason);
}
addEventListener<K extends keyof WebSocketEventMap>(
type: K,
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any,
options?: boolean | AddEventListenerOptions,
): void {
this.ws.addEventListener(type, listener, options);
}
removeEventListener<K extends keyof WebSocketEventMap>(
type: K,
listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any,
options?: boolean | EventListenerOptions,
): void {
this.ws.removeEventListener(type, listener, options);
}
dispatchEvent(event: Event): boolean {
return this.ws.dispatchEvent(event);
}
// 常量
static readonly CONNECTING = WebSocket.CONNECTING;
static readonly OPEN = WebSocket.OPEN;
static readonly CLOSING = WebSocket.CLOSING;
static readonly CLOSED = WebSocket.CLOSED;
readonly CONNECTING = WebSocket.CONNECTING;
readonly OPEN = WebSocket.OPEN;
readonly CLOSING = WebSocket.CLOSING;
readonly CLOSED = WebSocket.CLOSED;
}
function create(path: string): Promise<WebSocket> {
const ws = new WebSocket((import.meta.env.ENV_WEBSOCKET_BASE ?? '') + path);
const baseUrl = import.meta.env.ENV_WEBSOCKET_BASE ?? '';
const ws = new EnhancedWebSocket(path, baseUrl) as any;
return new Promise((resolve, reject) => {
ws.onopen = () => resolve(ws);
ws.onerror = (e) => reject(e);
const timeout = setTimeout(() => {
ws.close();
reject(new Error('WebSocket connection timeout'));
}, 10000); // 10秒连接超时
ws.addEventListener('open', () => {
clearTimeout(timeout);
resolve(ws);
});
ws.addEventListener('error', (e: any) => {
clearTimeout(timeout);
reject(e);
});
});
}
export default { create };

View File

@ -45,12 +45,12 @@ export default ({ mode }: Record<string, unknown>) =>
proxy: {
'/mocks/': { target: 'http://localhost:8888/web-amr' },
'/api/': {
target: 'http://82.156.39.91:18080/jeecg-boot',
target: 'http://192.168.189.206:8080/jeecg-boot',
rewrite: (path) => path.replace(/^\/api/, ''),
changeOrigin: true,
},
'/ws/': {
target: 'ws://82.156.39.91:18080/jeecg-boot',
target: 'ws://192.168.189.206:8080/jeecg-boot',
rewrite: (path) => path.replace(/^\/ws/, ''),
changeOrigin: true,
ws: true,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,632 @@
# 机器人运动监控组件详细分析
## 1. 组件架构概述
### 1.1 核心组件结构
`movement-supervision.vue` 是机器人运动监控的主要组件,负责实时显示机器人在场景中的位置和状态。
```typescript
// 组件核心属性
type Props = {
sid: string; // 场景ID
id?: string; // 机器人组ID可选
};
```
### 1.2 依赖服务架构
- **EditorService**: 基于Meta2D的场景编辑器服务
- **WebSocket服务**: 提供实时数据通信
- **场景API服务**: 处理场景数据的增删改查
## 2. 组件生命周期详解
### 2.1 组件初始化流程
```typescript
onMounted(async () => {
await readScene(); // 步骤1: 加载场景数据
await editor.value?.initRobots(); // 步骤2: 初始化机器人
await monitorScene(); // 步骤3: 建立WebSocket监控
});
```
#### 步骤1: readScene() - 场景数据加载
```typescript
const readScene = async () => {
const res = props.id ? await getSceneByGroupId(props.id, props.sid) : await getSceneById(props.sid);
title.value = res?.label ?? '';
editor.value?.load(res?.json);
};
```
**关键问题点**: 每个页面实例都独立调用API获取场景数据可能导致
- 不同时间点获取的数据版本不一致
- 网络延迟造成的数据获取时差
- 场景数据在获取期间被其他页面修改
#### 步骤2: initRobots() - 机器人初始化
```typescript
public async initRobots(): Promise<void> {
await Promise.all(
this.robots.map(async ({ id, label, type }) => {
const pen: MapPen = {
...this.#mapRobotImage(type, true),
id,
name: 'robot',
tags: ['robot'],
x: 0, // 关键: 初始位置固定为(0,0)
y: 0, // 关键: 初始位置固定为(0,0)
width: 74,
height: 74,
lineWidth: 1,
robot: { type },
visible: false, // 关键: 初始状态为不可见
text: label,
textTop: -24,
whiteSpace: 'nowrap',
ellipsis: false,
locked: LockState.Disable,
};
await this.addPen(pen, false, true, true);
}),
);
}
```
**问题分析**:
- 所有机器人初始位置都设为`(0,0)`
- 初始状态为`visible: false`需要WebSocket数据才能显示
- 如果WebSocket连接延迟不同页面的机器人可能长时间处于不可见状态
#### 步骤3: monitorScene() - WebSocket监控建立
```typescript
const monitorScene = async () => {
client.value?.close(); // 关闭之前的连接
const ws = await monitorSceneById(props.sid); // 创建新连接
if (isNil(ws)) return;
ws.onmessage = (e) => {
const { id, x, y, active, angle, path, ...rest } = <RobotRealtimeInfo>JSON.parse(e.data || '{}');
if (!editor.value?.checkRobotById(id)) return; // 验证机器人存在
editor.value?.updateRobot(id, rest); // 更新机器人基本信息
if (isNil(x) || isNil(y)) {
// 关键逻辑: 无位置信息时隐藏机器人
editor.value.updatePen(id, { visible: false });
} else {
// 关键逻辑: 有位置信息时更新位置并显示
editor.value.refreshRobot(id, { x, y, active, angle, path });
}
};
client.value = ws;
};
```
## 3. 机器人实时移动机制深度分析
### 3.1 WebSocket消息处理流程
每当接收到WebSocket消息时会执行以下处理逻辑
1. **消息解析**: 将JSON字符串解析为`RobotRealtimeInfo`对象
2. **机器人验证**: 调用`checkRobotById(id)`验证机器人是否存在
3. **基本信息更新**: 调用`updateRobot(id, rest)`更新电量、状态等信息
4. **位置处理**: 根据坐标是否存在进行不同处理
### 3.2 位置更新核心逻辑: refreshRobot()
```typescript
public refreshRobot(id: RobotInfo['id'], info: Partial<RobotRealtimeInfo>): void {
const pen = this.getPenById(id);
const { rotate: or, robot } = pen ?? {};
if (!robot?.type) return;
// 获取当前机器人位置
const { x: ox, y: oy } = this.getPenRect(pen!);
// 解析新的位置信息默认值为37,37是机器人中心点
const { x: cx = 37, y: cy = 37, active, angle, path: points } = info;
// 关键坐标转换: 从中心点坐标转换为左上角坐标
const x = cx - 37; // 机器人宽度74中心偏移37
const y = cy - 37; // 机器人高度74中心偏移37
const rotate = angle ?? or; // 角度更新
// 路径坐标转换
const path =
points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ?? // 新路径相对于机器人中心
robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y })); // 旧路径坐标调整
const o = { ...robot, ...omitBy({ active, path }, isNil) };
if (isNil(active)) {
// active为null时只更新位置不改变图标
this.setValue(
{ id, x, y, rotate, robot: o, visible: true },
{ render: true, history: false, doEvent: false }
);
} else {
// active有值时同时更新图标状态运行/停止状态图标不同)
this.setValue(
{ id, ...this.#mapRobotImage(robot.type, active), x, y, rotate, robot: o, visible: true },
{ render: true, history: false, doEvent: false }
);
}
}
```
### 3.3 机器人图标映射逻辑
```typescript
#mapRobotImage(
type: RobotType,
active?: boolean,
): Required<Pick<MapPen, 'image' | 'iconWidth' | 'iconHeight' | 'iconTop'>> {
const theme = this.data().theme;
const image = import.meta.env.BASE_URL +
(active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`);
return {
image,
iconWidth: 34,
iconHeight: 54,
iconTop: -5
};
}
```
### 3.4 机器人绘制函数
```typescript
function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const theme = sTheme.editor;
const { lineWidth: s = 1 } = pen.calculative ?? {};
const { x = 0, y = 0, width: w = 0, height: h = 0, rotate: deg = 0 } = pen.calculative?.worldRect ?? {};
const { active, path } = pen.robot ?? {};
if (!active) return; // 关键: 非活跃状态不绘制路径
const ox = x + w / 2; // 机器人中心X坐标
const oy = y + h / 2; // 机器人中心Y坐标
ctx.save();
// 绘制机器人本体(椭圆)
ctx.ellipse(ox, oy, w / 2, h / 2, 0, 0, Math.PI * 2);
ctx.fillStyle = get(theme, 'robot.fill') ?? '';
ctx.fill();
ctx.strokeStyle = get(theme, 'robot.stroke') ?? '';
ctx.stroke();
// 绘制运动路径
if (path?.length) {
ctx.strokeStyle = get(theme, 'robot.line') ?? '';
ctx.lineCap = 'round';
ctx.lineWidth = s * 4;
ctx.setLineDash([s * 5, s * 10]); // 虚线样式
ctx.translate(ox, oy);
ctx.rotate((-deg * Math.PI) / 180); // 根据机器人角度旋转
// 绘制路径线条
ctx.beginPath();
ctx.moveTo(0, 0);
path.forEach((d) => ctx.lineTo(d.x * s, d.y * s));
ctx.stroke();
// 绘制路径终点箭头
const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {};
const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {};
const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI;
ctx.setLineDash([0]);
ctx.translate(ex1 * s, ey1 * s);
ctx.beginPath();
ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10);
ctx.lineTo(0, 0);
ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10);
ctx.stroke();
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
ctx.restore();
}
```
## 4. 多页面位置不一致问题深度分析
### 4.1 根本原因:缺乏全局状态同步机制
每个页面实例都是完全独立的,具体表现为:
1. **独立的EditorService实例**
- 每个页面创建独立的`new EditorService(container.value!)`
- 各自维护独立的机器人状态映射`#robotMap`
- 无法共享机器人位置信息
2. **独立的WebSocket连接**
- 每个页面调用`monitorSceneById(props.sid)`创建独立连接
- 服务器可能向不同连接推送不同时间点的数据
- 网络延迟导致消息到达时间不同
### 4.2 具体问题场景分析
#### 场景1: 初始化时间差异
```typescript
// 页面A在时间T1执行
onMounted(async () => {
await readScene(); // T1时刻的场景数据
await initRobots(); // 创建机器人,位置(0,0)visible:false
await monitorScene(); // T1+100ms建立WebSocket
});
// 页面B在时间T2执行T2 > T1
onMounted(async () => {
await readScene(); // T2时刻的场景数据可能已更新
await initRobots(); // 创建机器人,位置(0,0)visible:false
await monitorScene(); // T2+80ms建立WebSocket
});
```
**结果**: 两个页面获取的初始场景数据可能不同,机器人列表或配置存在差异。
#### 场景2: WebSocket消息时序差异
```typescript
// WebSocket消息处理逻辑
ws.onmessage = (e) => {
const { id, x, y, active, angle, path, ...rest } = JSON.parse(e.data || '{}');
if (isNil(x) || isNil(y)) {
// 关键问题: 无坐标消息会隐藏机器人
editor.value.updatePen(id, { visible: false });
} else {
editor.value.refreshRobot(id, { x, y, active, angle, path });
}
};
```
**问题分析**:
- 页面A先收到有坐标的消息机器人显示在位置(100, 200)
- 页面B后收到无坐标的消息机器人被隐藏
- 页面C收到旧的坐标消息机器人显示在位置(80, 150)
#### 场景3: 坐标转换精度问题
```typescript
// refreshRobot中的坐标转换
const { x: cx = 37, y: cy = 37, active, angle, path: points } = info;
const x = cx - 37; // 默认值37导致的问题
const y = cy - 37;
// 当服务器发送的坐标为null/undefined时
// cx和cy都会使用默认值37导致机器人位置为(0,0)
```
**问题**: 不同页面接收到的消息中坐标字段可能为`null``undefined`或有效数值,默认值处理导致位置计算不一致。
#### 场景4: 机器人状态检查差异
```typescript
if (!editor.value?.checkRobotById(id)) return;
// checkRobotById实现
public checkRobotById(id: RobotInfo['id']): boolean {
return this.#robotMap.has(id);
}
```
**问题**: 不同页面的`#robotMap`内容可能不同,导致某些页面忽略特定机器人的更新消息。
### 4.3 路径绘制不一致问题
```typescript
// 路径坐标转换逻辑
const path =
points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ?? // 新路径处理
robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y })); // 旧路径处理
```
**问题分析**:
1. 新路径使用`p.x - cx, p.y - cy`进行坐标转换
2. 旧路径使用`p.x + ox! - x, p.y + oy! - y`进行坐标转换
3. 两种转换方式在特定情况下可能产生不同结果
4. 不同页面可能处于新旧路径的不同阶段
### 4.4 渲染状态不同步
```typescript
// setValue方法的渲染参数
this.setValue(
{ id, x, y, rotate, robot: o, visible: true },
{ render: true, history: false, doEvent: false }, // 立即渲染,不记录历史
);
```
**问题**:
- `render: true`表示立即重新渲染
- 不同页面的渲染时机不同步
- 可能出现某个页面正在渲染时收到新消息的情况
## 5. 解决方案详细设计
### 5.1 方案一: 全局状态管理器
```typescript
/**
* 全局机器人状态管理器
* 单例模式,确保所有页面共享同一份状态
*/
class GlobalRobotStateManager {
private static instance: GlobalRobotStateManager;
// 存储所有机器人的最新状态
private robotStates = new Map<string, RobotRealtimeInfo>();
// 订阅者列表,用于通知状态变化
private subscribers = new Set<(robotId: string, info: RobotRealtimeInfo) => void>();
// 连接管理,避免重复连接
private connections = new Map<string, WebSocket>();
static getInstance(): GlobalRobotStateManager {
if (!this.instance) {
this.instance = new GlobalRobotStateManager();
}
return this.instance;
}
/**
* 订阅机器人状态变化
*/
subscribe(callback: (robotId: string, info: RobotRealtimeInfo) => void): () => void {
this.subscribers.add(callback);
// 立即推送当前所有机器人状态
this.robotStates.forEach((info, robotId) => {
callback(robotId, info);
});
// 返回取消订阅函数
return () => this.subscribers.delete(callback);
}
/**
* 更新机器人状态并通知所有订阅者
*/
updateRobotState(robotId: string, info: RobotRealtimeInfo): void {
// 合并状态更新
const currentState = this.robotStates.get(robotId) || ({} as RobotRealtimeInfo);
const newState = { ...currentState, ...info };
this.robotStates.set(robotId, newState);
// 通知所有订阅者
this.subscribers.forEach((callback) => {
try {
callback(robotId, newState);
} catch (error) {
console.error('机器人状态更新回调执行失败:', error);
}
});
}
/**
* 获取或创建WebSocket连接复用连接
*/
async getOrCreateConnection(sceneId: string): Promise<WebSocket | null> {
// 检查现有连接
const existingConnection = this.connections.get(sceneId);
if (existingConnection && existingConnection.readyState === WebSocket.OPEN) {
return existingConnection;
}
try {
const ws = await monitorSceneById(sceneId);
if (!ws) return null;
// 设置消息处理
ws.onmessage = (e) => {
try {
const robotInfo = JSON.parse(e.data || '{}') as RobotRealtimeInfo;
this.updateRobotState(robotInfo.id, robotInfo);
} catch (error) {
console.error('WebSocket消息解析失败:', error);
}
};
// 设置连接关闭处理
ws.onclose = () => {
this.connections.delete(sceneId);
};
// 存储连接
this.connections.set(sceneId, ws);
return ws;
} catch (error) {
console.error('创建WebSocket连接失败:', error);
return null;
}
}
}
```
### 5.2 方案二: 改进的组件实现
```typescript
// 改进后的movement-supervision.vue核心逻辑
<script setup lang="ts">
import { GlobalRobotStateManager } from '@/services/global-robot-state';
const props = defineProps<Props>();
const globalStateManager = GlobalRobotStateManager.getInstance();
// 移除原有的monitorScene函数改用全局状态管理
const initializeMonitoring = async () => {
// 确保WebSocket连接存在
await globalStateManager.getOrCreateConnection(props.sid);
// 订阅机器人状态变化
const unsubscribe = globalStateManager.subscribe((robotId, robotInfo) => {
if (!editor.value?.checkRobotById(robotId)) return;
// 更新机器人基本信息
const { id, x, y, active, angle, path, ...rest } = robotInfo;
editor.value.updateRobot(id, rest);
// 处理位置更新
if (isNil(x) || isNil(y)) {
editor.value.updatePen(id, { visible: false });
} else {
editor.value.refreshRobot(id, { x, y, active, angle, path });
}
});
// 组件卸载时取消订阅
onUnmounted(() => {
unsubscribe();
});
};
onMounted(async () => {
await readScene();
await editor.value?.initRobots();
await initializeMonitoring(); // 使用改进的初始化方法
});
</script>
```
### 5.3 方案三: EditorService增强
```typescript
// 为EditorService添加状态缓存和同步机制
export class EditorService extends Meta2d {
// 添加状态缓存
private robotStateCache = new Map<string, RobotRealtimeInfo>();
/**
* 改进的坐标转换方法
*/
private normalizeCoordinates(info: Partial<RobotRealtimeInfo>): { x: number; y: number } | null {
const { x: cx, y: cy } = info;
// 严格的坐标验证
if (typeof cx !== 'number' || typeof cy !== 'number' || isNaN(cx) || isNaN(cy) || cx < 0 || cy < 0) {
return null; // 返回null表示无效坐标
}
// 坐标转换:从中心点转换为左上角
return {
x: cx - 37, // 机器人宽度74中心偏移37
y: cy - 37, // 机器人高度74中心偏移37
};
}
/**
* 改进的refreshRobot方法
*/
public refreshRobot(id: RobotInfo['id'], info: Partial<RobotRealtimeInfo>): void {
const pen = this.getPenById(id);
const { rotate: or, robot } = pen ?? {};
if (!robot?.type) return;
// 使用改进的坐标转换
const coords = this.normalizeCoordinates(info);
// 无效坐标处理
if (!coords) {
this.setValue({ id, visible: false }, { render: true, history: false, doEvent: false });
return;
}
const { x, y } = coords;
const { active, angle, path: points } = info;
const rotate = angle ?? or;
// 路径处理优化
let path: Point[] | undefined;
if (points && Array.isArray(points)) {
// 新路径:相对于机器人中心的坐标
path = points.map((p) => ({
x: (p.x || 0) - (info.x || 37),
y: (p.y || 0) - (info.y || 37),
}));
} else if (robot.path) {
// 保持原有路径,但需要调整坐标
const { x: ox, y: oy } = this.getPenRect(pen!);
path = robot.path.map((p) => ({
x: p.x + ox - x,
y: p.y + oy - y,
}));
}
const robotState = { ...robot, ...omitBy({ active, path }, isNil) };
// 根据active状态决定渲染方式
if (typeof active === 'boolean') {
// 有明确的活跃状态,更新图标
this.setValue(
{
id,
...this.#mapRobotImage(robot.type, active),
x,
y,
rotate,
robot: robotState,
visible: true,
},
{ render: true, history: false, doEvent: false },
);
} else {
// 无活跃状态信息,只更新位置
this.setValue(
{ id, x, y, rotate, robot: robotState, visible: true },
{ render: true, history: false, doEvent: false },
);
}
}
}
```
## 6. 性能优化建议
### 6.1 渲染优化
- 使用`requestAnimationFrame`批量处理渲染更新
- 实现视口裁剪,只渲染可见区域的机器人
- 添加机器人状态变化的diff检测避免无效渲染
### 6.2 内存管理
- 定期清理过期的机器人状态缓存
- 使用WeakMap存储临时状态避免内存泄漏
- 在组件卸载时正确清理WebSocket连接和事件监听器
### 6.3 网络优化
- 实现WebSocket连接池复用连接
- 添加消息压缩,减少网络传输量
- 使用心跳机制检测连接状态
## 7. 总结
机器人运动监控组件的多页面位置不一致问题主要源于:
1. **架构设计缺陷**: 缺乏全局状态管理,每个页面独立维护状态
2. **WebSocket连接独立性**: 多个连接可能接收到不同时间点的数据
3. **初始化时序问题**: 不同页面的初始化时间不同,导致状态基线不一致
4. **坐标转换逻辑**: 默认值处理和坐标转换在边界情况下存在问题
5. **状态验证不足**: 缺乏对接收数据的有效性验证
通过实施全局状态管理、WebSocket连接复用、状态缓存机制和坐标转换优化等解决方案可以有效解决这些问题确保多页面间机器人位置的一致性。

View File

@ -0,0 +1,677 @@
# 编辑器服务核心架构分析
## 1. 概述
`EditorService` 是整个场景编辑器的核心服务类,继承自 `Meta2d` 图形引擎。它负责管理场景中的所有元素(机器人、点位、路线、区域),处理用户交互,以及场景数据的序列化和反序列化。
```typescript
export class EditorService extends Meta2d {
// 继承 Meta2d 获得强大的 2D 图形渲染能力
}
```
## 2. 核心架构分析
### 2.1 继承架构
```
EditorService
↓ 继承
Meta2d (第三方图形引擎)
↓ 提供
- Canvas 渲染能力
- 图形元素管理
- 事件系统
- 坐标变换
- 撤销重做
```
### 2.2 核心组成模块
1. **场景文件管理** - 序列化/反序列化
2. **机器人管理** - 机器人组和个体管理
3. **点位管理** - 各种类型点位的创建和管理
4. **路线管理** - 连接点位的路径管理
5. **区域管理** - 矩形区域的创建和管理
6. **实时交互** - 鼠标事件处理和状态管理
7. **自定义绘制** - Canvas 绘制函数
8. **事件监听** - 编辑器状态变化监听
## 3. 场景文件管理详解
### 3.1 场景数据结构
```typescript
type StandardScene = {
robotGroups?: RobotGroup[]; // 机器人组
robots?: RobotInfo[]; // 机器人列表
points?: StandardScenePoint[]; // 点位数据
routes?: StandardSceneRoute[]; // 路线数据
areas?: StandardSceneArea[]; // 区域数据
blocks?: any[]; // 其他块数据
};
```
### 3.2 场景加载过程(为什么场景文件能生成对应区域)
#### 3.2.1 加载入口函数
```typescript
public async load(map?: string, editable = false, detail?: Partial<GroupSceneDetail>): Promise<void> {
// 1. 解析 JSON 字符串为场景对象
const scene: StandardScene = map ? JSON.parse(map) : {};
// 2. 如果有组详情,优先使用组数据
if (!isEmpty(detail?.group)) {
scene.robotGroups = [detail.group];
scene.robots = detail.robots;
}
// 3. 提取各类数据
const { robotGroups, robots, points, routes, areas } = scene;
// 4. 初始化编辑器
this.open(); // 打开 Meta2d 画布
this.setState(editable); // 设置编辑状态
// 5. 按顺序加载各类元素
this.#loadRobots(robotGroups, robots); // 加载机器人
await this.#loadScenePoints(points); // 加载点位
this.#loadSceneRoutes(routes); // 加载路线
await this.#loadSceneAreas(areas); // 加载区域 ⭐
// 6. 清空历史记录
this.store.historyIndex = undefined;
this.store.histories = [];
}
```
#### 3.2.2 区域加载详细过程
```typescript
async #loadSceneAreas(areas?: StandardSceneArea[]): Promise<void> {
if (!areas?.length) return;
// 并行处理所有区域
await Promise.all(
areas.map(async (v) => {
// 1. 从场景数据中提取区域信息
const { id, name, desc, x, y, w, h, type, points, routes, properties } = v;
// 2. 调用 addArea 方法在画布上创建实际的图形对象
await this.addArea(
{ x, y }, // 左上角坐标
{ x: x + w, y: y + h }, // 右下角坐标
type, // 区域类型
id // 区域ID
);
// 3. 设置区域的详细属性
this.setValue(
{
id,
label: name, // 显示名称
desc, // 描述
properties, // 自定义属性
area: { type, points, routes } // 区域特定数据
},
{ render: false, history: false, doEvent: false }
);
})
);
}
```
**关键理解点**
- 场景文件中的 `areas` 数组包含了所有区域的完整信息
- 每个区域包含位置 `(x, y, w, h)`、类型 `type`、关联的点位和路线
- `addArea` 方法负责在 Canvas 上创建实际的可视化图形
- `setValue` 方法设置图形对象的业务属性
## 4. 区域绘制原理详解(为什么可以在页面画一个区域)
### 4.1 鼠标事件监听系统
```typescript
// 鼠标事件主题
readonly #mouse$$ = new Subject<{ type: 'click' | 'mousedown' | 'mouseup'; value: Point }>();
// 点击事件流
public readonly mouseClick = useObservable<Point>(
this.#mouse$$.pipe(
filter(({ type }) => type === 'click'),
debounceTime(100),
map(({ value }) => value),
),
);
// 拖拽事件流 ⭐ 关键!这是画区域的核心
public readonly mouseBrush = useObservable<[Point, Point]>(
this.#mouse$$.pipe(
filter(({ type }) => type === 'mousedown'), // 监听鼠标按下
switchMap(({ value: s }) =>
this.#mouse$$.pipe(
filter(({ type }) => type === 'mouseup'), // 监听鼠标抬起
map(({ value: e }) => <[Point, Point]>[s, e]), // 返回起始和结束点
),
),
),
);
```
### 4.2 工具栏组件中的区域创建监听
```typescript
// 在 EditorToolbar 组件中
const mode = ref<MapAreaType>();
// 监听鼠标拖拽事件
watch(editor.value.mouseBrush, (v) => {
if (!mode.value) return; // 如果没有选择区域工具,不处理
const [p1, p2] = v ?? []; // 获取起始点和结束点
if (isEmpty(p1) || isEmpty(p2)) return; // 验证点位有效性
// 调用编辑器服务创建区域 ⭐
editor.value.addArea(p1, p2, mode.value);
mode.value = undefined; // 重置工具状态
});
```
### 4.3 addArea 方法详细实现
```typescript
public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string) {
// 1. 获取当前缩放比例
const scale = this.data().scale ?? 1;
// 2. 计算区域宽高
const w = Math.abs(p1.x - p2.x);
const h = Math.abs(p1.y - p2.y);
// 3. 最小尺寸检查(防止创建过小的区域)
if (w * scale < 50 || h * scale < 60) return;
// 4. 准备关联数据
const points = new Array<string>();
const routes = new Array<string>();
if (!id) {
id = s8(); // 生成唯一ID
const selected = <MapPen[]>this.store.active; // 获取当前选中的元素
// 5. 根据区域类型自动关联相关元素
switch (type) {
case MapAreaType.库区:
// 库区只关联动作点
selected?.filter(({ point }) => point?.type === MapPointType.动作点)
.forEach(({ id }) => points.push(id!));
break;
case MapAreaType.互斥区:
// 互斥区关联所有点位和路线
selected?.filter(({ point }) => point?.type)
.forEach(({ id }) => points.push(id!));
selected?.filter(({ route }) => route?.type)
.forEach(({ id }) => routes.push(id!));
break;
case MapAreaType.非互斥区:
// 非互斥区只关联点位
selected?.filter(({ point }) => point?.type)
.forEach(({ id }) => points.push(id!));
break;
}
}
// 6. 创建区域图形对象
const pen: MapPen = {
id,
name: 'area', // 图形类型标识
tags: ['area', `area-${type}`], // 标签用于查找和分类
label: `A${id}`, // 显示标签
x: Math.min(p1.x, p2.x), // 左上角 X
y: Math.min(p1.y, p2.y), // 左上角 Y
width: w, // 宽度
height: h, // 高度
lineWidth: 1, // 边框宽度
area: { type, points, routes }, // 区域业务数据
locked: LockState.DisableMoveScale, // 锁定状态(禁止移动缩放)
};
// 7. 添加到画布并设置层级
const area = await this.addPen(pen, true, true, true);
this.bottom(area); // 将区域放到最底层
}
```
**关键理解点**
1. **事件流处理**:通过 RxJS 的事件流来处理鼠标拖拽
2. **坐标计算**:将鼠标坐标转换为画布坐标系中的区域
3. **图形对象创建**:创建符合 Meta2d 要求的图形对象
4. **层级管理**:区域作为背景层,放在最底层
5. **状态管理**:自动关联当前选中的相关元素
## 5. 自定义绘制系统
### 5.1 绘制函数注册
```typescript
#register() {
// 注册基础图形
this.register({ line: () => new Path2D() });
// 注册自定义绘制函数 ⭐
this.registerCanvasDraw({
point: drawPoint, // 点位绘制
line: drawLine, // 路线绘制
area: drawArea, // 区域绘制 ⭐
robot: drawRobot // 机器人绘制
});
// 注册锚点
this.registerAnchors({ point: anchorPoint });
// 注册线条绘制函数
this.addDrawLineFn('bezier2', lineBezier2);
this.addDrawLineFn('bezier3', lineBezier3);
}
```
### 5.2 区域绘制函数详解
```typescript
function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void {
// 1. 获取主题配置
const theme = sTheme.editor;
// 2. 获取绘制参数
const { active, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {};
const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {};
const { type } = pen.area ?? {};
const { label = '' } = pen ?? {};
// 3. 开始绘制
ctx.save();
// 4. 绘制矩形区域
ctx.rect(x, y, w, h);
// 5. 填充颜色(根据区域类型)
ctx.fillStyle = get(theme, `area.fill-${type}`) ?? '';
ctx.fill();
// 6. 绘制边框(根据激活状态)
ctx.strokeStyle = get(theme, active ? 'area.strokeActive' : `area.stroke-${type}`) ?? '';
ctx.stroke();
// 7. 绘制标签文字
ctx.fillStyle = get(theme, 'color') ?? '';
ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(label, x + w / 2, y - fontSize * lineHeight);
ctx.restore();
}
```
**关键理解点**
- Canvas 2D API 直接绘制矩形和文字
- 主题系统提供颜色配置
- 根据区域类型和激活状态使用不同的样式
- 文字标签显示在区域上方
## 6. 响应式状态管理
### 6.1 数据流设计
```typescript
// 变化事件主题
readonly #change$$ = new Subject<boolean>();
// 区域列表响应式数据
public readonly areas = useObservable<MapPen[], MapPen[]>(
this.#change$$.pipe(
filter((v) => v), // 只响应数据变化事件
debounceTime(100), // 防抖处理
map(() => this.find('area')), // 查找所有区域
),
{ initialValue: new Array<MapPen>() },
);
// 当前选中元素
public readonly current = useObservable<MapPen>(
this.#change$$.pipe(
debounceTime(100),
map(() => <MapPen>clone(this.store.active?.[0])),
),
);
```
### 6.2 事件监听系统
```typescript
#listen(e: unknown, v: any) {
switch (e) {
case 'opened':
this.#load(sTheme.theme);
this.#change$$.next(true); // 触发数据更新
break;
case 'add':
this.#change$$.next(true); // 元素添加后更新
break;
case 'delete':
this.#onDelete(v);
this.#change$$.next(true); // 元素删除后更新
break;
case 'update':
case 'valueUpdate':
this.#change$$.next(true); // 元素更新后更新
break;
case 'active':
case 'inactive':
this.#change$$.next(false); // 选择状态变化
break;
case 'click':
case 'mousedown':
case 'mouseup':
// 将鼠标事件传递给事件流
this.#mouse$$.next({ type: e, value: pick(v, 'x', 'y') });
break;
}
}
```
## 7. 场景保存原理
### 7.1 保存入口函数
```typescript
public save(): string {
// 1. 构建标准场景对象
const scene: StandardScene = {
robotGroups: this.robotGroups.value,
robots: this.robots,
// 2. 将画布上的图形对象转换为标准格式
points: this.points.value.map((v) => this.#mapScenePoint(v)).filter((v) => !isNil(v)),
routes: this.routes.value.map((v) => this.#mapSceneRoute(v)).filter((v) => !isNil(v)),
areas: this.areas.value.map((v) => this.#mapSceneArea(v)).filter((v) => !isNil(v)), // ⭐
blocks: [],
};
// 3. 序列化为 JSON 字符串
return JSON.stringify(scene);
}
```
### 7.2 区域数据映射
```typescript
#mapSceneArea(pen: MapPen): StandardSceneArea | null {
if (!pen.id || isEmpty(pen.area)) return null;
// 1. 提取基础信息
const { id, label, desc, properties } = pen;
const { type, points, routes } = pen.area;
// 2. 获取区域的实际位置和尺寸
const { x, y, width, height } = this.getPenRect(pen);
// 3. 构建标准区域对象
const area: StandardSceneArea = {
id,
name: label || id,
desc,
x, // 左上角 X 坐标
y, // 左上角 Y 坐标
w: width, // 宽度
h: height, // 高度
type, // 区域类型
config: {},
properties,
};
// 4. 根据区域类型设置关联数据
if (MapAreaType.库区 === type) {
// 库区只保存动作点
area.points = points?.filter((v) =>
this.getPenById(v)?.point?.type === MapPointType.动作点
);
}
if ([MapAreaType.互斥区, MapAreaType.非互斥区].includes(type)) {
// 互斥区和非互斥区保存所有非禁行点
area.points = points?.filter((v) => {
const { point } = this.getPenById(v) ?? {};
if (isNil(point)) return false;
if (point.type === MapPointType.禁行点) return false;
return true;
});
}
if (MapAreaType.互斥区 === type) {
// 互斥区还要保存关联的路线
area.routes = routes?.filter((v) => !isEmpty(this.getPenById(v)?.area));
}
return area;
}
```
## 8. 机器人管理系统
### 8.1 机器人数据结构
```typescript
// 机器人映射表(响应式)
readonly #robotMap = reactive<Map<RobotInfo['id'], RobotInfo>>(new Map());
// 机器人组流
readonly #robotGroups$$ = new BehaviorSubject<RobotGroup[]>([]);
public readonly robotGroups = useObservable<RobotGroup[]>(
this.#robotGroups$$.pipe(debounceTime(300))
);
```
### 8.2 实时机器人更新
```typescript
public refreshRobot(id: RobotInfo['id'], info: Partial<RobotRealtimeInfo>): void {
const pen = this.getPenById(id);
const { rotate: or, robot } = pen ?? {};
if (!robot?.type) return;
// 1. 获取当前位置
const { x: ox, y: oy } = this.getPenRect(pen!);
// 2. 解析实时数据
const { x: cx = 37, y: cy = 37, active, angle, path: points } = info;
// 3. 计算新位置(机器人中心点偏移)
const x = cx - 37;
const y = cy - 37;
const rotate = angle ?? or;
// 4. 处理路径数据
const path = points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ??
robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y }));
// 5. 更新机器人状态
const o = { ...robot, ...omitBy({ active, path }, isNil) };
// 6. 根据激活状态使用不同的更新策略
if (isNil(active)) {
// 只更新位置和路径
this.setValue(
{ id, x, y, rotate, robot: o, visible: true },
{ render: true, history: false, doEvent: false }
);
} else {
// 同时更新图片资源
this.setValue(
{
id,
...this.#mapRobotImage(robot.type, active),
x, y, rotate, robot: o, visible: true
},
{ render: true, history: false, doEvent: false }
);
}
}
```
## 9. 点位和路线管理
### 9.1 点位创建
```typescript
public async addPoint(p: Point, type = MapPointType.普通点, id?: string): Promise<void> {
id ||= s8();
// 1. 创建点位图形对象
const pen: MapPen = {
...p, // 坐标
...this.#mapPoint(type), // 尺寸配置
...this.#mapPointImage(type), // 图片配置
id,
name: 'point',
tags: ['point'],
label: `P${id}`,
point: { type },
};
// 2. 调整坐标到中心点
pen.x! -= pen.width! / 2;
pen.y! -= pen.height! / 2;
// 3. 添加到画布
await this.addPen(pen, false, true, true);
}
```
### 9.2 路线创建
```typescript
public addRoute(p: [MapPen, MapPen], type = MapRouteType.直线, id?: string): void {
const [p1, p2] = p;
if (!p1?.anchors?.length || !p2?.anchors?.length) return;
// 1. 连接两个点位
const line = this.connectLine(p1, p2, undefined, undefined, false);
// 2. 设置ID
id ||= line.id!;
this.changePenId(line.id!, id);
// 3. 设置路线属性
const pen: MapPen = { tags: ['route'], route: { type }, lineWidth: 1 };
this.setValue({ id, ...pen }, { render: false, history: false, doEvent: false });
// 4. 更新线条类型
this.updateLineType(line, type);
// 5. 选中并渲染
this.active(id);
this.render();
}
```
## 10. 主题系统集成
### 10.1 主题响应
```typescript
// 监听主题变化
watch(
() => sTheme.theme,
(v) => this.#load(v),
{ immediate: true },
);
#load(theme: string): void {
// 1. 设置 Meta2d 主题
this.setTheme(theme);
// 2. 更新编辑器配置
this.setOptions({ color: get(sTheme.editor, 'color') });
// 3. 更新所有点位图片
this.find('point').forEach((pen) => {
if (!pen.point?.type) return;
if (pen.point.type < 10) return;
this.canvas.updateValue(pen, this.#mapPointImage(pen.point.type));
});
// 4. 更新所有机器人图片
this.find('robot').forEach((pen) => {
if (!pen.robot?.type) return;
this.canvas.updateValue(pen, this.#mapRobotImage(pen.robot.type, pen.robot.active));
});
// 5. 重新渲染
this.render();
}
```
## 11. 性能优化策略
### 11.1 防抖处理
```typescript
// 所有响应式数据都使用防抖
debounceTime(100); // 100ms 防抖
debounceTime(300); // 300ms 防抖(机器人组)
```
### 11.2 浅层响应式
```typescript
// 使用 shallowRef 避免深度响应式
const editor = shallowRef<EditorService>();
```
### 11.3 并行处理
```typescript
// 场景加载时并行处理
await Promise.all(
areas.map(async (v) => {
await this.addArea(/* ... */);
}),
);
```
## 12. 总结
### 12.1 画区域的完整流程
1. **工具选择**:用户点击工具栏的区域工具,设置 `mode`
2. **鼠标监听**`mouseBrush` 流监听鼠标拖拽事件
3. **坐标获取**:获取拖拽的起始点和结束点
4. **区域创建**:调用 `addArea` 方法创建区域对象
5. **画布绘制**`drawArea` 函数在 Canvas 上绘制实际图形
6. **状态更新**:触发响应式数据更新,通知 Vue 组件
### 12.2 场景文件生成区域的完整流程
1. **文件解析**:将 JSON 字符串解析为 `StandardScene` 对象
2. **数据提取**:从 `areas` 数组中提取每个区域的信息
3. **图形创建**:调用 `addArea` 方法在画布上创建图形对象
4. **属性设置**:通过 `setValue` 设置业务属性
5. **绘制渲染**:自定义绘制函数在 Canvas 上渲染图形
### 12.3 架构优势
1. **分层设计**:业务逻辑与图形引擎分离
2. **响应式驱动**:状态变化自动更新 UI
3. **事件流处理**RxJS 提供强大的异步事件处理
4. **自定义绘制**:完全控制图形的渲染效果
5. **类型安全**TypeScript 提供完整的类型检查
这个编辑器服务是一个设计精良的复杂系统,通过合理的架构设计实现了强大的场景编辑功能。

165
自动同步仓库.ps1 Normal file
View File

@ -0,0 +1,165 @@
# Git多仓库同步脚本 (PowerShell)
# 用于同步镜像仓库到你的个人仓库
param(
[switch]$UseRebase = $false,
[switch]$DryRun = $false
)
# 设置控制台编码以正确显示中文
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
# 设置Git配置以正确处理中文
$env:LC_ALL = "zh_CN.UTF-8"
function Write-ColorOutput {
param(
[string]$Message,
[string]$Color = "White"
)
Write-Host $Message -ForegroundColor $Color
}
function Check-GitStatus {
$status = git status --porcelain
if ($status) {
Write-ColorOutput "警告: 工作目录不干净,请先提交或储藏你的更改!" "Yellow"
git status
return $false
}
return $true
}
function Sync-Repositories {
Write-ColorOutput "开始同步镜像仓库..." "Cyan"
# 检查工作目录状态
if (-not (Check-GitStatus)) {
if (-not $DryRun) {
return
}
}
# 获取镜像仓库最新代码
Write-ColorOutput "正在获取upstream最新代码..." "Green"
if (-not $DryRun) {
git fetch upstream
if ($LASTEXITCODE -ne 0) {
Write-ColorOutput "获取upstream代码失败" "Red"
return
}
} else {
Write-ColorOutput "[预览模式] git fetch upstream" "Gray"
}
# 检查是否有新的提交
$upstreamCommits = & git log HEAD..upstream/master --oneline | ForEach-Object { [System.Text.Encoding]::UTF8.GetString([System.Text.Encoding]::Default.GetBytes($_)) }
if ($upstreamCommits) {
Write-ColorOutput "发现镜像仓库有新的提交:" "Yellow"
$upstreamCommits | ForEach-Object { Write-ColorOutput $_ "Gray" }
if ($UseRebase) {
Write-ColorOutput "正在使用rebase合并代码..." "Green"
if (-not $DryRun) {
git rebase upstream/master
if ($LASTEXITCODE -ne 0) {
Write-ColorOutput "Rebase失败请手动解决冲突后再继续。" "Red"
Write-ColorOutput "解决冲突后请运行: git rebase --continue" "Yellow"
return
}
} else {
Write-ColorOutput "[预览模式] git rebase upstream/master" "Gray"
}
} else {
Write-ColorOutput "正在使用merge合并代码..." "Green"
if (-not $DryRun) {
# 生成自动合并消息
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$mergeMessage = "feat: 同步上游仓库更新 ($timestamp)"
git merge upstream/master --no-edit -m $mergeMessage
if ($LASTEXITCODE -ne 0) {
Write-ColorOutput "Merge失败请手动解决冲突后再继续。" "Red"
Write-ColorOutput "解决冲突后请运行: git commit" "Yellow"
return
}
Write-ColorOutput "使用自动合并消息: $mergeMessage" "Gray"
} else {
Write-ColorOutput "[预览模式] git merge upstream/master --no-edit -m '自动合并消息'" "Gray"
}
}
Write-ColorOutput "同步完成!" "Green"
Write-ColorOutput ""
Write-ColorOutput "下一步操作提示:" "Cyan"
Write-ColorOutput " 请手动推送到你的仓库: git push origin master" "White"
if ($UseRebase) {
Write-ColorOutput " (如果使用了rebase可能需要: git push origin master --force-with-lease)" "Yellow"
}
} else {
Write-ColorOutput "镜像仓库没有新的提交,检查本地是否有未推送的修改..." "Blue"
# 检查是否有本地修改需要推送
$localCommits = & git log origin/master..HEAD --oneline | ForEach-Object { [System.Text.Encoding]::UTF8.GetString([System.Text.Encoding]::Default.GetBytes($_)) }
if ($localCommits) {
Write-ColorOutput "发现本地有未推送的修改:" "Yellow"
$localCommits | ForEach-Object { Write-ColorOutput $_ "Gray" }
Write-ColorOutput ""
Write-ColorOutput "下一步操作提示:" "Cyan"
Write-ColorOutput " 请手动推送到你的仓库: git push origin master" "White"
} else {
Write-ColorOutput "没有需要同步的内容。" "Blue"
}
}
}
function Show-Help {
Write-ColorOutput "Git多仓库同步脚本" "Cyan"
Write-ColorOutput "用法: .\sync-repos.ps1 [选项]" "White"
Write-ColorOutput ""
Write-ColorOutput "选项:" "Yellow"
Write-ColorOutput " -UseRebase 使用rebase代替merge进行合并" "White"
Write-ColorOutput " -DryRun 预览模式不执行实际的git命令" "White"
Write-ColorOutput " -Help 显示此帮助信息" "White"
Write-ColorOutput ""
Write-ColorOutput "示例:" "Yellow"
Write-ColorOutput " .\sync-repos.ps1 # 使用merge同步" "White"
Write-ColorOutput " .\sync-repos.ps1 -UseRebase # 使用rebase同步" "White"
Write-ColorOutput " .\sync-repos.ps1 -DryRun # 预览模式" "White"
Write-ColorOutput ""
Write-ColorOutput "注意: 脚本不会自动推送到你的仓库,需要手动执行推送命令。" "Yellow"
}
# 主程序
if ($args -contains "-Help" -or $args -contains "--help" -or $args -contains "-h") {
Show-Help
return
}
Write-ColorOutput "Git多仓库同步工具" "Cyan"
Write-ColorOutput "===================" "Cyan"
# 显示当前配置
Write-ColorOutput "当前远程仓库配置:" "Blue"
git remote -v
Write-ColorOutput ""
if ($DryRun) {
Write-ColorOutput "预览模式 - 不会执行实际的git命令" "Yellow"
}
if ($UseRebase) {
Write-ColorOutput "使用rebase模式" "Yellow"
} else {
Write-ColorOutput "使用merge模式" "Yellow"
}
Write-ColorOutput ""
Sync-Repositories
# 等待用户按键后再关闭
Write-ColorOutput ""
Write-ColorOutput "按任意键继续..." "Green"
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")